Skip to content

Feature: Adds onramp webhook schema to sdk #7450

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 26, 2025
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
5 changes: 5 additions & 0 deletions .changeset/eleven-taxis-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Adds onramp webhook parsing for Universal Bridge
6 changes: 3 additions & 3 deletions packages/thirdweb/src/bridge/Status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ describe.runIf(process.env.TW_SECRET_KEY)("Bridge.status", () => {
// TODO: flaky test
it.skip("should handle successful status", async () => {
const result = await status({
chainId: 137,
chainId: 8453,
client: TEST_CLIENT,
transactionHash:
"0x5959b9321ec581640db531b80bac53cbd968f3d34fc6cb1d5f4ea75f26df2ad7",
"0x8e8ab7c998bdfef6e10951c801a862373ce87af62c21fb870e62fca57683bf10",
});

expect(result).toBeDefined();
Expand Down Expand Up @@ -46,7 +46,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("Bridge.status", () => {
chain: defineChain(8453),
client: TEST_CLIENT,
transactionHash:
"0x06ac91479b3ea4c6507f9b7bff1f2d5f553253fa79af9a7db3755563b60f7dfb",
"0x8e8ab7c998bdfef6e10951c801a862373ce87af62c21fb870e62fca57683bf10",
});

expect(result).toBeDefined();
Expand Down
23 changes: 17 additions & 6 deletions packages/thirdweb/src/bridge/Webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ const generateSignature = async (

describe("parseIncomingWebhook", () => {
const testTimestamp = Math.floor(Date.now() / 1000).toString();
const validPayload: WebhookPayload = {
const validWebhook: WebhookPayload = {
data: {
action: "TRANSFER",
clientId: "client123",
destinationAmount: "1.0",
destinationAmount: 10n,
destinationToken: {
address: "0x1234567890123456789012345678901234567890" as const,
chainId: 1,
Expand All @@ -37,7 +37,7 @@ describe("parseIncomingWebhook", () => {
},
developerFeeBps: 100,
developerFeeRecipient: "0x1234567890123456789012345678901234567890",
originAmount: "1.0",
originAmount: 10n,
originToken: {
address: "0x1234567890123456789012345678901234567890" as const,
chainId: 1,
Expand All @@ -64,8 +64,17 @@ describe("parseIncomingWebhook", () => {
],
type: "transfer",
},
type: "pay.onchain-transaction",
version: 2,
};
const validPayload = {
...validWebhook,
data: {
...validWebhook.data,
destinationAmount: validWebhook.data.destinationAmount.toString(),
originAmount: validWebhook.data.originAmount.toString(),
},
};

it("should successfully verify a valid webhook", async () => {
const signature = await generateSignature(
Expand All @@ -78,7 +87,7 @@ describe("parseIncomingWebhook", () => {
};

const result = await parse(JSON.stringify(validPayload), headers, secret);
expect(result).toEqual(validPayload);
expect(result).toEqual(validWebhook);
});

it("should accept alternative header names", async () => {
Expand All @@ -92,7 +101,7 @@ describe("parseIncomingWebhook", () => {
};

const result = await parse(JSON.stringify(validPayload), headers, secret);
expect(result).toEqual(validPayload);
expect(result).toEqual(validWebhook);
});

it("should throw error for missing headers", async () => {
Expand Down Expand Up @@ -149,6 +158,7 @@ describe("parseIncomingWebhook", () => {
data: {
someField: "value",
},
type: "pay.onchain-transaction",
version: 1,
};
const v1PayloadString = JSON.stringify(v1Payload);
Expand Down Expand Up @@ -180,7 +190,7 @@ describe("parseIncomingWebhook", () => {
secret,
300,
);
expect(result).toEqual(validPayload);
expect(result).toEqual(validWebhook);
});

describe("payload validation", () => {
Expand Down Expand Up @@ -575,6 +585,7 @@ describe("parseIncomingWebhook", () => {

it("should throw error for version 1 payload missing data object", async () => {
const invalidPayload = {
type: "pay.onchain-transaction",
version: 1,
// no data field
} as unknown as WebhookPayload;
Expand Down
73 changes: 49 additions & 24 deletions packages/thirdweb/src/bridge/Webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,32 @@ const hexSchema = z
const addressSchema = z
.string()
.check(z.refine(isAddress, { message: "Invalid address" }));

const webhookSchema = z.union([
const tokenSchema = z.object({
address: addressSchema,
chainId: z.coerce.number(),
decimals: z.coerce.number(),
iconUri: z.optional(z.string()),
name: z.string(),
priceUsd: z.coerce.number(),
symbol: z.string(),
});

const onchainWebhookSchema = z.discriminatedUnion("version", [
z.object({
data: z.object({}),
type: z.literal("pay.onchain-transaction"),
version: z.literal(1),
}),
z.object({
data: z.object({
action: z.enum(["TRANSFER", "BUY", "SELL"]),
clientId: z.string(),
destinationAmount: z.string(),
destinationToken: z.object({
address: addressSchema,
chainId: z.coerce.number(),
decimals: z.coerce.number(),
iconUri: z.optional(z.string()),
name: z.string(),
priceUsd: z.coerce.number(),
symbol: z.string(),
}),
destinationAmount: z.coerce.bigint(),
destinationToken: tokenSchema,
developerFeeBps: z.coerce.number(),
developerFeeRecipient: addressSchema,
originAmount: z.string(),
originToken: z.object({
address: addressSchema,
chainId: z.coerce.number(),
decimals: z.coerce.number(),
iconUri: z.optional(z.string()),
name: z.string(),
priceUsd: z.coerce.number(),
symbol: z.string(),
}),
originAmount: z.coerce.bigint(),
originToken: tokenSchema,
paymentId: z.string(),
// only exists when the payment was triggered from a developer specified payment link
paymentLinkId: z.optional(z.string()),
Expand All @@ -55,10 +49,41 @@ const webhookSchema = z.union([
),
type: z.string(),
}),
type: z.literal("pay.onchain-transaction"),
version: z.literal(2),
}),
]);

const onrampWebhookSchema = z.discriminatedUnion("version", [
z.object({
data: z.object({}),
type: z.literal("pay.onramp-transaction"),
version: z.literal(1),
}),
z.object({
data: z.object({
amount: z.coerce.bigint(),
currency: z.string(),
currencyAmount: z.number(),
id: z.string(),
onramp: z.string(),
paymentLinkId: z.optional(z.string()),
purchaseData: z.unknown(),
receiver: z.optional(addressSchema),
sender: z.optional(addressSchema),
status: z.enum(["PENDING", "COMPLETED", "FAILED"]),
token: tokenSchema,
transactionHash: z.optional(hexSchema),
}),
type: z.literal("pay.onramp-transaction"),
version: z.literal(2),
}),
]);

const webhookSchema = z.discriminatedUnion("type", [
onchainWebhookSchema,
onrampWebhookSchema,
]);
export type WebhookPayload = Exclude<
z.infer<typeof webhookSchema>,
{ version: 1 }
Expand Down Expand Up @@ -93,7 +118,7 @@ export async function parse(
* The tolerance in seconds for the timestamp verification.
*/
tolerance = 300, // Default to 5 minutes if not specified
) {
): Promise<WebhookPayload> {
// Get the signature and timestamp from headers
const receivedSignature =
headers["x-payload-signature"] || headers["x-pay-signature"];
Expand Down Expand Up @@ -158,5 +183,5 @@ export async function parse(
);
}

return parsedPayload;
return parsedPayload satisfies WebhookPayload;
}
Loading