From a54c1aba350bf1672b2c3fc1ffc702cf2e36bb49 Mon Sep 17 00:00:00 2001 From: MananTank Date: Tue, 11 Nov 2025 23:20:42 +0000 Subject: [PATCH] SDK: Add verify parameter in Bridge.Webhook.parse function (#8395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## 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}` ## 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. --- .changeset/mean-pants-beam.md | 22 +++ packages/thirdweb/src/bridge/Webhook.test.ts | 157 +++++++++++++++++++ packages/thirdweb/src/bridge/Webhook.ts | 107 +++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 .changeset/mean-pants-beam.md diff --git a/.changeset/mean-pants-beam.md b/.changeset/mean-pants-beam.md new file mode 100644 index 00000000000..6cb95c6d591 --- /dev/null +++ b/.changeset/mean-pants-beam.md @@ -0,0 +1,22 @@ +--- +"thirdweb": patch +--- + +Add `verify` parameter to `Bridge.Webhook.parse` function to validate the payload + +### Example + +```ts +import { Bridge } from "thirdweb"; + +const payload = await Bridge.Webhook.parse( + body, + headers, + process.env.WEBHOOK_SECRET, + tolerance, + { + // throw an error if the `payload` doesn't have this receiver address set + receiverAddress: "0x1234567890123456789012345678901234567890", + } +); +``` diff --git a/packages/thirdweb/src/bridge/Webhook.test.ts b/packages/thirdweb/src/bridge/Webhook.test.ts index 7f23f917c9b..ef1349444ec 100644 --- a/packages/thirdweb/src/bridge/Webhook.test.ts +++ b/packages/thirdweb/src/bridge/Webhook.test.ts @@ -619,4 +619,161 @@ describe("parseIncomingWebhook", () => { "$ZodError", ); }); + + describe("verify", () => { + type VerifyOptions = Parameters[4]; + + async function stringifyAndParse( + payload: unknown, + verify?: VerifyOptions, + ): Promise { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const payloadString = + typeof payload === "string" ? payload : JSON.stringify(payload); + const signature = await generateSignature(timestamp, payloadString); + const headers = { + "x-payload-signature": signature, + "x-timestamp": timestamp, + }; + return parse(payloadString, headers, secret, 300, verify); + } + + async function expectVerifyFailure( + payload: unknown, + verify: VerifyOptions, + message: string, + ): Promise { + await expect(stringifyAndParse(payload, verify)).rejects.toThrow(message); + } + + describe("onchain tx", () => { + it("should pass when all verification values match", async () => { + const result = await stringifyAndParse(validPayload, { + receiverAddress: validWebhook.data.receiver, + destinationTokenAddress: validWebhook.data.destinationToken.address, + destinationChainId: validWebhook.data.destinationToken.chainId, + minDestinationAmount: validWebhook.data.destinationAmount, + }); + expect(result).toEqual(validWebhook); + }); + + it("should fail if receiverAddress does not match", async () => { + const expected = "0x0000000000000000000000000000000000000000"; + await expectVerifyFailure( + validPayload, + { receiverAddress: expected }, + `Verification Failed: receiverAddress mismatch, Expected: ${expected}, Received: ${validWebhook.data.receiver}`, + ); + }); + + it("should fail if destinationTokenAddress does not match", async () => { + const expected = "0x0000000000000000000000000000000000000001"; + await expectVerifyFailure( + validPayload, + { destinationTokenAddress: expected }, + `Verification Failed: destinationTokenAddress mismatch, Expected: ${expected}, Received: ${validWebhook.data.destinationToken.address}`, + ); + }); + + it("should fail if destinationChainId does not match", async () => { + const expected = 137; + await expectVerifyFailure( + validPayload, + { destinationChainId: expected }, + `Verification Failed: destinationChainId mismatch, Expected: ${expected}, Received: ${validWebhook.data.destinationToken.chainId}`, + ); + }); + + it("should fail if minDestinationAmount is greater than actual", async () => { + const expectedMin = validWebhook.data.destinationAmount + 1n; + await expectVerifyFailure( + validPayload, + { minDestinationAmount: expectedMin }, + `Verification Failed: minDestinationAmount, Expected minimum amount to be ${expectedMin}, Received: ${validWebhook.data.destinationAmount}`, + ); + }); + }); + + describe("onramp tx", () => { + const onrampWebhook: WebhookPayload = { + data: { + amount: 100n, + currency: "USD", + currencyAmount: 100, + id: "onramp123", + onramp: "moonpay", + paymentLinkId: "plink_123", + purchaseData: {}, + receiver: "0x1234567890123456789012345678901234567890", + sender: "0x1234567890123456789012345678901234567890", + status: "COMPLETED", + token: { + address: "0x1234567890123456789012345678901234567890", + chainId: 1, + decimals: 18, + iconUri: "https://example.com/icon.png", + name: "Token", + priceUsd: 1.0, + symbol: "TKN", + }, + transactionHash: "0x1234567890123456789012345678901234567890", + }, + type: "pay.onramp-transaction", + version: 2, + }; + const onrampPayload = { + ...onrampWebhook, + data: { + ...onrampWebhook.data, + amount: onrampWebhook.data.amount.toString(), + }, + }; + + it("should pass when all verification values match ", async () => { + const result = await stringifyAndParse(onrampPayload, { + receiverAddress: onrampWebhook.data.receiver, + destinationTokenAddress: onrampWebhook.data.token.address, + destinationChainId: onrampWebhook.data.token.chainId, + minDestinationAmount: onrampWebhook.data.amount, + }); + expect(result).toEqual(onrampWebhook); + }); + + it("should fail if destinationTokenAddress does not match ", async () => { + const expected = "0x0000000000000000000000000000000000000002"; + await expectVerifyFailure( + onrampPayload, + { destinationTokenAddress: expected }, + `Verification Failed: destinationTokenAddress mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.token.address}`, + ); + }); + + it("should fail if destinationChainId does not match ", async () => { + const expected = 8453; + await expectVerifyFailure( + onrampPayload, + { destinationChainId: expected }, + `Verification Failed: destinationChainId mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.token.chainId}`, + ); + }); + + it("should fail if minDestinationAmount is greater than actual ", async () => { + const expectedMin = onrampWebhook.data.amount + 1n; + await expectVerifyFailure( + onrampPayload, + { minDestinationAmount: expectedMin }, + `Verification Failed: minDestinationAmount, Expected minimum amount to be ${expectedMin}, Received: ${onrampWebhook.data.amount}`, + ); + }); + + it("should fail if receiverAddress does not match", async () => { + const expected = "0x0000000000000000000000000000000000000003"; + await expectVerifyFailure( + onrampPayload, + { receiverAddress: expected }, + `Verification Failed: receiverAddress mismatch, Expected: ${expected}, Received: ${onrampWebhook.data.receiver}`, + ); + }); + }); + }); }); diff --git a/packages/thirdweb/src/bridge/Webhook.ts b/packages/thirdweb/src/bridge/Webhook.ts index 1f438258874..224762008aa 100644 --- a/packages/thirdweb/src/bridge/Webhook.ts +++ b/packages/thirdweb/src/bridge/Webhook.ts @@ -118,6 +118,28 @@ export async function parse( * The tolerance in seconds for the timestamp verification. */ tolerance = 300, // Default to 5 minutes if not specified + + /** + * Add various validations to the parsed payload to ensure it matches the expected values. Throws error if any validation fails. + */ + verify?: { + /** + * Verify that the payload's the destination token amount (in wei) is greater than `minDestinationAmount` value + */ + minDestinationAmount?: bigint; + /** + * Verify that the payload's destination token address is the same as `destinationTokenAddress` value + */ + destinationTokenAddress?: string; + /** + * Verify that the payload's destination chain id is the same as `destinationChainId` value + */ + destinationChainId?: number; + /** + * Verify that the payload's receiver address is the same as `receiverAddress` value. + */ + receiverAddress?: string; + }, ): Promise { // Get the signature and timestamp from headers const receivedSignature = @@ -183,5 +205,90 @@ export async function parse( ); } + if (verify) { + // verify receiver address + if (verify.receiverAddress) { + if ( + parsedPayload.data.receiver.toLowerCase() !== + verify.receiverAddress.toLowerCase() + ) { + throw new Error( + `Verification Failed: receiverAddress mismatch, Expected: ${verify.receiverAddress}, Received: ${parsedPayload.data.receiver}`, + ); + } + } + + // verify destination token address + if (verify.destinationTokenAddress) { + // onchain transaction + if ("destinationToken" in parsedPayload.data) { + if ( + parsedPayload.data.destinationToken.address.toLowerCase() !== + verify.destinationTokenAddress.toLowerCase() + ) { + throw new Error( + `Verification Failed: destinationTokenAddress mismatch, Expected: ${verify.destinationTokenAddress}, Received: ${parsedPayload.data.destinationToken.address}`, + ); + } + } + // onramp transaction + else if ("onramp" in parsedPayload.data) { + if ( + parsedPayload.data.token.address.toLowerCase() !== + verify.destinationTokenAddress.toLowerCase() + ) { + throw new Error( + `Verification Failed: destinationTokenAddress mismatch, Expected: ${verify.destinationTokenAddress}, Received: ${parsedPayload.data.token.address}`, + ); + } + } + } + + // verify destination chain id + if (verify.destinationChainId) { + // onchain tx + if ("destinationToken" in parsedPayload.data) { + if ( + parsedPayload.data.destinationToken.chainId !== + verify.destinationChainId + ) { + throw new Error( + `Verification Failed: destinationChainId mismatch, Expected: ${verify.destinationChainId}, Received: ${parsedPayload.data.destinationToken.chainId}`, + ); + } + } + // onramp tx + else if ("onramp" in parsedPayload.data) { + if (parsedPayload.data.token.chainId !== verify.destinationChainId) { + throw new Error( + `Verification Failed: destinationChainId mismatch, Expected: ${verify.destinationChainId}, Received: ${parsedPayload.data.token.chainId}`, + ); + } + } + } + + // verify amount + if (verify.minDestinationAmount) { + // onchain tx + if ("destinationAmount" in parsedPayload.data) { + if ( + parsedPayload.data.destinationAmount < verify.minDestinationAmount + ) { + throw new Error( + `Verification Failed: minDestinationAmount, Expected minimum amount to be ${verify.minDestinationAmount}, Received: ${parsedPayload.data.destinationAmount}`, + ); + } + } + // onramp tx + else if ("onramp" in parsedPayload.data) { + if (parsedPayload.data.amount < verify.minDestinationAmount) { + throw new Error( + `Verification Failed: minDestinationAmount, Expected minimum amount to be ${verify.minDestinationAmount}, Received: ${parsedPayload.data.amount}`, + ); + } + } + } + } + return parsedPayload satisfies WebhookPayload; }