Token decimals: why an x402 rail stays switched off

An x402 payment requirement carries an atomic amount and an asset address, but no decimals. Why that one missing field keeps non-stablecoin rails off a seller's challenge.

By Matthias Begot ·

The x402 protocol was designed to be multi-asset: accepts is an array, a server can offer several ways to settle the same call, and the agent picks the one it can pay. Yet most sellers in production today ship stablecoin-only rails. The reason is usually not liquidity, and not facilitator support — it is that a payment requirement carries an amount in atomic units and an asset address, and nothing that says how many decimals that asset has. Every consumer supplies the missing exponent from a table of its own. When the table is wrong, the number on the wire is still correct and the number on the screen is off by a power of ten.

This is a note from the seller side: where the gap is in the specification, the two opposite ways it bites, and why the honest response is to keep a rail configured and disabled rather than offer it and hope.

The one field that is not in the challenge

A PaymentRequirements object in the v2 specification has seven fields:

FieldMeaning
schemepayment scheme identifier (e.g. exact)
networkchain identifier in CAIP-2 form
amount”Required payment amount in atomic token units”
asset”Token contract address or ISO 4217 currency code for fiat”
payTorecipient address
maxTimeoutSecondshow long the payment may take
extra”Scheme-specific additional information”

There is no decimals, and no human-readable price. Redacted to its shape, an entry from a live Invoket challenge on /iban/resolve looks like this:

{
  "scheme": "exact",
  "network": "eip155:8453",
  "amount": "<atomic amount>",
  "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "payTo": "<recipient>",
  "maxTimeoutSeconds": 60,
  "extra": { "name": "USD Coin", "version": "2" }
}

extra is the only free-form slot, and it is already spoken for: in the exact EVM scheme it carries assetTransferMethod, plus the name and version of the EIP-712 domain the signature is built over. That is facilitator input, not a display channel. Putting a decimals key there would be a private convention that no client is obliged to read — and a client that does not read it is exactly the client that gets the rendering wrong.

Where consumers get the exponent instead

Three options exist, and none of them is “ask the challenge”.

Read it on-chain. ERC-20 exposes decimals(), but the standard makes it optional, and EIP-20 says so in the strongest available terms: the method “can be used to improve usability, but interfaces and other contracts MUST NOT expect these values to be present.” A consumer that depends on it is depending on a courtesy. It is also an RPC round trip per unknown asset, on the path between receiving a 402 and showing a price — which a directory doing static indexing will simply not do.

Ship a table. This is what actually happens. The x402 documentation publishes default-asset tables with a decimals column per network — USDC on Base at 0x8335…2913, six decimals; USDC on Solana, six. An asset in the table renders correctly. An asset outside it renders with whatever the consumer assumes, and the overwhelmingly common assumption is six.

Assume the symbol implies the unit. This is the one that fails quietly, because it is nearly true. It is not true. The same official tables list stablecoins at other precisions — MegaUSD at eighteen decimals, Mezo USD at eighteen, USDC on Stellar at seven. A ticker is a name, not a unit. Two deployments of the same brand on two chains can disagree, and the challenge gives you no way to tell them apart beyond the address.

The error is always a power of ten

The arithmetic is trivial, which is what makes it dangerous. A consumer that assumes six decimals renders:

displayed value = true value × 10(actual decimals − 6)

Asset decimalsRendered by a 6-decimal consumer
6correct
710× too high
8100× too high
181,000,000,000,000× too high

That last row is not hypothetical. Invoket configured four rails — USDC on Base, USDC on Solana, and the tokenised equivalents cbBTC and WETH on Base, since BTC and ETH native are out of protocol reach for a token-based scheme. In June 2026 a public x402 directory listed our endpoints at a seven-figure dollar price, because it was reading the eighteen-decimal WETH amount with the six-decimal convention. The number was right. The unit was missing.

Note what does not happen. There is no 4xx, no validation failure, no facilitator complaint. The atomic string on the wire is correct, the signature is over the correct value, and a settlement would transfer exactly the intended amount. The only broken layer is the one where a decision gets made.

The 6-decimal assumption is baked into reference code

It is tempting to file this as one directory’s bug. It is not. The assumption travels as a numeric constant through integration code that is otherwise exemplary. Stripe’s own x402 guide records settled payments with this snippet:

// requirements.amount is in atomic USDC units (6 decimals).
// $0.01 = 10000 atomic units. Convert to cents for Stripe.
const amountInCents = Math.round(Number(requirements.amount) / 10000);

That is correct — for the asset that sample offers. Point the same handler at an eighteen-decimal asset and it books a PaymentIntent a trillion times too large, in your own accounting, with no error anywhere. The comment is honest about the assumption; the constant is what survives into production, and constants do not carry units.

