Skip to content
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
87 changes: 87 additions & 0 deletions .agents/plans/validate-sponsored-micros-inputs.plan.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions web/__tests__/api/sponsored-transaction-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions web/__tests__/lib/sponsoredPurchase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
assertSponsoredTransactionSignatures,
getSponsoredTransactionDebug,
getSponsoredCoreInstructions,
parseNonNegativeBigInt,
} from "@/lib/sponsoredPurchase";
import { bufferKoraTokenFee, getSponsoredSponsorMode } from "@/lib/koraSponsor";
import {
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 17 additions & 8 deletions web/app/api/transactions/sponsored/purchase/prepare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions web/lib/sponsoredPurchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading