Seven ways an XCM transfer fails without an error
A cross-chain transfer is a chain of assumptions: that the amount survived every decimal conversion, that the fee asset is the one the user chose, that the destination balance check used the right baseline, and that the client it was built on is still alive. When one of those is wrong, nothing throws. You get a valid-looking transaction with the wrong numbers.
These seven mechanisms are drawn from 117 public bounty reports read as of 25 October 2026 against a production XCM SDK. Every one shipped, passed review, and reached users.
1. The fee asset is silently replaced
On Hydration, the SDK asks the chain which asset the account pays fees in (MultiTransactionPayment.AccountCurrencyMap) and uses that for the quote. If the caller passed an explicit feeAsset, the explicit value must win. In one SDK implementation it did; in two others the guard was missing, so the account default overrode the caller.
The bug hid behind an incomplete fix: the first implementation got || feeAsset, the siblings were not updated. Three SDKs, two different answers for identical input.
Check: find every fee-asset resolver. Confirm an explicit asset short-circuits the account lookup on every implementation, not just the one you use.
2. Slippage is a shared option some implementations ignore
slippagePct sits in the shared options type. One exchange reads it and applies a haircut to the quote; another returns the raw quote no matter what the user sets. The quote and the execution then disagree by exactly the amount the user thought they were protected against.
Check: for each exchange implementation, assert that the quote changes when slippage changes. A test that passes '5', '0', and '200' and gets the same number is a failing test.
3. A placeholder becomes the signed fee amount
When an origin has no DryRunApi, fee computation can return null. If the final call is built with that null, and the builder treats null as "this is the throwaway estimation pass", the signed message carries the estimation placeholder: one whole fee token, BuyExecution of half of it, and an AllCounted filter. The code comment calls it a dummy call "later dryRunned". On these chains it is signed.
Check: distinguish "fees unavailable" from "fee-estimation pass" with an explicit type, and test the no-DryRunApi path all the way to the serialized call.
4. Cloned clients are never released
clone() + init() leases a pooled connection and increments a reference count. Only disconnect() decrements it. A helper that clones, reads a balance, and returns without disconnecting leaves the pool believing the client is in use forever: the entry is extended at TTL, the socket is destroyed while the count is still non-zero, and eviction ranking starts protecting the leaked entries.
Check: grep every clone(); walk to the enclosing function; confirm a disconnect() in a finally. Do the same for per-candidate clients in routing code; an auto-selected swap can create one client per exchange per call.
5. The existential deposit is checked against the wrong baseline
The question is "will the recipient end at or above the existential deposit?" The comparison is balance + received - fee against ED, or equivalently received - fee against ED - balance when the balance is below the ED. Comparing the incoming amount against the full ED reports a false negative for anyone who already holds part of it, and using > instead of >= reports one at exactly the ED.
Check: write three tests: balance zero, balance partway to the ED, and balance such that the result lands exactly on the ED.
6. Locations are compared as JSON text
Two locations are the same location. Comparing JSON.stringify(a) === JSON.stringify(b) makes the answer depend on key order, whitespace, and whether a junction value is 41, "41", or "0x29". It will match in your tests and fail for a user who pasted the same location in a different order.
Check: canonicalise before comparing, and test the same location with reversed key order, number versus numeric string, and Here versus { Here: null }.
7. A field is accepted, validated, and dropped
The DTO takes isFeeAsset, disableFallback, or a custom-chain option, the validator accepts it, and the service never reads it. No error; the transfer is built without the option. This is the most common shape in the corpus because no type checker catches an unused required property.
Check: for every field on a public options object, find its read site. If there is none, it is either removeable or a bug.
The pattern behind all seven
Every one is a boundary between two representations: user input and chain units, explicit option and account default, quote and execution, dry-run placeholder and final calldata, reference count and actual use. The bug is never in the arithmetic; it is in the assumption that the two sides agree.
The fastest audit is to pick one transfer path and follow the value across every boundary, out loud, in the code. That finds more than any linter.
If you want the checklist I use (10 sections, 41 checks, each citing the real report that documents the mechanism), it is included in the pre-flight pack. For 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.