Four ways wallets silently corrupt cross-chain amounts
In four public Polkadot wallets and transfer apps, the same kind of bug appeared in four different places: the amount that was signed did not match the amount the user meant. None of them threw an error on the happy path. Each is a boundary between representations, and each is easy to check.
1. The amount becomes a JavaScript number
A user types 0.0000001 for an 18-decimal asset. The form parses it with parseFloat, the wallet passes the number to the transfer SDK, and Number serialises it as 1e-7. viem's parseUnits rejects exponential notation, so the build throws; if the fee preview catches the error, the UI simply shows no fee.
The same conversion also rounds: Number('0.123456789012345678') is 0.12345678901234568, so a few base units change before encoding.
Check: the SDK accepts string | number | bigint. Keep the user's decimal string and call parseUnits(amount, decimals) directly. Reject /e/i before conversion as a backstop.
2. The amount is floored through a float
Another wallet computes:
BigInt(Math.floor(input * 10 ** decimals))
At 6 decimals, 2.01 * 10**6 is 2009999.999…, so the encoded amount is 2009999, one base unit short. 0.57 at 10 decimals has the same problem; at 18 decimals the float rounds either way. The input shows 2.01, the signed amount is 2.009999, and the confirmation formats the encoded value with three decimals and floor rounding, so it shows 2.009 and hides the discrepancy.
Check: grep for Math.floor( near 10 ** and for BigInt( near Number(. A string plus parseUnits eliminates the class.
3. The existential-deposit check compares two different assets
A multisig wallet blocks transfers with "Transfer amount does not meet destination existential deposit". The check is:
isEdSufficient: receivableAmount >= destED
destED is the fee asset's existential deposit, not the transferred token's, and the recipient's existing balance is ignored. Transferring 10 USDC on a route whose destination fee is paid in DOT compares 10_000_000 (6 decimals) to 10_000_000_000 (10 decimals) and disables the form. Sending 0.5 DOT to an account that already holds 100 DOT is blocked for the same reason.
Check: compare balance + received - fee against the ED of the transferred asset, and only when the fee asset and the transferred asset match. ParaSpell exposes this as verifyEdOnDestination; use it rather than re-deriving the rule.
4. Only the first transaction of a multi-leg route is submitted
An agent tool calls a router and submits routerPlan[0].tx. The router returns an array: for a cross-chain swap it pushes the origin→exchange transfer first, then the swap (and optional destination transfer). When the single-transaction execute path is filtered at the origin, the plan has two entries, and only the transfer is signed. The asset arrives on the exchange chain, the swap never happens, and the tool reports the first transaction's hash as success.
Check: if an API returns a plan or a batch, submit every entry in order and report success only when all of them succeed. A partial success is a failure with a refund path, not a success.
The shared shape
All four are conversions at a boundary:
- user input → chain units,
- float → integer,
- fee asset → transferred asset,
- plan → submitted transactions.
The arithmetic is never the hard part. The assumption that both sides represent the same thing is. A 20-minute pass that follows one amount from input to signature, and one route from plan to submission, finds more than any linter.
I maintain a checklist built from 117 public XCM bounty reports read as of 25 October 2026, with a zero-dependency scanner for these shapes, included in the pre-flight pack. If you want a fixed-scope review of your own integration, see the audit page.
AI assistance was used to draft this article. Every finding was checked against the cited code and the linked public report before publication.
Want this checked in your integration?
Fixed-scope XCM audits with a failing test or call-path proof for every finding. The first verified finding is free.