Skip to main content

One asset, many chains

A fintech takes USDC. Its customers hold USDC on whichever chain their exchange or wallet happens to use — Ethereum, Polygon, Base, Solana — and none of them think of those as different assets. The fintech's dashboard has to show one USDC balance, and its treasury has to be somewhere it can actually be spent from.

That is two problems wearing one coat, and ChainOS solves them with two different mechanisms.

Four chains receive, each sweeps to its own treasury wallet, three of those bridge through CCTP onto Polygon, and the fourth is already there — one balance of 700 USDC at the end.

A sweep consolidates within a chain. A bridge crosses between chains. They are separate because they fail differently: a sweep that fails costs a fee and leaves the money where it was, and a bridge that fails can leave money in neither place. Collapsing them into one verb would hide that difference at exactly the moment it matters.

Before anything: is one address per chain even right?

The natural design — and the one most diagrams show — is a separate deposit address per network. On ChainOS that is one more address than you need on the EVM side.

Ethereum, BSC, Polygon, Avalanche and Base share the derivation path m/44'/60'/0', so one issued address receives on all five networks. What differs is which network the sender broadcasts on, and the chain field on the webhook tells you which one it was.

NetworksAddresses you issue per customer
Ethereum · BSC · Polygon · Avalanche · Baseone, shared
Solanaone
TRON, XRP, Stellar, Bitcoinone each

So the four-chain example above needs two addresses per customer, not four. Show the same EVM address under all five network names, with the network the payer must choose stated plainly beside it.

The same address on the wrong network is the most expensive mistake in this product

An EVM address is valid on all five networks. A customer who copies their Polygon address and sends on Ethereum has sent real money to a real address you control — so it is recoverable — but a customer who pastes an exchange's Ethereum deposit address into your Polygon withdrawal form has not. Say the network, in words, next to the address. Never just the asset.

1. What can actually cross

Check this before you design the rest, because it decides the destination chain.

GET /v1/treasury/routes/check?token=USDC&from=eth&to=polygon

ChainOS only uses issuer-operated burn-and-mint bridges, where the issuer of the token controls the contract on both sides. A third-party wrapper would hand you an asset nobody is obliged to honour, and no amount of liquidity makes that a good trade with customer money.

For USDC that means Circle's CCTP, and it reaches:

TokenProtocolChains
USDCCircle CCTPEthereum, Avalanche, Base, Polygon, Solana
EURCCircle CCTPEthereum, Avalanche, Base, Solana
USDTLayerZero OFTVerified endpoints only — not Base, not TRON
RLUSDWormhole NTTThe XRPL leg needs a trust line first
cNGNIssuer APIPermissioned; needs a relationship with the issuer

Two gaps are worth knowing by heart because they look like configuration mistakes and are not:

  • USDC on BNB Smart Chain. Circle has a CCTP domain for BSC — domain 17 — and does not issue USDC there. The domain carries USYC. A burn addressed to it could never be minted and the funds would be irrecoverable, so the route does not exist.
  • USDT on Base and on TRON. Native USDT does not exist on Base at all, and TRON is not an EVM LayerZero endpoint despite carrying an enormous amount of legacy USDT.

Native coins are never bridged. Moving ETH to Polygon needs a wrapping bridge or a swap, and both are excluded by design. Native assets are swept, not bridged.

Choosing the destination

Pick the chain where you will actually spend. The considerations, in the order they usually decide it:

  1. Can every source reach it? Solana can reach Polygon and Base; it cannot reach a chain Circle does not issue on.
  2. What do your payouts cost there? This is where the money leaves, so the fee per withdrawal matters more than the fee per sweep.
  3. Can the treasury wallet be derived? On Bitcoin, Ethereum and the other secp256k1 chains, yes — which means your Edge verifies the destination by re-deriving it. On Solana and Stellar it cannot, and an external treasury address has to be allowlisted on the Edge by hand.

