Lab: send a trillion dollars by mistake
Convert amounts between human and atomic units, get the decimals wrong in both directions, and read what you actually sent.
The x402 lesson mentioned that amounts are atomic units and that USDC has six decimals. That is a one-line fact with a very wide blast radius, because getting it wrong does not error, it succeeds at the wrong magnitude.
- 1
Write the conversion both ways and make three attempts at sending 1.5 USDC.
bashcat > /tmp/units.py <<'EOF' DECIMALS = {"ETH": 18, "USDC": 6} to_atomic = lambda amount, sym: int(amount * 10 ** DECIMALS[sym]) fmt = lambda atomic, sym: atomic / 10 ** DECIMALS[sym] print("intent: send 1.5 USDC\n") correct = to_atomic(1.5, "USDC") print(f" correct: {correct:>22} -> {fmt(correct, 'USDC')} USDC") naive = int(1.5) # forgot to convert at all print(f" human value passed: {naive:>22} -> {fmt(naive, 'USDC')} USDC") wrong = to_atomic(1.5, "ETH") # reused the ETH decimals print(f" 18 decimals on USDC:{wrong:>22} -> {fmt(wrong, 'USDC'):,.0f} USDC") EOF python3 /tmp/units.py - 2
Two of the three succeed and neither sends what you meant.
textintent: send 1.5 USDC correct: 1500000 -> 1.5 USDC human value passed: 1 -> 1e-06 USDC 18 decimals on USDC: 1500000000000000000 -> 1,500,000,000,000 USDC - 3
Clean up.
bashrm /tmp/units.py
Neither mistake is a crash. One sends dust, which you notice eventually and which costs a fee; the other attempts to send one and a half trillion USDC, which fails on balance rather than on validation and which would not fail at all if the balance were large enough.
- Decimals vary per token, not per chain. USDC uses 6, ETH uses 18, WBTC uses 8, and hardcoding one of them is how a working integration breaks on the second asset.
- A value cap denominated in atomic units has the same problem. A guardrail written for 18 decimals is a thousand billion times looser when applied to a 6-decimal token.
- Float arithmetic makes it worse. 0.1 + 0.2 is not 0.3, and converting a float to atomic units can round into the wrong integer, which is why production code uses integer or decimal types throughout.
Read the amount back in human units before signing and compare it to what you intended. It is one line and it catches every one of these, which is why simulation and typed-data guardrails matter: they let a human or a policy see the amount in a form that can be checked.
This is also why the x402 challenge validation pins the asset contract rather than the symbol. Two tokens can both call themselves USDC and use different decimals.
Check your understanding
3 questionsWhy is passing the human amount straight through so hard to catch?
What goes wrong when a value cap is written in atomic units for the wrong token?
Why does x402 challenge validation pin the asset contract rather than the symbol?