Sending withdrawals
Outbound is the one flow that needs all three parties. Cloud builds, your Edge signs, Cloud broadcasts.

Cloud can propose. Only your Edge can sign. That is the whole point, and it is why a withdrawal requires a live Edge while a deposit does not.
Estimate the fee first
curl -s "$EDGE/fees/eth/estimate?from=0x3fC9…7FAD&to=0x8a1c…&amount=1000000&tokenContract=0xdAC1…1ec7" \
-H "X-API-Key: $CHAINOS_EDGE_KEY"
The breakdown is per chain, because the fee models have nothing in common:
| Chain | Formula | Fields returned |
|---|---|---|
| Bitcoin | vBytes × satPerVbyte | satPerVbyte estimatedVBytes inputCount outputCount |
| Ethereum · Polygon · Avalanche | gasUnits × (baseFee×2 + priorityFee) | baseFeeGwei priorityFeeGwei maxFeeGwei gasUnits |
| Base | gasUnits × (baseFee×2 + priorityFee) + l1DataFee | the four EIP-1559 fields, plus l1DataFeeWei |
| BSC | gasUnits × max(gasPrice×1.1, 3 gwei) | gasPriceGwei gasUnits |
| TRON | energy × energyPrice + bandwidthCost | energy energyPriceSun bandwidth bandwidthCostSun |
| Solana | 5000×signers + (CU × priorityFee)/1e6 + rent | baseFeeLamports computeUnits priorityFeeMicroLamports ataRentLamports |
| XRP | 12 drops, load-adjusted | baseFeeDrops openLedgerFeeDrops reserveDrops |
| Stellar | chargedFeePerOperation × operations, floored at the 100-stroop base fee | baseFeeStroops chargedFeePerOperationStroops operations |
On Base the fee has a second component. An OP-Stack transaction pays for its own data availability on Ethereum, charged at execution and carried in no field of the transaction, so it cannot be bid or capped. It is included in the total rather than reported beside it — the total is what the pre-flight compares your balance against. It also moves with Ethereum's blob market rather than with Base's gas price, which makes it the one fee component here that can drift for reasons unrelated to the chain you are sending on. See Base.
An estimate is an estimate. Bitcoin's estimatedVBytes depends on which UTXOs get selected, and
EVM base fees move between your estimate and your broadcast. Do not present a fee to a customer as
final unless you are prepared to absorb the difference.
The sending address pays its own fee
Always. If it cannot, the request is refused before a transaction is assembled.
That is the only rule that holds on every chain but one anyway. Bitcoin computes the fee as
sum(inputs) − sum(outputs), so it necessarily comes out of what is being spent. EVM, TRON, XRP
and Stellar all require the sender to hold the native asset. Only Solana has a fee-payer field
permitting a third account to pay, and building the product around the single chain that allows it
would be a poor trade.
The practical consequence, and it catches everyone once: an ERC-20 deposit address holds tokens and no native currency. That is the default state of every such address, because your customer sent you USDT and nobody sent you ETH. Sending that USDT out requires ETH at that address for gas.
Build, sign, broadcast
curl -X POST $EDGE/transactions \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Idempotency-Key: wd-payout-99118" \
-H "Content-Type: application/json" \
-d '{
"chain": "eth",
"from": "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD",
"to": "0x8a1cA5e9F0C7b3D2E4a6B8C0d2E4f6A8b0C2d4E6",
"amount": "1000000",
"tokenContract": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"userRef": "cust_88213"
}'
The Edge handles the round trip — it asks Cloud to build, signs the payload it gets back, and hands the signature back for broadcast. Your application makes one call.
{
"success": true,
"data": {
"id": "tx_01J8XKF",
"chain": "eth",
"txid": "0x71f3…",
"direction": "outbound",
"status": "broadcast",
"confirmations": 0,
"requiredConfirmations": 12,
"fee": { "amount": "420000000000000", "amountFormatted": "0.00042", "decimals": 18 },
"explorerUrl": "https://etherscan.io/tx/0x71f3…"
}
}
Send an Idempotency-Key. A withdrawal is the single worst request to retry blindly. Use your
own payout identifier so a retry after a timeout returns the original transaction rather than
sending twice.
Then two webhooks:
withdrawal_broadcast accepted by the network
withdrawal_confirmed reached the chain's threshold
withdrawal_failed reverted or rejected
withdrawal_broadcast means the network accepted it, not that it succeeded. An EVM transaction can
be mined and still revert; that produces withdrawal_failed, and the fee is spent either way.
The two-step form
If you need to inspect an unsigned transaction before it is signed — a compliance hold, a four-eyes approval, an internal risk check — build and broadcast separately:
# Build only
curl -s "$EDGE/transactions/unsigned" -H "X-API-Key: $CHAINOS_EDGE_KEY" \
-X POST -H "Content-Type: application/json" -d '{ … }'
# Inspect, approve, then
curl -X POST $EDGE/transactions/{id}/broadcast -H "X-API-Key: $CHAINOS_EDGE_KEY"
Between those calls the fee estimate can go stale. PATCH /v1/transactions/unsigned/{id} re-prices
it without rebuilding the whole thing.
When a send is refused
The pre-flight is not balance < amount + fee. That expression is wrong wherever the fee is
denominated in a different asset from the amount — every token send — and wrong again wherever part
of a balance is immobilised. Between them that is most of the product:
if sending a token:
token.available >= amount else INSUFFICIENT_FUNDS
native.available >= fee else INSUFFICIENT_GAS
else:
native.available >= amount + fee else INSUFFICIENT_FUNDS
Reading available rather than confirmed is what makes this correct without per-chain
branching: XRP, Solana and Stellar all immobilise part of the balance, and available already has
it deducted.
Three codes, because they have three different remedies. A single NOT_ENOUGH_FUNDS produces
support tickets nobody can answer.
| Code | Meaning | What to do |
|---|---|---|
INSUFFICIENT_FUNDS | Not enough of the asset being sent | Reduce the amount, or wait for a deposit |
INSUFFICIENT_GAS | Enough of the asset, not enough native for the fee | Fund the address with native currency |
INSUFFICIENT_RESERVE | Nominally enough, but the send would breach a locked reserve | Leave the reserve. Send less |
INSUFFICIENT_GAS is the common one by a wide margin, for the ERC-20 reason above.
INSUFFICIENT_RESERVE is distinguished by comparing against both figures — confirmed covers the
requirement and available does not. It matters that this is its own code: telling an operator to
fund an address that visibly holds enough is worse than saying nothing.
The EVM chains and TRON have no reserve: available == confirmed on all of them, and
INSUFFICIENT_RESERVE cannot occur.
| Chain | The reserve |
|---|---|
| XRP | 1 XRP base, plus 0.2 XRP per owned ledger object. Current values, not protocol invariants |
| Solana | 890,880 lamports rent-exempt minimum; 2,039,280 more per associated token account |
| Stellar | (2 + subentries) × 0.5 XLM — 1 XLM for a bare account, another 0.5 XLM per trustline. Set by validator vote, so also a current value |
All three are real: an XRP account below the base reserve is not activated and cannot receive, a Solana account drained below rent-exemption can be collected by the runtime, and a Stellar account cannot be taken below its minimum at all — the ledger refuses the transaction. See XRP, Solana and Stellar.
The fourth case: the destination cannot receive the asset
DESTINATION_OPT_IN_REQUIRED is a fourth 422, and it is Stellar's alone. A Stellar account cannot
hold USDC until it has signed a changeTrust operation for it, and only that account can sign
it — unlike Solana, where the sender creates the recipient's token account and pays its rent.
{
"error": {
"code": "DESTINATION_OPT_IN_REQUIRED",
"message": "G… cannot receive USDC until it establishes a Stellar trustline for it.",
"details": { "chain": "xlm", "asset": "USDC", "destination": "G…" }
}
}
It has its own code 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 someone else's account. Nothing you do at your end fixes this — the
destination has to establish the trustline, and until it does, no amount of USDC can reach it. For
an address ChainOS issued you, POST /v1/addresses/{address}/trustlines builds that opt-in; see
Stellar.
Other failures
| Error | Meaning |
|---|---|
423 EDGE_OFFLINE | No live Edge. Nothing can be signed |
423 EDGE_NO_LEADER | Replicas live, none holds the lease. Check for a partition |
422 CHAIN_REJECTED | The node refused it. Its reason is in error.details |
400 INVALID_ADDRESS | The destination fails that chain's format validation |
409 IDENTITY_ROTATION_PENDING | A different mnemonic is mounted. Do not retry — see Identity rotation |
422 DESTINATION_OPT_IN_REQUIRED | Stellar only. The destination holds no trustline for the asset, and only it can establish one |
Validate a destination address before you take it from a customer:
curl -s -X POST $EDGE/addresses/validate \
-H "X-API-Key: $CHAINOS_EDGE_KEY" \
-H "Content-Type: application/json" \
-d '{ "chain": "eth", "address": "0x8a1c…" }'
Cheaper than a failed send, and much cheaper than a successful send to a mistyped address — which is irreversible on every chain here.
Gas tanks
A gas tank is an address ChainOS may draw on with no per-transaction human decision. An ordinary withdrawal never needs one — the sending address pays its own fee — but the sweep engine does: a deposit address holding only USDT cannot pay the network fee to move that USDT, so gas has to be sent to it first, without a person approving each top-up. That is the tank's one production caller.
It is your own address, derived from your own mnemonic — a funded Ziklag hot wallet would collapse the non-custodial claim. That forces one tank per chain, since a funded BTC address cannot pay an ETH fee.
It is not one of your deposit addresses. It sits on the BIP-44 change branch at M/1/0,
against M/0/n for deposits, and carries role = gas_tank. Reusing a deposit address would
attribute gas spend to a customer and make reconciliation report a permanent discrepancy against
them, and it would put two contradictory policies on one address — a sweep wants to empty a
deposit address, a tank must never be emptied.
curl -s $CLOUD/v1/edge/gas-tanks -H "Authorization: Bearer $TOKEN"
Funding is advisory. Readiness reports the tank address and the configured minimum and nothing else, because a live balance there would be seven RPC calls per poll per account and readiness is polled every fifteen seconds. The endpoint above fetches balances on demand, once, when you open the funding panel. Neither gates address generation nor deposits.
Autonomous spend against a tank is capped Edge-side, per chain, per rolling window, and defaults to zero — so a tank funds no token sweep until a cap is set deliberately. See Configuration.
Registering a tank as a sweep funding wallet adds a low-balance alert and a "sweeps remaining" figure on top of it. It takes no address: the tank is derived, not chosen.
Next
- Sweeps — consolidating deposit addresses into a treasury wallet, and the guards that stop a sweep costing more than it moves.
- Idempotency — retrying a withdrawal safely.
- Reconciliation — proving what was sent.