Skip to main content

Sending withdrawals

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

Your app posts a transaction to the Edge, which forwards to Cloud; Cloud runs pre-flight checks and builds an unsigned transaction; the Edge signs it; Cloud broadcasts it.

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:

ChainFormulaFields returned
BitcoinvBytes × satPerVbytesatPerVbyte estimatedVBytes inputCount outputCount
Ethereum · Polygon · AvalanchegasUnits × (baseFee×2 + priorityFee)baseFeeGwei priorityFeeGwei maxFeeGwei gasUnits
BasegasUnits × (baseFee×2 + priorityFee) + l1DataFeethe four EIP-1559 fields, plus l1DataFeeWei
BSCgasUnits × max(gasPrice×1.1, 3 gwei)gasPriceGwei gasUnits
TRONenergy × energyPrice + bandwidthCostenergy energyPriceSun bandwidth bandwidthCostSun
Solana5000×signers + (CU × priorityFee)/1e6 + rentbaseFeeLamports computeUnits priorityFeeMicroLamports ataRentLamports
XRP12 drops, load-adjustedbaseFeeDrops openLedgerFeeDrops reserveDrops
StellarchargedFeePerOperation × operations, floored at the 100-stroop base feebaseFeeStroops 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.

CodeMeaningWhat to do
INSUFFICIENT_FUNDSNot enough of the asset being sentReduce the amount, or wait for a deposit
INSUFFICIENT_GASEnough of the asset, not enough native for the feeFund the address with native currency
INSUFFICIENT_RESERVENominally enough, but the send would breach a locked reserveLeave 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.

ChainThe reserve
XRP1 XRP base, plus 0.2 XRP per owned ledger object. Current values, not protocol invariants
Solana890,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

ErrorMeaning
423 EDGE_OFFLINENo live Edge. Nothing can be signed
423 EDGE_NO_LEADERReplicas live, none holds the lease. Check for a partition
422 CHAIN_REJECTEDThe node refused it. Its reason is in error.details
400 INVALID_ADDRESSThe destination fails that chain's format validation
409 IDENTITY_ROTATION_PENDINGA different mnemonic is mounted. Do not retry — see Identity rotation
422 DESTINATION_OPT_IN_REQUIREDStellar 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.