For the diagram above, Polygon and Base are both defensible. Ethereum is not: you would be paying L1 gas on every payout for the privilege of consolidating there.

2. A sweep policy per source chain

Each source chain consolidates into its own treasury wallet first. The bridge moves between treasury wallets, never between deposit addresses.

POST /v1/treasury/wallets
{ "chain": "eth", "source": "derived", "label": "USDC collection — Ethereum" }

POST /v1/treasury/sweeps/policies
{ "chain": "eth", "token": "USDC", "enabled": true,
"triggerMode": "THRESHOLD_AND_SCHEDULE",
"minAmount": "500000000", // 500 USDC. Ethereum gas is the reason
"scheduleCron": "0 0 2 * * *",
"treasuryWalletId": "…", "fundingWalletId": "…",
"gasFundingMode": "FUNDING_WALLET",
"maxFeeRatioBps": 300, "dustFloor": "20000000" }

Repeat per chain. The economics are not the same on each one and copying one policy across five chains is the commonest way to lose money here:

ChainA sane dustFloor for USDCWhy
Ethereumtens of dollarsA token sweep plus a gas top-up is two L1 transactions
Polygon · Base · AvalanchecentsFees are small enough that the floor is about tidiness
Solanacents, but watch rentLeave the 890,880-lamport rent-exempt minimum

Run POST /v1/treasury/sweeps/preview on each before enabling it, and read skipped. See Sweeps.

Solana's treasury wallet is external

Ed25519 admits no public-key-only derivation, so source: derived is unavailable on Solana and Stellar. Nominate an address with source: external and add it to EDGE_SWEEP_TREASURY_ALLOWLIST on the Edge — otherwise the Edge refuses to sign the sweep, because there is nothing for it to re-derive and check against.

3. One bridge policy

POST /v1/treasury/bridges/policies
{
"token": "USDC",
"destinationChain": "polygon",
"destinationWalletId": "twl_polygon_main",
"maxInFlightValue": "500000000000",
"maxInFlightCount": 20,
"maxSingleTransfer": "100000000000",
"maxFeeRatioBps": 100,
"relayMode": "hybrid"
}

The three in-flight caps are the part to think about rather than copy.

Between the burn and the mint your funds exist in neither place. Not on the source chain, not on the destination chain — they are a claim against an off-chain attestation service. The caps bound how much of your money can be in that state at once, and going over them returns 409 IN_FLIGHT_LIMIT_EXCEEDED.

That refusal is the guard working. Refusing a bridge is always recoverable — the funds are still on the source chain and you can transfer in an hour. Accumulating unbounded exposure to somebody else's attestation service is not.

maxFeeRatioBps defaults to 100 here against a sweep policy's 300, deliberately: a sweep's alternative is funds stranded in a deposit address, while a bridge's alternative is waiting until more has accumulated, which costs nothing.

4. Name the whole thing with a pipeline

POST /v1/treasury/pipelines
{
"name": "USDC to Polygon treasury",
"token": "USDC",
"destinationChain": "polygon",
"destinationWalletId": "twl_polygon_main",
"bridgePolicyId": "brp_usdc_polygon",
"sweepPolicyIds": ["swp_eth_usdc", "swp_base_usdc", "swp_sol_usdc"],
"enabled": true
}

A pipeline owns nothing. It is the statement that these policies belong together, which is what lets one screen answer "where is my money and what is it doing" — a question neither half can answer alone. Delete it and the sweeps keep running.

Add the chains that cannot reach the destination too. A source with no route is kept and marked unsupported with the reason, not rejected. If you collect USDC on Stellar and XRP as well, you want those visible and greyed on the flow diagram, still sweeping into their own treasury, rather than silently missing from a list you submitted.

GET /v1/treasury/pipelines/{id}/flow

returns the stages left to right in the order money travels, each with a state — idle, working, blocked, excluded. Render excluded in place with its reason beside it. A customer who configured a chain and then cannot find it has a worse problem than one who can see why it is greyed out.