The same comfort exists on our side of the wire. At six decimals against a one-dollar unit, micro-USD are atomic units: the conversion from price to amount is the identity function, and it stays the identity function through every test you write. It stops being the identity the moment decimals differ from six or the unit is not worth a dollar — and by then the assumption has spread.

The opposite failure: too few decimals for a valuable unit

Display is only half of it. Decimals also define the price grid, and a rail can be rendered perfectly and still be unusable for micropayments.

The usable granularity of a rail is:

smallest expressible amount = unit price ÷ 10decimals

cbBTC follows Bitcoin with eight decimals, and one unit is worth tens of thousands of dollars. Take an illustrative rate of $67,123.45 per unit — the granularity is one satoshi, about $0.00067. USDC’s granularity is $0.000001. The Bitcoin-shaped rail is roughly 670× coarser, which is invisible for a $50 call and decisive for a sub-cent one.

Work an illustrative half-cent price through it: $0.005 ÷ $67,123.45 = 7.449 satoshis. You cannot charge 7.449 atoms. Rounding up — the safe direction for a seller, and what our gateway does — bills 8, a 7.4% overcharge created by nothing but the unit. Round down instead and you have quietly given away the same 7.4%. There is no third option; at that granularity the price is not really yours to set.

So the two disabled rails fail for opposite reasons, and only one of them is a rendering problem:

RailDecimalsFailure mode
USDC (Base, Solana)6none — granularity and rendering both comfortable
cbBTC8quantisation: percent-level rounding on a micro-price
WETH18rendering: atomic string misread as six decimals

Both remain enabled = false in the gateway configuration today, which is why the live /catalog shows stablecoin rails only. That is a deliberate presentation choice, not a missing feature.

What a seller should actually do

Test the consumer surface, not your own. A challenge can be fully spec-compliant and still misprice you everywhere it is quoted. The check that matters is what your listing looks like in a directory and in a client, not whether your 402 validates.

Publish the human price out of band, in a field with a documented unit. The catalog carries a metadata.priceUsd per item alongside the accepts array; the atomic amount stays authoritative for signing, and the USD figure exists so that something readable is available to whoever needs to render one. Both are served from the gateway — never from an article, and never from a hardcoded copy.

Disable, do not delete. The cbBTC and WETH rails stay in version control with their addresses, symbols, decimals and rate mode intact, behind a boolean. When the display chain is fixed end to end, re-enabling is a one-line change, not a re-implementation.

Degrade per rail, never per challenge. A rail whose exchange rate is unavailable is dropped from that challenge and logged; the stablecoin rails still go out. An agent should never see a 402 with an empty accepts array because one oracle was slow.

Grep your own code for atomic constants. Any literal that encodes a decimal exponent — a divisor, a dust threshold, a minimum — is a rail lock-in that will not announce itself when you add an asset.

Why a wrong rail is worse than a missing rail

The reflex is that more rails is more optionality, so an imperfectly rendered rail is still better than none. For agentic payments it is the reverse, and the reason is consent.

An autonomous payer decides with a spend cap and a check of the challenge amount against the published price. Both are enforced against a rendered value. A rail that renders twelve orders of magnitude high trips every cap and is functionally dead; the mirror case — an asset with fewer decimals than assumed — renders low and sails through a cap it should have hit. The signature is over the atomic amount either way, so nothing on-chain is wrong. What is wrong is that the agent approved a number nobody displayed correctly.

That is the whole argument for restraint. Offer only the rails whose entire chain — facilitator, directory, client — renders correctly today, and keep the rest configured and off. The custody-side counterpart of this discipline, where caps and challenge verification actually live, is covered in managed agent wallets; the shape of the challenge itself, and its size budget, in the 16 KB ceiling.

What would change the answer

One field would close this: a decimals (or a canonical human-readable amount) alongside amount in the requirement, normative rather than conventional, so that a consumer can render any asset it has never seen without an RPC call and without a lookup table. Until something like it lands, multi-asset x402 works for assets that are already in everyone’s table, and is a presentation risk for everything else.

The rails, assets and amounts actually offered at any moment come from the gateway and the catalog — the accepted networks and assets are described in Payments and rails, the full loop in the Quickstart, and the machine-facing surfaces in For agents. Nothing in this article is a price.

Sources

  • x402 v2 specification, PaymentRequirements fields and atomic-unit amountcoinbase/x402 specs.
  • extra contents for the exact EVM scheme (assetTransferMethod, EIP-712 name / version) — scheme_exact_evm.md.
  • Default asset tables with per-network decimalsdocs.x402.org: Networks & token support.
  • decimals() declared OPTIONAL, with “interfaces and other contracts MUST NOT expect these values to be present” — EIP-20.
  • Six-decimal constant in reference integration code — Stripe: x402 payments.
  • cbBTC as an 8-decimal ERC-20 backed 1:1 by BTC — Coinbase: cbBTC is now live.
  • Rail configuration and live rendering: Invoket gateway and /catalog, 13 August 2026.