Withdrawals to another network
A customer's balance is USDT they deposited on TRON. They want to withdraw to an address on Ethereum. Or they hold USDC you collected on Base and their exchange only accepts Polygon.
This happens in the first week of every production integration, and how you answer it decides whether your support queue is manageable.
The rule, stated once
ChainOS never converts an asset and never moves a customer's balance across chains. A withdrawal is a send, from an address you hold on the network the customer named, of the asset that address holds. There is no swap anywhere in the product and no price feed on any path.
That is not a missing feature. A swap on the withdrawal path means holding a rate, quoting it, honouring it for some window, and absorbing the difference — which is a trading business, with its own capital and its own regulator, bolted onto a custody product. ChainOS refuses it for the same reason it refuses to hold your keys.
So the question is never "how do I bridge this customer's balance?" It is "what do I owe them, and where do I have liquidity to pay it from?" Those are two separate movements, and keeping them separate is the whole design.
Which of the three shapes is it?
Work this out first. They have completely different answers.
| Example | Answer | |
|---|---|---|
| 1. Same asset, route exists | Holds USDC (Base), wants USDC (Polygon) | Pay from your Polygon float. Rebalance with a bridge |
| 2. Same asset, no route | Holds USDT, wants USDT on Base | Refuse at quote time, with the reason |
| 3. Different asset | Holds USDT, wants ETH | That is a trade. Not a ChainOS operation at all |
Shape 3 is worth being blunt about
If your product genuinely needs to let customers convert, you are running an exchange or you are integrating one. Do that conversion on your own venue, record its result in your ledger, and then the withdrawal is an ordinary shape-1 or shape-2 problem. Do not try to make ChainOS do it — there is nowhere in the API for a rate to go, deliberately.
1. Make the network a first-class field
The commonest and most expensive design mistake here is a withdrawal form that asks for an asset and an address, and infers the network.
// Wrong. "USDT" and an 0x address does not name a network.
{ "asset": "USDT", "address": "0x8a1c…", "amount": "500.00" }
// Right. Nothing is inferred.
{ "asset": "USDT", "network": "polygon", "address": "0x8a1c…", "amount": "500.00" }
Ethereum, BSC, Polygon, Avalanche and Base share an address format and a derivation path. An
0x… address a customer pastes is syntactically valid on all five, and POST /v1/addresses/validate
will say so for whichever one you ask about.
A customer who pastes an exchange's Ethereum deposit address and selects Polygon has given you a perfectly valid address on a network where that exchange does not credit it. The funds arrive at an address nobody is watching, on a chain the recipient does not monitor, and there is no recall.
Make the customer choose the network explicitly, show it back to them in words on the confirmation screen, and never pre-select it from the address.
2. Quote before you accept
Your quote endpoint should answer four questions, in this order, and every refusal here is free:
1. Do I support this asset on this network at all?
2. Is the address valid for this network?
3. Does this network need anything else from the destination? (memo, trustline, reserve)
4. Do I have liquidity on this network, and what is the fee?
async function quoteWithdrawal(req: { asset: string; network: Chain; address: string; amount: bigint }) {
// 1. Your own product decision, not a ChainOS one.
if (!PAYOUT_NETWORKS[req.asset]?.includes(req.network)) {
return refuse('NETWORK_NOT_SUPPORTED', supportedNetworksFor(req.asset));
}
// 2. Cheaper than a failed send, and very much cheaper than a successful send
// to a mistyped address — which is irreversible on every chain here.
const valid = await edge.post('/addresses/validate',
{ chain: req.network, address: req.address });
if (!valid.data.valid) return refuse('INVALID_ADDRESS');
// 3. Chain-specific destination requirements. See §5.
const destination = await checkDestination(req.network, req.asset, req.address);
if (!destination.ok) return refuse(destination.code, destination.detail);
// 4. Your float, and the network fee.
const fee = await edge.get(`/fees/${req.network}/estimate`, {
from: FLOAT[req.network], to: req.address,
amount: req.amount.toString(), tokenContract: contractOf(req.asset, req.network),
});
if (floatBalance(req.network, req.asset) < req.amount) {
return refuse('TEMPORARILY_UNAVAILABLE'); // never "insufficient funds"
}
return { fee: fee.data, network: req.network, expiresAt: in(90, 'seconds') };
}
TEMPORARILY_UNAVAILABLE, never "insufficient funds". The customer has funds; you do not
have them there. Telling them otherwise produces a support conversation nobody can win, and
telling them the truth — "Polygon withdrawals are paused, try again shortly, or withdraw on
Base now" — usually resolves it in one screen.
3. Where the payout comes from
A withdrawal needs an address that holds the asset and can pay its own network fee. Three options, and the right one depends on your volume.
Option A — pay from the customer's own deposit address
The simplest, and the one the product does natively. A withdrawal can go straight from a customer's deposit address to their destination with nothing passing through treasury.
It only works for same-network withdrawals, and it needs native currency at that address to
pay the fee — which an ERC-20 deposit address never has, because your customer sent you USDT
and nobody sent you ETH. That is what INSUFFICIENT_GAS means, and it is by a wide margin the
most common refusal in the product.
Use it where the customer withdraws on the network they deposited on, which for many products is most withdrawals.
Option B — a payout float per (network, asset)
For cross-network payouts you need liquidity sitting where the customer is going. Hold it at a dedicated address ChainOS issued you, not at a treasury wallet:
curl -s -X POST $EDGE/addresses \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Idempotency-Key: float-polygon-usdc" \
-H "Content-Type: application/json" \
-d '{ "chain": "polygon", "userRef": "house:float", "tag": "settlement",
"label": "Polygon USDC payout float" }'
Keep native currency at that address too — the sending address always pays its own fee.
Sweep candidate selection covers your deposit addresses on the chain. A float sitting on a
(chain, token) pair that has an enabled sweep policy will be consolidated into treasury on
the next cycle, and your payouts will start failing with INSUFFICIENT_FUNDS for a reason
that is not obvious from the error.
Decide deliberately: either run no sweep policy for the pair you use as float, or treat the sweep as expected and re-fund the float as part of your treasury routine.
Option C — do not try to pay out of a treasury wallet
A treasury wallet is a destination, never a source. The ordinary withdrawal path builds a
transaction only from an address ChainOS issued you, and a derived treasury wallet is not one —
it lives on the reserved change branch at M/1/1 and exists to be swept into.
Your Edge can sign for it, because it is your own key. What it cannot do is do so through
POST /v1/transactions. Plan your liquidity around Option A and Option B, and treat the
treasury wallet as the reserve you top a float up from rather than the account you pay from.
4. Rebalancing the float
Your floats drain in the direction your customers withdraw and fill in the direction they deposit, and those are rarely the same. Rebalancing is a treasury operation and has nothing to do with any individual customer's withdrawal.
Where a route exists, use the bridge lane: treasury on the source chain, treasury on the destination chain, one burn-and-mint transfer. See One asset, many chains, which is this exact machinery.
| Token | Reaches |
|---|---|
| USDC | Ethereum, Avalanche, Base, Polygon, Solana |
| EURC | Ethereum, Avalanche, Base, Solana |
| USDT | LayerZero-verified endpoints — not Base, not TRON |
| RLUSD | Wormhole NTT; the XRPL leg needs a trust line |
Where no route exists — USDT between TRON and anything, USDC to BSC — rebalancing goes through whatever venue you already use. That is a business decision about spreads, not a ChainOS operation, and it is why shape 2 above is a refusal rather than a workaround.
Size the float from the withdrawal pattern, not the deposit pattern. A week of p95 withdrawals on that network, plus the time it takes you to rebalance, is a defensible starting point. Alert well before empty: a bridge takes minutes and an exchange leg can take hours.
5. Destination checks that are not about the address format
Four chains want something more than a valid address, and each fails differently.
| Chain | What else | What happens without it |
|---|---|---|
| Stellar | A trustline for the asset, signed by the destination | 422 DESTINATION_OPT_IN_REQUIRED. Only they can sign it |
| XRP | The account funded past its base reserve | An unfunded account cannot receive at all |
| XRP · Stellar | A destination tag or memo, if paying an exchange | Funds arrive and are credited to nobody |
| Solana | An associated token account | The sender creates and pays rent for it — unlike Stellar |
An exchange deposit on XRP or Stellar is a shared address plus a tag that identifies the customer. A payment with the right address and no tag reaches the exchange and is credited to nobody, and recovering it is a support ticket with a third party that may take weeks or fail.
If you pay out on those chains, make the tag a required field when the customer says the destination is an exchange, and show it back on the confirmation screen at the same weight as the address.
DESTINATION_OPT_IN_REQUIRED has its own error code precisely because its remedy is unlike
every other funding failure's. The sender can add funds, add gas, or wait out a reserve; it
cannot act on somebody else's account. Nothing you do fixes it — the destination has to
establish the trustline.
6. Your ledger
Two entries, at two different moments, and getting the timing wrong lets a customer spend the same balance twice.
Debit the customer when you accept the instruction, not when the chain confirms. Between those two moments is ninety seconds in which a second withdrawal request would pass a balance check against money already committed.
accept: customer:88213 −500 USDT house:payable +500 USDT
broadcast: house:payable −500 USDT house:float(polygon) −500 USDT
house:fees −0.42 POL (the network fee, in the chain's own coin)
Three details that matter:
The fee is in a different asset from the amount. A USDT withdrawal on Polygon costs POL.
Never net it out of the USDT figure; record it against a native-currency account. This is also
why the pre-flight is not balance < amount + fee — that expression is wrong wherever the fee
is denominated differently, which is every token send.
withdrawal_broadcast is not success. An EVM transaction can be mined and still revert;
that produces withdrawal_failed, and the fee is spent either way. Reverse the customer
debit on withdrawal_failed, not before.
Send an Idempotency-Key keyed on your own payout id. A withdrawal is the single worst
request to retry blindly. A retry after a timeout then returns the original transaction rather
than sending twice.
7. A worked example
A customer holds 1,200 USDT deposited on TRON. They ask to withdraw 500 USDT to a Polygon address.
| Step | What happens | Where |
|---|---|---|
| 1 | Customer picks asset USDT, network Polygon, pastes an address | Your UI |
| 2 | You quote: USDT is supported on Polygon, address valid, fee 0.42 POL | Your API |
| 3 | Customer confirms | — |
| 4 | Ledger: customer:88213 −500, house:payable +500 | Your DB |
| 5 | POST /v1/transactions from the Polygon USDT float, not from TRON | ChainOS |
| 6 | withdrawal_confirmed → house:payable −500, house:float:polygon −500 | Your DB |
| 7 | Later, and unrelated: Polygon float is low. You rebalance | Treasury |
Nothing crossed a chain on the customer's behalf. Their TRON balance was reduced and your Polygon liquidity paid them; step 7 is a treasury operation you do on your own schedule, in your own size, with the bridge caps protecting you.
That separation is the whole answer to this page. A design that tries to make steps 5 and 7 into one operation ends up bridging 500 USDT at a time, paying a protocol fee per customer, and putting a customer's withdrawal behind an attestation service's availability.
8. What to tell the customer
The wording that prevents the most tickets, in the order it should appear:
- The network, in words, on the same line as the asset. "USDT — Polygon network", not "USDT".
- A warning that the network must match their destination. Most exchanges name the network on their deposit screen; tell the customer to check it there.
- The fee, and in which currency. If you charge a flat withdrawal fee, say so; if you pass the network fee through, say that the estimate can move.
- What "sent" means. A broadcast transaction is not yet a confirmed one. Give them the
explorer link — it is on every transaction response as
explorerUrl. - That it cannot be reversed. Once, plainly, on the confirmation screen. Not in a footer.
Next
- Withdrawals — fee models per chain, the three insufficient-funds codes, and the two-step build-inspect-broadcast form.
- One asset, many chains — the rebalancing machinery in full.
- Customer wallets — the ledger this page assumes.