Running a pipeline does not start the bridge

POST /v1/treasury/pipelines/{id}/run triggers the sweeps and stops there, deliberately. The sweeps have to confirm before their proceeds exist in the source treasury wallet, and bridging now would burn a balance that has not arrived. The bridge policy's own trigger fires when it has — which is the whole reason the two halves are policies rather than steps in a script.

5. The number you show, and the one you must not hide

The holder sees 700.00 USDC. That number is not the destination treasury balance. It is:

one balance = destination treasury
+ every source treasury not yet bridged
+ every deposit address not yet swept
+ everything currently in flight between a burn and a mint

The last term is the one integrations drop, and it is the one that matters. During a CCTP transfer out of Ethereum — finalized, which is the default — there is a window of minutes where a real amount of real money is attested and unminted. A dashboard that sums only on-chain balances shows the holder a number that dipped by 100 USDC and came back, with no explanation, and generates a support ticket every single time.

GET /v1/treasury/bridges/in-flight
{ "byToken": [ { "token": "USDC", "count": 3, "value": "72100000000",
"oldestInFlightSeconds": 340 } ],
"stalled": [], "maxInFlightValue": "500000000000", "utilisationBps": 1684 }

Show the total as one figure and label the in-flight part in transit. Alert on oldestInFlightSeconds, not on value — a total says how much is at risk, and that one says whether something is wrong.

6. When a bridge does not complete

The failure states are not interchangeable and treating them as one is the most expensive mistake available on this page.

StatusWhere your money isWhat to do
SOURCE_FAILEDStill in your source treasury. Nothing was at riskRetry immediately
STALLED_ATTESTATIONBurned, not attestedDo not retry. Wait; the message is still valid
STALLED_RELAYAttested, not mintedPOST /relay — submit the mint yourself
EXPIREDThe attestation window lapsedPOST /reattest. Not a new transfer
DEST_FAILEDThe mint reverted; the attestation is still goodFix the cause, then POST /relay
Never start a second transfer for a stalled one

A stalled bridge is alarming and the instinct is to try again. That would burn a second amount against a message that is still perfectly valid. Every transfer carries a recommendedAction field with the sentence to act on. Put it on the screen the on-call person is looking at.

Rehearse both stall cases before you go live. GET /v1/sandbox/treasury/scenarios ships eight rehearsals, each corresponding to a production incident that will eventually happen, and the two worth doing first are stalled attestation and expired attestation — the two that put funds in the state where they exist in neither place. Your Edge really signs in sandbox; what is simulated is the outside world.

7. What this does not do

It does not convert. A customer who deposits USDT does not get USDC. There is no swap anywhere in ChainOS and no price feed on any path — "one asset, many chains" is exactly that, one asset. See Cross-network withdrawals for the shape of the problem when the asset differs too.

It does not chain bridges. A source with no direct route to the destination is unsupported, full stop; ChainOS will not route Ethereum → Base → Polygon around a missing route. Multi-hop doubles the in-flight window and the failure surface, and the in-flight window is the thing the entire treasury component is organised around minimising.

It does not take custody. ChainOS selects the provider, builds the unsigned legs, polls the attestation and records both sides in your ledger. Your Edge signs, from keys we do not have. No liquidity is provided and no custody is taken at any point.

Check the destination route is configured before you rely on it

A CCTP mint is built against MessageTransmitterV2, whose address differs per chain and comes from the route's registry configuration rather than from a constant. If it is missing for your destination, GET /v1/treasury/routes/check says so and the transfer is refused before anything is burned — which is the cheap moment. Verify the exact source→destination pair you intend to use, not just the token.

Next

  • Bridges — the reference page, including relay modes and the stall thresholds.
  • Pipelines — the flow diagram and what each stage state means.
  • Treasury policy and yield — what may be done with the pool once it is in one place.