diff --git a/.changeset/eleven-taxis-mix.md b/.changeset/eleven-taxis-mix.md new file mode 100644 index 00000000000..01d19cc178d --- /dev/null +++ b/.changeset/eleven-taxis-mix.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Adds onramp webhook parsing for Universal Bridge diff --git a/packages/thirdweb/src/bridge/Status.test.ts b/packages/thirdweb/src/bridge/Status.test.ts index 2f7b6b7ef74..bbab9331fa4 100644 --- a/packages/thirdweb/src/bridge/Status.test.ts +++ b/packages/thirdweb/src/bridge/Status.test.ts @@ -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(); @@ -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(); diff --git a/packages/thirdweb/src/bridge/Webhook.test.ts b/packages/thirdweb/src/bridge/Webhook.test.ts index ac5d21d6d6b..7f23f917c9b 100644 --- a/packages/thirdweb/src/bridge/Webhook.test.ts +++ b/packages/thirdweb/src/bridge/Webhook.test.ts @@ -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, @@ -37,7 +37,7 @@ describe("parseIncomingWebhook", () => { }, developerFeeBps: 100, developerFeeRecipient: "0x1234567890123456789012345678901234567890", - originAmount: "1.0", + originAmount: 10n, originToken: { address: "0x1234567890123456789012345678901234567890" as const, chainId: 1, @@ -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( @@ -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 () => { @@ -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 () => { @@ -149,6 +158,7 @@ describe("parseIncomingWebhook", () => { data: { someField: "value", }, + type: "pay.onchain-transaction", version: 1, }; const v1PayloadString = JSON.stringify(v1Payload); @@ -180,7 +190,7 @@ describe("parseIncomingWebhook", () => { secret, 300, ); - expect(result).toEqual(validPayload); + expect(result).toEqual(validWebhook); }); describe("payload validation", () => { @@ -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; diff --git a/packages/thirdweb/src/bridge/Webhook.ts b/packages/thirdweb/src/bridge/Webhook.ts index 1c9927c3a13..79f74ad63f4 100644 --- a/packages/thirdweb/src/bridge/Webhook.ts +++ b/packages/thirdweb/src/bridge/Webhook.ts @@ -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()), @@ -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, { version: 1 } @@ -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 { // Get the signature and timestamp from headers const receivedSignature = headers["x-payload-signature"] || headers["x-pay-signature"]; @@ -158,5 +183,5 @@ export async function parse( ); } - return parsedPayload; + return parsedPayload satisfies WebhookPayload; }