Skip to content

Commit a54c1ab

Browse files
committed
SDK: Add verify parameter in Bridge.Webhook.parse function (#8395)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR adds a `verify` parameter to the `Bridge.Webhook.parse` function in the `thirdweb` library, allowing for validation of webhook payloads against specified criteria such as receiver address, destination token address, chain ID, and minimum destination amount. ### Detailed summary - Introduced `verify` parameter in `Bridge.Webhook.parse` function. - Added validation checks for: - `receiverAddress` - `destinationTokenAddress` - `destinationChainId` - `minDestinationAmount` - Enhanced error handling for mismatched values. - Updated tests to cover verification scenarios for both onchain and onramp transactions. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Webhook parsing now supports optional payload verification: enforce receiver address (case-insensitive), destination token, destination chain ID, and minimum destination amount. Verification works for both on-chain and on-ramp payloads and returns descriptive errors on mismatch to help ensure incoming webhooks meet your requirements. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent bea4f01 commit a54c1ab

File tree

3 files changed

+286
-0
lines changed

3 files changed

+286
-0
lines changed

.changeset/mean-pants-beam.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Add `verify` parameter to `Bridge.Webhook.parse` function to validate the payload
6+
7+
### Example
8+
9+
```ts
10+
import { Bridge } from "thirdweb";
11+
12+
const payload = await Bridge.Webhook.parse(
13+
body,
14+
headers,
15+
process.env.WEBHOOK_SECRET,
16+
tolerance,
17+
{
18+
// throw an error if the `payload` doesn't have this receiver address set
19+
receiverAddress: "0x1234567890123456789012345678901234567890",
20+
}
21+
);
22+
```

packages/thirdweb/src/bridge/Webhook.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,4 +619,161 @@ describe("parseIncomingWebhook", () => {
619619
"$ZodError",
620620
);
621621
});
622+
623+
describe("verify", () => {
624+
type VerifyOptions = Parameters<typeof parse>[4];
625+
626+
async function stringifyAndParse(
627+
payload: unknown,
628+
verify?: VerifyOptions,
629+
): Promise<WebhookPayload> {
630+
const timestamp = Math.floor(Date.now() / 1000).toString();
631+
const payloadString =
632+
typeof payload === "string" ? payload : JSON.stringify(payload);
633+
const signature = await generateSignature(timestamp, payloadString);
634+
const headers = {
635+
"x-payload-signature": signature,
636+
"x-timestamp": timestamp,
637+
};
638+
return parse(payloadString, headers, secret, 300, verify);
639+
}
640+
641+
async function expectVerifyFailure(
642+
payload: unknown,
643+
verify: VerifyOptions,
644+
message: string,
645+
): Promise<void> {
646+
await expect(stringifyAndParse(payload, verify)).rejects.toThrow(message);
647+
}
648+
649+
describe("onchain tx", () => {
650+
it("should pass when all verification values match", async () => {
651+
const result = await stringifyAndParse(validPayload, {
652+
receiverAddress: validWebhook.data.receiver,
653+
destinationTokenAddress: validWebhook.data.destinationToken.address,
654+
destinationChainId: validWebhook.data.destinationToken.chainId,
655+
minDestinationAmount: validWebhook.data.destinationAmount,
656+
});
657+
expect(result).toEqual(validWebhook);
658+
});
659+
660+
it("should fail if receiverAddress does not match", async () => {
661+
const expected = "0x0000000000000000000000000000000000000000";
662+
await expectVerifyFailure(
663+
validPayload,
664+
{ receiverAddress: expected },
665+
`Verification Failed: receiverAddress mismatch, Expected: ${expected}, Received: ${validWebhook.data.receiver}`,
666+
);
667+
});
668+
669+
it("should fail if destinationTokenAddress does not match", async () => {
670+
const expected = "0x0000000000000000000000000000000000000001";
671+
await expectVerifyFailure(
672+
validPayload,
673+
{ destinationTokenAddress: expected },
674+
`Verification Failed: destinationTokenAddress mismatch, Expected: ${expected}, Received: ${validWebhook.data.destinationToken.address}`,
675+
);
676+
});
677+
678+
it("should fail if destinationChainId does not match", async () => {
679+
const expected = 137;
680+
await expectVerifyFailure(
681+
validPayload,
682+
{ destinationChainId: expected },
683+
`Verification Failed: destinationChainId mismatch, Expected: ${expected}, Received: ${validWebhook.data.destinationToken.chainId}`,
684+
);
685+
});
686+
687+
it("should fail if minDestinationAmount is greater than actual", async () => {
688+
const expectedMin = validWebhook.data.destinationAmount + 1n;
689+
await expectVerifyFailure(
690+
validPayload,
691+
{ minDestinationAmount: expectedMin },
692+
`Verification Failed: minDestinationAmount, Expected minimum amount to be ${expectedMin}, Received: ${validWebhook.data.destinationAmount}`,
693+
);
694+
});
695+
});
696+
697+
describe("onramp tx", () => {
698+
const onrampWebhook: WebhookPayload = {
699+
data: {
700+
amount: 100n,
701+
currency: "USD",
702+
currencyAmount: 100,
703+
id: "onramp123",
704+
onramp: "moonpay",
705+
paymentLinkId: "plink_123",
706+
purchaseData: {},
707+
receiver: "0x1234567890123456789012345678901234567890",
708+
sender: "0x1234567890123456789012345678901234567890",
709+
status: "COMPLETED",
710+
token: {
711+
address: "0x1234567890123456789012345678901234567890",
712+
chainId: 1,
713+
decimals: 18,
714+
iconUri: "https://example.com/icon.png",
715+
name: "Token",
716+
priceUsd: 1.0,
717+
symbol: "TKN",
718+
},
719+
transactionHash: "0x1234567890123456789012345678901234567890",
720+
},
721+
type: "pay.onramp-transaction",
722+
version: 2,
723+
};
724+
const onrampPayload = {
725+
...onrampWebhook,
726+
data: {
727+
...onrampWebhook.data,
728+
amount: onrampWebhook.data.amount.toString(),
729+
},
730+
};
731+
732+
it("should pass when all verification values match ", async () => {
733+
const result = await stringifyAndParse(onrampPayload, {
734+
receiverAddress: onrampWebhook.data.receiver,
735+
destinationTokenAddress: onrampWebhook.data.token.address,
736+
destinationChainId: onrampWebhook.data.token.chainId,
737+
minDestinationAmount: onrampWebhook.data.amount,
738+
});
739+
expect(result).toEqual(onrampWebhook);
740+
});
741+
742+
it("should fail if destinationTokenAddress does not match ", async () => {
743+
const expected = "0x0000000000000000000000000000000000000002";
744+
await expectVerifyFailure(
745+
onrampPayload,
746+
{ destinationTokenAddress: expected },
747+
`Verification Failed: destinationTokenAddress mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.token.address}`,
748+
);
749+
});
750+
751+
it("should fail if destinationChainId does not match ", async () => {
752+
const expected = 8453;
753+
await expectVerifyFailure(
754+
onrampPayload,
755+
{ destinationChainId: expected },
756+
`Verification Failed: destinationChainId mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.token.chainId}`,
757+
);
758+
});
759+
760+
it("should fail if minDestinationAmount is greater than actual ", async () => {
761+
const expectedMin = onrampWebhook.data.amount + 1n;
762+
await expectVerifyFailure(
763+
onrampPayload,
764+
{ minDestinationAmount: expectedMin },
765+
`Verification Failed: minDestinationAmount, Expected minimum amount to be ${expectedMin}, Received: ${onrampWebhook.data.amount}`,
766+
);
767+
});
768+
769+
it("should fail if receiverAddress does not match", async () => {
770+
const expected = "0x0000000000000000000000000000000000000003";
771+
await expectVerifyFailure(
772+
onrampPayload,
773+
{ receiverAddress: expected },
774+
`Verification Failed: receiverAddress mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.receiver}`,
775+
);
776+
});
777+
});
778+
});
622779
});

