DOC# X402AM SLUG x402_amount_string_parser PRINTED 2026-05-07 03:37 UTC

x402 Vector 9: amount-string parser fuzzing

x402 amounts travel as JSON strings. "1000", "1e3", " 1000 ", "+1000", "01000" round-trip differently across implementations. Any disagreement between the facilitator's validator and Solana's transfer is monetisable.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/x402_amount_string_parser/
FILED
2026-04-10 15:00 UTC
TIME
6 min read
SERIES
x402 attack surface
TAGS
#security #x402 #solana #parser-differential #research

The x402 spec carries the payment amount as a JSON string, not a number. The reasoning is sound: JSON numbers can’t represent u64 cleanly across JavaScript / Rust / Go runtimes, and stringifying sidesteps the silent precision loss you’d otherwise eat at parse time. The cost of that decision is parser disagreement.

This post is Vector 9 in the SOLMAL series.

The shape of the bug

Two parsers see the same string. They return two different u64s. The facilitator authorises N units, the Solana program transfers M units, and the difference is value the merchant didn’t ask for and the client didn’t intend to send. If M > N the client overpays. If M < N the merchant underbills, and a client who knows the discrepancy can drain the merchant’s product surface for free.

The interesting cases aren’t malformed input — those reject everywhere. The interesting cases are inputs that all parsers accept and disagree about.

Five inputs that round-trip differently

// All of these "look fine" to a human reader.
const cases = [
  '"1000"',     // canonical
  '"1e3"',      // exponent form
  '" 1000 "',   // ASCII whitespace padding
  '"+1000"',    // explicit positive sign
  '"01000"',    // leading zero
];

Run each through five plausible facilitator-side parsers and one Solana-side parser. The disagreement matrix:

"1000""1e3"" 1000 ""+1000""01000"
parseInt10001100010001000
Number()10001000100010001000
BigInt()1000errerr10001000
u64::from_str1000errerrerr1000
serde_json10001000100010001000
Solana SPL1000n/an/an/a1000

parseInt reading "1e3" as 1 is the famous one — it stops at the first non-digit. If the facilitator validates with parseInt(amount, 10) <= max_amount and then forwards the raw string into BigInt(amount) for the SPL transfer, an attacker authorising "1e3" lamports against a 1-lamport limit gets a 1000-lamport transfer authorised at base price.

u64::from_str rejects "+1000" while JavaScript’s Number happily accepts it. Mixed-runtime stacks (TypeScript facilitator, Rust verifier) decline the same payload differently — and any place where the facilitator forwards the raw JSON string instead of round-tripped digits inherits the asymmetry.

The exploit recipe

  1. Discover which parser flavour the facilitator’s validator uses. Most are parseInt or Number.
  2. Discover which parser flavour the settlement path uses. If it forwards raw JSON to the SPL transfer_checked instruction (most do), the parser is serde_json’s string-to-u64 coercion via .parse().
  3. Find an input the validator under-counts and the settler over-counts. "1e3" against parseInt is the canonical example.
  4. Submit amount: "1e3" with a max-allowed price ceiling of, say, 10. Validator: parseInt("1e3", 10) = 1, well under the ceiling. Settler: 1000-lamport transfer.

If you’re attacking from the merchant side instead, the inversion: pick a parser that the validator over-counts. "01000" against an Number() validator that strips leading zeros, against a settler that interprets the leading zero as octal in some legacy library — vanishingly rare, but I caught it in one Go facilitator that used strconv.ParseUint(s, 0, 64) instead of strconv.ParseUint(s, 10, 64). The 0 prefix turns it into octal: "01000" → 512.

Why this is endemic to x402 specifically

Three properties of the spec amplify the risk:

  1. Amounts are strings. Every implementer writes their own coercion. There’s no canonical parse defined by the spec.
  2. Verifier and settler may live in different processes. The facilitator can be a TypeScript Cloudflare Worker calling out to a Rust on-chain settler. Two languages, two parsers, one wire format.
  3. Failed-verify-but-succeeded-settle has no rollback. Once the SPL transfer_checked lands, the lamports moved; the merchant can refuse to deliver the product but most x402 deployments treat the settlement signature as final.

A spec that returned a u64 in a fixed-width binary field would have prevented this entire class of bug. JSON wins because it’s already in every ecosystem; the price is parser-differential exposure.

Mitigations

In rough order of effectiveness:

  1. Round-trip the amount through a canonical form before validation and before settlement. Decode the JSON string with the strictest available parser (u64::from_str rejecting +/e/whitespace), then re-emit the canonical decimal representation, then carry that forward. Both validator and settler see the same string after canonicalisation.
  2. Define a strict grammar in the spec. ^[1-9][0-9]*$ — leading-zero-free decimal digits only. Reject everything else at the wire boundary. Update the spec, ship a conformance test fixture.
  3. Settle the SPL transfer with the parsed u64, not the raw string. Most facilitator code today passes the JSON string directly into the transaction builder. Parse it once, in canonical form, and pass the u64 everywhere downstream.
  4. Conformance fuzzer in CI. A small corpus (the table above plus 200 generated edge cases) run against verify(amount) == settle_amount(amount) for every facilitator implementation. Fail closed on disagreement.
  5. Maximum amount enforcement on-chain. Have the settler wrap the SPL transfer_checked in a custom program that asserts amount <= max_amount_in_payment_signature. Pushes the safety check into a place where the parser is the same on both sides.

(1) is the bare minimum and trivial to ship. (5) is the closest thing to a real fix.

What I’d do if I were operating an x402 facilitator

// Canonicalise on the wire boundary, before any business logic.
function canonicaliseAmount(raw: string): bigint {
  // Reject anything that isn't strict decimal digits.
  if (!/^[1-9][0-9]*$/.test(raw) && raw !== "0") {
    throw new Error(`invalid amount: ${JSON.stringify(raw)}`);
  }
  // BigInt, not Number — Solana amounts can exceed 2^53.
  const n = BigInt(raw);
  // Sanity bound: 2^64 - 1 is the SPL maximum.
  if (n < 0n || n >= 1n << 64n) {
    throw new Error(`out of range: ${raw}`);
  }
  return n;
}

// Use the canonical bigint everywhere downstream.
const amount = canonicaliseAmount(payment.amount);
// validator: amount <= maxAuthorised
// settler: build SPL transfer_checked with amount as u64

Rolling this out across an existing facilitator is one PR. Cost: about 30 lines of code and one rejected-input audit. Worth it.

Bibliography

Previous: AI-agent wallet drain ← · Series: SOLMAL — x402 attack surface.

← Back to article