diff --git a/.agents/plans/validate-sponsored-micros-inputs.plan.md b/.agents/plans/validate-sponsored-micros-inputs.plan.md new file mode 100644 index 00000000..1ddc4270 --- /dev/null +++ b/.agents/plans/validate-sponsored-micros-inputs.plan.md @@ -0,0 +1,87 @@ +--- +name: validate-sponsored-micros-inputs +overview: "Reject non-finite and non-integer JSON number inputs for sponsored purchase micro-USDC fields with client-error responses before transaction preparation." +todos: + - id: reproduce-invalid-micros-response + content: Add a focused failing regression showing an overflowing JSON numeric micro-USDC value reaches BigInt conversion and throws a generic error + status: completed + - id: validate-numeric-micros + content: Validate numeric inputs before BigInt conversion and preserve the sponsored route's client-error classification + status: completed + - id: cover-helper-and-route-contract + content: Add behavioral helper coverage and route-level no-downstream-side-effect coverage for invalid numeric micro-USDC input + status: completed + - id: verify-focused-change + content: Run focused tests, format, lint, typecheck, full web tests, webpack build, and whitespace checks + status: completed +isProject: false +--- + +# Validate Sponsored Micro-USDC Inputs + +## Goal + +Make `POST /api/transactions/sponsored/purchase/prepare` reject invalid JSON numeric values for micro-USDC fields with a `400` response before sponsored transaction preparation, rather than leaking a `BigInt` conversion failure as a `500`. + +## Scope + +- In scope: sponsored purchase preparation's `expectedPriceUsdcMicros` and `maxSetupFeeUsdcMicros` numeric parsing, its shared `parseNonNegativeBigInt` helper, and focused regression coverage. +- Out of scope: sponsored checkout economics, feature flags, rate-limit policy, transaction-account construction, database changes, wallet behavior, and Base mainnet enablement. + +## Verified Gap (2026-09-02) + +- `web/app/api/transactions/sponsored/purchase/prepare/route.ts:21-26` accepts every JavaScript `number` from JSON and forwards it to `prepareSponsoredPurchase` at lines 64-71. +- `web/lib/sponsoredPurchase.ts:117-124` passes numeric input directly to `BigInt(value)`. Valid JSON `1e309` parses as `Infinity`; `BigInt(Infinity)` throws a `RangeError`. +- The route's handler maps only expected validation messages to `400` (`route.ts:73-83`), so that conversion error becomes a generic `500`. +- Existing `web/__tests__/api/sponsored-transaction-routes.test.ts` covers literal `null` bodies but not invalid numeric micro-USDC values. + +## Files To Change + +- `web/lib/sponsoredPurchase.ts`: reject non-finite, fractional, and unsafe numeric inputs with a stable validation error before invoking `BigInt`. +- `web/app/api/transactions/sponsored/purchase/prepare/route.ts`: classify that stable input-validation error as a `400`. +- `web/__tests__/lib/sponsoredPurchase.test.ts`: add behavioral regressions for invalid numbers at the pure parsing boundary. +- `web/__tests__/api/sponsored-transaction-routes.test.ts`: assert an overflowing numeric request returns `400` and never calls `prepareSponsoredPurchase`. + +## Implementation Steps + +1. Add a route-level RED regression with a valid buyer/listing payload and `expectedPriceUsdcMicros: 1e309`; assert a client error and no transaction helper invocation. +2. Add pure helper regressions for `Infinity`, fractional values, and unsafe numeric integers, while preserving valid integer/string input behavior. +3. Validate numbers with `Number.isSafeInteger` before `BigInt` conversion. Use an error message whose existing route classification consistently returns `400`. +4. Keep strings as the canonical representation for arbitrarily large micro-USDC values, so exact high-value client input remains supported. +5. Re-run focused tests and repository quality gates. + +## Verification + +Run with Node 24 as required by `AGENTS.md`: + +```bash +. "$HOME/.nvm/nvm.sh" --no-use && { nvm use --silent || nvm install; } +npm test --workspace @agentvouch/web -- __tests__/api/sponsored-transaction-routes.test.ts __tests__/lib/sponsoredPurchase.test.ts --maxWorkers=1 --no-fileParallelism +npm run format:check +npm run lint:web +npm run typecheck +npm test --workspace @agentvouch/web -- --maxWorkers=1 --no-fileParallelism +npm exec --workspace @agentvouch/web -- next build --webpack +git diff --check +``` + +Acceptance criteria: invalid numeric JSON values receive a `400` without invoking sponsored transaction preparation; the shared helper fails with a stable validation error before `BigInt`; valid exact string values remain supported. + +## Rollout + +Ship as a narrow API input-hardening PR. No data migration, deployment configuration, wallet transaction, or on-chain state change is required. + +## Execution Note (2026-09-02) + +- RED: `npm test --workspace @agentvouch/web -- __tests__/lib/sponsoredPurchase.test.ts --maxWorkers=1 --no-fileParallelism` failed before the fix because `BigInt(Infinity)` threw `The number Infinity cannot be converted to a BigInt because it is not an integer` instead of a stable validation error. +- The implementation rejects unsafe numeric inputs at the route boundary before `prepareSponsoredPurchase` runs, and the shared parser independently rejects non-finite, fractional, and unsafe numeric values. Exact high-value values remain supported as decimal strings. +- Passed under Node `v24.10.0`: focused sponsored tests (2 files / 27 tests), `npm run format:check`, `npm run lint:web`, `npm run typecheck`, full web Vitest (128 files / 930 tests), `npm exec --workspace @agentvouch/web -- next build --webpack`, and `git diff --check`. +- The build retained the repository's existing `ox`/`viem` dynamic-import warning and expected static-generation `DATABASE_URL` fallback logs. No live sponsored transaction, database, wallet, or deployment flow was run. + +## Rollback + +Revert the focused commit. The patch changes only invalid request handling and associated tests. + +## Blockers + +- None known. Do not broaden the shared parser's accepted types or alter sponsored fee economics. diff --git a/web/__tests__/api/sponsored-transaction-routes.test.ts b/web/__tests__/api/sponsored-transaction-routes.test.ts index 4e597263..af72ec6f 100644 --- a/web/__tests__/api/sponsored-transaction-routes.test.ts +++ b/web/__tests__/api/sponsored-transaction-routes.test.ts @@ -53,6 +53,31 @@ describe("sponsored transaction routes", () => { expect(transactionMocks.prepareSponsoredPurchase).not.toHaveBeenCalled(); }); + it("rejects overflowing numeric micro-USDC fields before preparing a transaction", async () => { + for (const [field, error] of [ + ["expectedPriceUsdcMicros", "Invalid expectedPriceUsdcMicros"], + ["maxSetupFeeUsdcMicros", "Invalid maxSetupFeeUsdcMicros"], + ]) { + const response = await preparePurchase( + new NextRequest( + "http://localhost/api/transactions/sponsored/purchase/prepare", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Forwarded-For": "198.51.100.5", + }, + body: `{"buyerPubkey":"buyer","listingAddress":"listing","${field}":1e309}`, + } + ) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error }); + expect(transactionMocks.prepareSponsoredPurchase).not.toHaveBeenCalled(); + } + }); + it("rejects a null purchase-submit body before submitting a transaction", async () => { const response = await submitPurchase( nullJsonRequest( diff --git a/web/__tests__/lib/sponsoredPurchase.test.ts b/web/__tests__/lib/sponsoredPurchase.test.ts index bdf86b7a..08e57d68 100644 --- a/web/__tests__/lib/sponsoredPurchase.test.ts +++ b/web/__tests__/lib/sponsoredPurchase.test.ts @@ -10,6 +10,7 @@ import { assertSponsoredTransactionSignatures, getSponsoredTransactionDebug, getSponsoredCoreInstructions, + parseNonNegativeBigInt, } from "@/lib/sponsoredPurchase"; import { bufferKoraTokenFee, getSponsoredSponsorMode } from "@/lib/koraSponsor"; import { @@ -47,6 +48,22 @@ describe("assertBuyerIsNotSponsor", () => { }); }); +describe("parseNonNegativeBigInt", () => { + it("rejects unsafe JSON numbers before BigInt conversion", () => { + for (const value of [Infinity, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + parseNonNegativeBigInt(value, "expectedPriceUsdcMicros") + ).toThrow("expectedPriceUsdcMicros must be a non-negative safe integer"); + } + }); + + it("keeps exact string micro-USDC values available", () => { + expect( + parseNonNegativeBigInt("9007199254740993", "expectedPriceUsdcMicros") + ).toBe(9007199254740993n); + }); +}); + describe("Kora sponsor mode helpers", () => { it("defaults to the bespoke server sponsor and accepts explicit Kora mode", () => { delete process.env.AGENTVOUCH_SPONSOR_MODE; diff --git a/web/app/api/transactions/sponsored/purchase/prepare/route.ts b/web/app/api/transactions/sponsored/purchase/prepare/route.ts index 3352cf22..fc186a67 100644 --- a/web/app/api/transactions/sponsored/purchase/prepare/route.ts +++ b/web/app/api/transactions/sponsored/purchase/prepare/route.ts @@ -18,12 +18,15 @@ function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function bigintishOrNull(value: unknown): string | number | bigint | null { - return typeof value === "string" || - typeof value === "number" || - typeof value === "bigint" - ? value - : null; +function bigintishOrNull( + value: unknown, + label: string +): string | number | bigint | null { + if (typeof value === "number") { + if (Number.isSafeInteger(value)) return value; + throw new Error(`Invalid ${label}`); + } + return typeof value === "string" || typeof value === "bigint" ? value : null; } export async function POST(request: NextRequest) { @@ -65,9 +68,15 @@ export async function POST(request: NextRequest) { buyerPubkey, listingAddress, skillDbId: stringOrNull(body.skillDbId), - expectedPriceUsdcMicros: bigintishOrNull(body.expectedPriceUsdcMicros), + expectedPriceUsdcMicros: bigintishOrNull( + body.expectedPriceUsdcMicros, + "expectedPriceUsdcMicros" + ), expectedUsdcMint: stringOrNull(body.expectedUsdcMint), - maxSetupFeeUsdcMicros: bigintishOrNull(body.maxSetupFeeUsdcMicros), + maxSetupFeeUsdcMicros: bigintishOrNull( + body.maxSetupFeeUsdcMicros, + "maxSetupFeeUsdcMicros" + ), }); return NextResponse.json(result); } catch (error: unknown) { diff --git a/web/lib/sponsoredPurchase.ts b/web/lib/sponsoredPurchase.ts index 03c40669..3ca21977 100644 --- a/web/lib/sponsoredPurchase.ts +++ b/web/lib/sponsoredPurchase.ts @@ -119,6 +119,9 @@ export function parseNonNegativeBigInt( label: string ) { if (value === null || value === undefined || value === "") return null; + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`${label} must be a non-negative safe integer`); + } const parsed = BigInt(value); if (parsed < 0n) throw new Error(`${label} must be non-negative`); return parsed;