packages/thirdweb/src/bridge/Webhook.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,28 @@ export async function parse(
118118
* The tolerance in seconds for the timestamp verification.
119119
*/
120120
tolerance = 300, // Default to 5 minutes if not specified
121+
122+
/**
123+
* Add various validations to the parsed payload to ensure it matches the expected values. Throws error if any validation fails.
124+
*/
125+
verify?: {
126+
/**
127+
* Verify that the payload's the destination token amount (in wei) is greater than `minDestinationAmount` value
128+
*/
129+
minDestinationAmount?: bigint;
130+
/**
131+
* Verify that the payload's destination token address is the same as `destinationTokenAddress` value
132+
*/
133+
destinationTokenAddress?: string;
134+
/**
135+
* Verify that the payload's destination chain id is the same as `destinationChainId` value
136+
*/
137+
destinationChainId?: number;
138+
/**
139+
* Verify that the payload's receiver address is the same as `receiverAddress` value.
140+
*/
141+
receiverAddress?: string;
142+
},
121143
): Promise<WebhookPayload> {
122144
// Get the signature and timestamp from headers
123145
const receivedSignature =
@@ -183,5 +205,90 @@ export async function parse(
183205
);
184206
}
185207

208+
if (verify) {
209+
// verify receiver address
210+
if (verify.receiverAddress) {
211+
if (
212+
parsedPayload.data.receiver.toLowerCase() !==
213+
verify.receiverAddress.toLowerCase()
214+
) {
215+
throw new Error(
216+
`Verification Failed: receiverAddress mismatch, Expected: ${verify.receiverAddress}, Received: ${parsedPayload.data.receiver}`,
217+
);
218+
}
219+
}
220+
221+
// verify destination token address
222+
if (verify.destinationTokenAddress) {
223+
// onchain transaction
224+
if ("destinationToken" in parsedPayload.data) {
225+
if (
226+
parsedPayload.data.destinationToken.address.toLowerCase() !==
227+
verify.destinationTokenAddress.toLowerCase()
228+
) {
229+
throw new Error(
230+
`Verification Failed: destinationTokenAddress mismatch, Expected: ${verify.destinationTokenAddress}, Received: ${parsedPayload.data.destinationToken.address}`,
231+
);
232+
}
233+
}
234+
// onramp transaction
235+
else if ("onramp" in parsedPayload.data) {
236+
if (
237+
parsedPayload.data.token.address.toLowerCase() !==
238+
verify.destinationTokenAddress.toLowerCase()
239+
) {
240+
throw new Error(
241+
`Verification Failed: destinationTokenAddress mismatch, Expected: ${verify.destinationTokenAddress}, Received: ${parsedPayload.data.token.address}`,
242+
);
243+
}
244+
}
245+
}
246+
247+
// verify destination chain id
248+
if (verify.destinationChainId) {
249+
// onchain tx
250+
if ("destinationToken" in parsedPayload.data) {
251+
if (
252+
parsedPayload.data.destinationToken.chainId !==
253+
verify.destinationChainId
254+
) {
255+
throw new Error(
256+
`Verification Failed: destinationChainId mismatch, Expected: ${verify.destinationChainId}, Received: ${parsedPayload.data.destinationToken.chainId}`,
257+
);
258+
}
259+
}
260+
// onramp tx
261+
else if ("onramp" in parsedPayload.data) {
262+
if (parsedPayload.data.token.chainId !== verify.destinationChainId) {
263+
throw new Error(
264+
`Verification Failed: destinationChainId mismatch, Expected: ${verify.destinationChainId}, Received: ${parsedPayload.data.token.chainId}`,
265+
);
266+
}
267+
}
268+
}
269+
270+
// verify amount
271+
if (verify.minDestinationAmount) {
272+
// onchain tx
273+
if ("destinationAmount" in parsedPayload.data) {
274+
if (
275+
parsedPayload.data.destinationAmount < verify.minDestinationAmount
276+
) {
277+
throw new Error(
278+
`Verification Failed: minDestinationAmount, Expected minimum amount to be ${verify.minDestinationAmount}, Received: ${parsedPayload.data.destinationAmount}`,
279+
);
280+
}
281+
}
282+
// onramp tx
283+
else if ("onramp" in parsedPayload.data) {
284+
if (parsedPayload.data.amount < verify.minDestinationAmount) {
285+
throw new Error(
286+
`Verification Failed: minDestinationAmount, Expected minimum amount to be ${verify.minDestinationAmount}, Received: ${parsedPayload.data.amount}`,
287+
);
288+
}
289+
}
290+
}
291+
}
292+
186293
return parsedPayload satisfies WebhookPayload;
187294
}

0 commit comments

Comments
 (0)