diff --git a/CHANGELOG.md b/CHANGELOG.md index b6288ed..39d541d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed — only `exact` payment options are signed + +- `extractPaymentDetails` took the 402's first option whatever its scheme, and + every caller signs an exact transfer. A gateway listing `upto` or + `batch-settlement` first would have had an exact payment signed against its + terms, against `upto` for the ceiling. It now selects an `exact` option (or one + with no scheme, v1) in any position, and throws when there is none. + ## [3.26.2] - 2026-11-07 ### Fixed — a failed job says what happened to the payment diff --git a/src/x402.ts b/src/x402.ts index 87d5988..f783258 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -605,10 +605,20 @@ export function extractPaymentDetails( extra?: { name?: string; version?: string; feePayer?: string }; resource?: ResourceInfo; } { - const accepts = paymentRequired.accepts || []; - if (accepts.length === 0) { + const offered = paymentRequired.accepts || []; + if (offered.length === 0) { throw new Error("No payment options in payment required response"); } + // Every caller signs an exact transfer. sol.blockrun.ai also offers + // batch-settlement and a Base gateway may offer upto; signing exact against + // those terms is refused at best, and against upto pays its ceiling. An option + // with no scheme is exact (v1). + const accepts = offered.filter((opt) => !opt.scheme || opt.scheme === "exact"); + if (accepts.length === 0) { + throw new Error( + `This client signs the exact scheme, but the 402 only offers ${offered.map((opt) => JSON.stringify(opt.scheme)).join(", ")}` + ); + } // If preferred network specified, try to find matching option let option = null; diff --git a/test/unit/x402.test.ts b/test/unit/x402.test.ts index 5222655..d471b2a 100644 --- a/test/unit/x402.test.ts +++ b/test/unit/x402.test.ts @@ -1,246 +1,267 @@ -import { describe, it, expect } from "vitest"; -import { - createPaymentPayload, - parsePaymentRequired, - extractPaymentDetails, -} from "../../src/x402"; -import { TEST_PRIVATE_KEY, TEST_ACCOUNT, TEST_RECIPIENT } from "../helpers/testHelpers"; - -describe("x402 Payment Protocol", () => { - describe("createPaymentPayload", () => { - it("should create valid payment payload", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453" - ); - - expect(payload).toBeTruthy(); - expect(typeof payload).toBe("string"); - - // Should be base64 - expect(() => atob(payload)).not.toThrow(); - - // Decode and check structure - const decoded = JSON.parse(atob(payload)); - expect(decoded.x402Version).toBe(2); - expect(decoded.payload.signature).toBeTruthy(); - expect(decoded.payload.signature.startsWith("0x")).toBe(true); - expect(decoded.payload.authorization).toBeTruthy(); - expect(decoded.payload.authorization.from).toBe(TEST_ACCOUNT.address); - expect(decoded.payload.authorization.to).toBe(TEST_RECIPIENT); - }); - - it("should attach BlockRun builder-code service code (s)", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453" - ); - - const decoded = JSON.parse(atob(payload)); - expect(decoded.extensions["builder-code"].info.s).toEqual(["blockrun"]); - }); - - it("should preserve server-echoed app code (a) when adding service code", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453", - { extensions: { "builder-code": { info: { a: "blockrun" } } } } - ); - - const decoded = JSON.parse(atob(payload)); - expect(decoded.extensions["builder-code"].info.a).toBe("blockrun"); - expect(decoded.extensions["builder-code"].info.s).toEqual(["blockrun"]); - }); - - it("should include resource info when provided", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453", - { - resourceUrl: "https://api.blockrun.ai/v1/test", - resourceDescription: "Test Resource", - } - ); - - const decoded = JSON.parse(atob(payload)); - expect(decoded.resource.url).toBe("https://api.blockrun.ai/v1/test"); - expect(decoded.resource.description).toBe("Test Resource"); - }); - - it("should set valid time windows", async () => { - const before = Math.floor(Date.now() / 1000); - - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453" - ); - - const after = Math.floor(Date.now() / 1000); - const decoded = JSON.parse(atob(payload)); - const auth = decoded.payload.authorization; - - // Valid after should be in the past (allows clock skew) - expect(parseInt(auth.validAfter)).toBeLessThan(before); - - // Valid before should be in the future - expect(parseInt(auth.validBefore)).toBeGreaterThan(after); - }); - - it("should include accepted payment details", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "5000000", - "eip155:8453" - ); - - const decoded = JSON.parse(atob(payload)); - expect(decoded.accepted.scheme).toBe("exact"); - expect(decoded.accepted.network).toBe("eip155:8453"); - expect(decoded.accepted.amount).toBe("5000000"); - expect(decoded.accepted.payTo).toBe(TEST_RECIPIENT); - }); - - it("should use custom max timeout", async () => { - const payload = await createPaymentPayload( - TEST_PRIVATE_KEY, - TEST_ACCOUNT.address, - TEST_RECIPIENT, - "1000000", - "eip155:8453", - { maxTimeoutSeconds: 600 } - ); - - const decoded = JSON.parse(atob(payload)); - expect(decoded.accepted.maxTimeoutSeconds).toBe(600); - }); - }); - - describe("parsePaymentRequired", () => { - it("should parse valid payment required header", () => { - const data = { - x402Version: 2, - accepts: [ - { - scheme: "exact", - network: "eip155:8453", - amount: "1000000", - asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - payTo: TEST_RECIPIENT, - maxTimeoutSeconds: 300, - }, - ], - }; - - const encoded = btoa(JSON.stringify(data)); - const result = parsePaymentRequired(encoded); - - expect(result.x402Version).toBe(2); - expect(result.accepts).toHaveLength(1); - expect(result.accepts[0].scheme).toBe("exact"); - }); - - it("should throw on invalid base64", () => { - expect(() => parsePaymentRequired("invalid!!!")).toThrow( - "invalid format" - ); - }); - - it("should throw on invalid JSON", () => { - expect(() => parsePaymentRequired(btoa("not json"))).toThrow( - "invalid format" - ); - }); - - it("should throw on missing accepts field", () => { - const data = { x402Version: 2 }; - expect(() => parsePaymentRequired(btoa(JSON.stringify(data)))).toThrow( - "Invalid payment required structure" - ); - }); - - it("should throw on invalid accepts field", () => { - const data = { x402Version: 2, accepts: "not an array" }; - expect(() => parsePaymentRequired(btoa(JSON.stringify(data)))).toThrow( - "Invalid payment required structure" - ); - }); - }); - - describe("extractPaymentDetails", () => { - it("should extract payment details", () => { - const paymentRequired = { - x402Version: 2, - accepts: [ - { - scheme: "exact", - network: "eip155:8453", - amount: "1000000", - asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - payTo: TEST_RECIPIENT, - maxTimeoutSeconds: 300, - extra: { name: "USD Coin", version: "2" }, - }, - ], - resource: { - url: "https://api.blockrun.ai/test", - description: "Test", - }, - }; - - const details = extractPaymentDetails(paymentRequired); - - expect(details.amount).toBe("1000000"); - expect(details.recipient).toBe(TEST_RECIPIENT); - expect(details.network).toBe("eip155:8453"); - expect(details.asset).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); - expect(details.scheme).toBe("exact"); - expect(details.maxTimeoutSeconds).toBe(300); - expect(details.resource?.url).toBe("https://api.blockrun.ai/test"); - }); - - it("should throw on empty accepts array", () => { - expect(() => - extractPaymentDetails({ - x402Version: 2, - accepts: [], - }) - ).toThrow("No payment options"); - }); - - it("should use default timeout if not specified", () => { - const paymentRequired = { - x402Version: 2, - accepts: [ - { - scheme: "exact", - network: "eip155:8453", - amount: "1000000", - asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - payTo: TEST_RECIPIENT, - // maxTimeoutSeconds not specified - }, - ], - }; - - const details = extractPaymentDetails(paymentRequired); - expect(details.maxTimeoutSeconds).toBe(300); - }); - }); -}); +import { describe, it, expect } from "vitest"; +import { + createPaymentPayload, + parsePaymentRequired, + extractPaymentDetails, +} from "../../src/x402"; +import { TEST_PRIVATE_KEY, TEST_ACCOUNT, TEST_RECIPIENT } from "../helpers/testHelpers"; + +describe("x402 Payment Protocol", () => { + describe("createPaymentPayload", () => { + it("should create valid payment payload", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453" + ); + + expect(payload).toBeTruthy(); + expect(typeof payload).toBe("string"); + + // Should be base64 + expect(() => atob(payload)).not.toThrow(); + + // Decode and check structure + const decoded = JSON.parse(atob(payload)); + expect(decoded.x402Version).toBe(2); + expect(decoded.payload.signature).toBeTruthy(); + expect(decoded.payload.signature.startsWith("0x")).toBe(true); + expect(decoded.payload.authorization).toBeTruthy(); + expect(decoded.payload.authorization.from).toBe(TEST_ACCOUNT.address); + expect(decoded.payload.authorization.to).toBe(TEST_RECIPIENT); + }); + + it("should attach BlockRun builder-code service code (s)", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453" + ); + + const decoded = JSON.parse(atob(payload)); + expect(decoded.extensions["builder-code"].info.s).toEqual(["blockrun"]); + }); + + it("should preserve server-echoed app code (a) when adding service code", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453", + { extensions: { "builder-code": { info: { a: "blockrun" } } } } + ); + + const decoded = JSON.parse(atob(payload)); + expect(decoded.extensions["builder-code"].info.a).toBe("blockrun"); + expect(decoded.extensions["builder-code"].info.s).toEqual(["blockrun"]); + }); + + it("should include resource info when provided", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453", + { + resourceUrl: "https://api.blockrun.ai/v1/test", + resourceDescription: "Test Resource", + } + ); + + const decoded = JSON.parse(atob(payload)); + expect(decoded.resource.url).toBe("https://api.blockrun.ai/v1/test"); + expect(decoded.resource.description).toBe("Test Resource"); + }); + + it("should set valid time windows", async () => { + const before = Math.floor(Date.now() / 1000); + + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453" + ); + + const after = Math.floor(Date.now() / 1000); + const decoded = JSON.parse(atob(payload)); + const auth = decoded.payload.authorization; + + // Valid after should be in the past (allows clock skew) + expect(parseInt(auth.validAfter)).toBeLessThan(before); + + // Valid before should be in the future + expect(parseInt(auth.validBefore)).toBeGreaterThan(after); + }); + + it("should include accepted payment details", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "5000000", + "eip155:8453" + ); + + const decoded = JSON.parse(atob(payload)); + expect(decoded.accepted.scheme).toBe("exact"); + expect(decoded.accepted.network).toBe("eip155:8453"); + expect(decoded.accepted.amount).toBe("5000000"); + expect(decoded.accepted.payTo).toBe(TEST_RECIPIENT); + }); + + it("should use custom max timeout", async () => { + const payload = await createPaymentPayload( + TEST_PRIVATE_KEY, + TEST_ACCOUNT.address, + TEST_RECIPIENT, + "1000000", + "eip155:8453", + { maxTimeoutSeconds: 600 } + ); + + const decoded = JSON.parse(atob(payload)); + expect(decoded.accepted.maxTimeoutSeconds).toBe(600); + }); + }); + + describe("parsePaymentRequired", () => { + it("should parse valid payment required header", () => { + const data = { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: TEST_RECIPIENT, + maxTimeoutSeconds: 300, + }, + ], + }; + + const encoded = btoa(JSON.stringify(data)); + const result = parsePaymentRequired(encoded); + + expect(result.x402Version).toBe(2); + expect(result.accepts).toHaveLength(1); + expect(result.accepts[0].scheme).toBe("exact"); + }); + + it("should throw on invalid base64", () => { + expect(() => parsePaymentRequired("invalid!!!")).toThrow( + "invalid format" + ); + }); + + it("should throw on invalid JSON", () => { + expect(() => parsePaymentRequired(btoa("not json"))).toThrow( + "invalid format" + ); + }); + + it("should throw on missing accepts field", () => { + const data = { x402Version: 2 }; + expect(() => parsePaymentRequired(btoa(JSON.stringify(data)))).toThrow( + "Invalid payment required structure" + ); + }); + + it("should throw on invalid accepts field", () => { + const data = { x402Version: 2, accepts: "not an array" }; + expect(() => parsePaymentRequired(btoa(JSON.stringify(data)))).toThrow( + "Invalid payment required structure" + ); + }); + }); + + describe("extractPaymentDetails", () => { + it("selects the exact option whatever order the gateway lists it in", () => { + const details = extractPaymentDetails({ + x402Version: 2, + accepts: [ + { scheme: "upto", network: "eip155:8453", amount: "9000", asset: "0xa", payTo: "0xb", maxTimeoutSeconds: 300 }, + { scheme: "exact", network: "eip155:8453", amount: "1000", asset: "0xa", payTo: "0xb", maxTimeoutSeconds: 300 }, + ], + } as never); + expect(details.amount).toBe("1000"); + expect(details.scheme).toBe("exact"); + }); + + it("refuses a 402 that offers no exact option instead of signing it as one", () => { + expect(() => + extractPaymentDetails({ + x402Version: 2, + accepts: [{ scheme: "batch-settlement", network: "solana:x", amount: "1", asset: "m", payTo: "p", maxTimeoutSeconds: 300 }], + } as never) + ).toThrow(/exact/); + }); + + it("should extract payment details", () => { + const paymentRequired = { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: TEST_RECIPIENT, + maxTimeoutSeconds: 300, + extra: { name: "USD Coin", version: "2" }, + }, + ], + resource: { + url: "https://api.blockrun.ai/test", + description: "Test", + }, + }; + + const details = extractPaymentDetails(paymentRequired); + + expect(details.amount).toBe("1000000"); + expect(details.recipient).toBe(TEST_RECIPIENT); + expect(details.network).toBe("eip155:8453"); + expect(details.asset).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); + expect(details.scheme).toBe("exact"); + expect(details.maxTimeoutSeconds).toBe(300); + expect(details.resource?.url).toBe("https://api.blockrun.ai/test"); + }); + + it("should throw on empty accepts array", () => { + expect(() => + extractPaymentDetails({ + x402Version: 2, + accepts: [], + }) + ).toThrow("No payment options"); + }); + + it("should use default timeout if not specified", () => { + const paymentRequired = { + x402Version: 2, + accepts: [ + { + scheme: "exact", + network: "eip155:8453", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + payTo: TEST_RECIPIENT, + // maxTimeoutSeconds not specified + }, + ], + }; + + const details = extractPaymentDetails(paymentRequired); + expect(details.maxTimeoutSeconds).toBe(300); + }); + }); +});