Skip to main content

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.

ExampleAnswer
1. Same asset, route existsHolds USDC (Base), wants USDC (Polygon)Pay from your Polygon float. Rebalance with a bridge
2. Same asset, no routeHolds USDT, wants USDT on BaseRefuse at quote time, with the reason
3. Different assetHolds USDT, wants ETHThat 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" }
The same address is valid on five networks

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.

A float address is an ordinary deposit address, and sweeps will empty it

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.

TokenReaches
USDCEthereum, Avalanche, Base, Polygon, Solana
EURCEthereum, Avalanche, Base, Solana
USDTLayerZero-verified endpoints — not Base, not TRON
RLUSDWormhole 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.

ChainWhat elseWhat happens without it
StellarA trustline for the asset, signed by the destination422 DESTINATION_OPT_IN_REQUIRED. Only they can sign it
XRPThe account funded past its base reserveAn unfunded account cannot receive at all
XRP · StellarA destination tag or memo, if paying an exchangeFunds arrive and are credited to nobody
SolanaAn associated token accountThe sender creates and pays rent for it — unlike Stellar
The destination tag is the one your customers will get wrong

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.

StepWhat happensWhere
1Customer picks asset USDT, network Polygon, pastes an addressYour UI
2You quote: USDT is supported on Polygon, address valid, fee 0.42 POLYour API
3Customer confirms
4Ledger: customer:88213 −500, house:payable +500Your DB
5POST /v1/transactions from the Polygon USDT float, not from TRONChainOS
6withdrawal_confirmedhouse:payable −500, house:float:polygon −500Your DB
7Later, and unrelated: Polygon float is low. You rebalanceTreasury

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:

  1. The network, in words, on the same line as the asset. "USDT — Polygon network", not "USDT".
  2. 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.
  3. 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.
  4. 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.
  5. That it cannot be reversed. Once, plainly, on the confirmation screen. Not in a footer.

Next