diff --git a/.changeset/payment-option-decimals-validation.md b/.changeset/payment-option-decimals-validation.md new file mode 100644 index 00000000..b5f3a7ad --- /dev/null +++ b/.changeset/payment-option-decimals-validation.md @@ -0,0 +1,14 @@ +--- +"@agentcommercekit/ack-pay": minor +--- + +`paymentOptionSchema` now rejects a negative `decimals` instead of normalizing +it. The valibot schema used `toMinValue(0)`, which is a transformation that +clamps rather than a validation that rejects, so `decimals: -2` parsed +successfully as `decimals: 0`. + +`decimals` scales `amount`, so clamping turned an invalid payment option into a +valid one asking for a different sum, and it did so on the verification path as +well: `verifyPaymentRequestToken` parses token payloads with this schema. The +zod schema already rejected the same input via `nonnegative()`, so the two +validators disagreed about which payment requests are well-formed. diff --git a/packages/ack-pay/src/payment-request.test.ts b/packages/ack-pay/src/payment-request.test.ts index 018c78b3..bff3a2ef 100644 --- a/packages/ack-pay/src/payment-request.test.ts +++ b/packages/ack-pay/src/payment-request.test.ts @@ -29,6 +29,19 @@ describe("isPaymentRequest", () => { ).toBe(false) }) + // `decimals` scales `amount`, so a negative value has to be rejected rather + // than normalized: reading it as 0 silently changes what the request asks for. + it("returns false if decimals is negative", () => { + expect( + isPaymentRequest({ + ...validPaymentRequest, + paymentOptions: [ + { ...validPaymentRequest.paymentOptions[0], decimals: -2 }, + ], + }), + ).toBe(false) + }) + it("returns false if given null", () => { expect(isPaymentRequest(null)).toBe(false) }) diff --git a/packages/ack-pay/src/schemas/valibot.ts b/packages/ack-pay/src/schemas/valibot.ts index 06a58f1e..dfb5bcc7 100644 --- a/packages/ack-pay/src/schemas/valibot.ts +++ b/packages/ack-pay/src/schemas/valibot.ts @@ -7,7 +7,7 @@ const urlOrDidUri = v.union([v.pipe(v.string(), v.url()), didUriSchema]) export const paymentOptionSchema = v.object({ id: v.string(), amount: v.union([v.pipe(v.number(), v.integer(), v.gtValue(0)), v.string()]), - decimals: v.pipe(v.number(), v.integer(), v.toMinValue(0)), + decimals: v.pipe(v.number(), v.integer(), v.minValue(0)), currency: v.string(), recipient: v.string(), network: v.optional(v.string()),