Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/mean-pants-beam.md
Original file line number Diff line number Diff line change
@@ -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",
}
);
```
157 changes: 157 additions & 0 deletions packages/thirdweb/src/bridge/Webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,4 +619,161 @@ describe("parseIncomingWebhook", () => {
"$ZodError",
);
});

describe("verify", () => {
type VerifyOptions = Parameters<typeof parse>[4];

async function stringifyAndParse(
payload: unknown,
verify?: VerifyOptions,
): Promise<WebhookPayload> {
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<void> {
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}`,
);
});
});
});
});
107 changes: 107 additions & 0 deletions packages/thirdweb/src/bridge/Webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebhookPayload> {
// Get the signature and timestamp from headers
const receivedSignature =
Expand Down Expand Up @@ -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;
}
Loading