Skip to main content

Sweeps

A sweep consolidates funds from many deposit addresses into one treasury wallet, on the same chain. It is what makes the deposit-address model usable.

On an account-model chain — Ethereum, BSC, Polygon, Avalanche, Base, TRON, Solana, XRP, Stellar — balances cannot be combined in a single transaction. A withdrawal of 5,000 USDT might need funds from forty separate addresses: forty transfers, each from an address holding no native currency to pay for itself. Without consolidation the funds are there and are not spendable.

On Bitcoin many inputs can be spent in one transaction, so a sweep is one transaction rather than forty — but the fee scales with input count and the wallet fragments further over time.

ChainOS never holds the funds

Stated once, because everything below follows from it:

Every movement a sweep makes is emitted as an unsigned transaction, signed by your Edge from a key ChainOS does not possess, and broadcast by ChainOS on your behalf. There is no point in a sweep at which ChainOS controls your money.

Two consequences you will notice immediately.

A sweep is not instant. POST /v1/treasury/sweeps/trigger returns as soon as the job is planned. The operations then wait for your Edge to collect them, sign them, and hand the signatures back. A job sits at SWEEPING for as long as that takes, and an Edge that is offline for an hour delays a sweep rather than failing it.

A treasury wallet is a destination, never a source. ChainOS builds no transaction that spends from one. It is either derived from your own account key — so your Edge can sign for it whenever you choose to move the funds — or an address you nominate, which ChainOS validates and watches and nothing more.

What your Edge checks before it signs a sweep

A sweep is the one thing your Edge signs with nobody asking for it. Everything else it signs has a caller waiting — your application posts a withdrawal and gets a transaction id back. A sweep is planned on a schedule, so your Edge collects the work itself, and it is worth knowing exactly what it will and will not do on your behalf.

It checks the destination, not the source. A sweep spends from a deposit address, which is the very thing the rest of the design protects, so the source cannot be what makes it safe. Instead your Edge re-derives the treasury address from your mnemonic at m/…/1/1 and compares it with where the transaction is going. If they do not match, it does not sign.

That is a specific and useful guarantee: even if ChainOS were entirely compromised, it could choose which of your addresses get emptied and when — an inconvenience and a fee — and it could not choose where the funds go. They can only go to an address your own seed phrase produces.

An external treasury wallet has to be named on the Edge. A source: external wallet is an address no mnemonic of yours produces — an exchange deposit, a custodian, a multisig — so there is nothing to re-derive and the check above cannot be made. Your Edge refuses it by default. To sweep to one, list it:

EDGE_SWEEP_TREASURY_ALLOWLIST=0xYourCustodianAddress,0xAnother

This is deliberately more work than the derived case. Setting it means you are asserting the address is right, in a file on a machine ChainOS cannot reach, rather than accepting an address over the wire.

Gas top-ups are capped separately. Before a token sweep your Edge may need to send native gas to the deposit address, and that spends from your gas tank with nobody asking either. It is bounded by a rolling per-chain cap which is zero unless you set it — so token sweeps on the account-model chains do nothing until you do:

EDGE_SPEND_CAP_POLYGON=50000000000000000000 # 50 POL per rolling window
EDGE_SPEND_WINDOW=86400 # the window, in seconds

Native sweeps need no top-up and are unaffected.

Bitcoin sweeps are planned but not yet signed

A Bitcoin consolidation spends many inputs at once and needs one signature per input. The Edge does not produce those yet, so a Bitcoin sweep policy plans jobs that do not advance. The Edge logs this once per build rather than failing quietly. Every other supported chain sweeps end to end.

If you would rather your Edge signed nothing on its own, set EDGE_SWEEP_COLLECTOR=false. Policies still plan jobs and those jobs then wait, unsigned, until their builds expire — so this is a way to switch sweeping off, not a way to run it manually.

Setting one up

Three things, in this order.

1. A treasury wallet

POST /v1/treasury/wallets
{ "chain": "polygon", "source": "derived", "label": "Main treasury" }

source: derived sends no address. ChainOS derives it from your account's extended public key at the reserved change path m/…/1/1 — which means your Edge can already sign for it, and means there is no address in this request body for anybody to change. That matters more here than anywhere else in the API: the treasury wallet is where every swept balance in your account ends up.

If you do send an address alongside derived, it is compared with what ChainOS derives and a mismatch is refused rather than resolved in either direction. A disagreement about a derivation is the most serious kind of bug this product can have.

Solana and Stellar cannot be derived. Both use Ed25519, which admits no public-key-only child derivation, so no extended public key exists and ChainOS has nothing to derive from. Use source: external and nominate an address on those two chains.

2. A funding wallet, if you sweep tokens

A token sweep needs native currency at the source address, because the fee is always denominated in the chain's own coin whatever is being sent. An ERC-20 deposit address holds USDT and no ETH — that is the default state of every such address — so ChainOS must send it gas first, wait for that to confirm, and only then build the sweep.

The account extended public key branches two ways: M/0/i to customer deposit addresses, which a sweep wants to empty, and M/1/0 to the gas tank, which must never be emptied. A token sweep is then two passes — top up, then sweep.

POST /v1/treasury/funding-wallets
{ "chain": "polygon", "lowBalanceAlert": "5000000000000000000" }

This takes no address either. A funding wallet names the gas tank your Edge already derived for that chain at M/1/0. That tank is already the only address ChainOS may spend from without a person approving each transaction, already never issued to any of your customers, and already excluded from your billable address count. Letting a request body name the address gas is drawn from would point the one autonomous spend in the product wherever the body said.

Fund the tank as you normally would. GET /v1/treasury/funding-wallets reports estimatedSweepsRemaining alongside the balance — "about 180 sweeps left" is a number you can act on; "4.21 POL" is not.

3. A policy

One per (chain, token). Omit token for the chain's native coin, which is a policy like any other.

POST /v1/treasury/sweeps/policies
{
"chain": "polygon",
"token": "USDC",
"enabled": true,
"triggerMode": "THRESHOLD_AND_SCHEDULE",
"minAmount": "50000000",
"scheduleCron": "0 0 */4 * * *",
"treasuryWalletId": "…",
"maxFeeRatioBps": 300,
"dustFloor": "1000000",
"gasFundingMode": "FUNDING_WALLET",
"fundingWalletId": "…"
}

Triggers

ModeBehaviour
MANUALAPI or portal only
THRESHOLDEvaluated when a deposit confirms on that chain
SCHEDULECron; sweeps everything above the dust floor
THRESHOLD_AND_SCHEDULEBoth — the recommended default

THRESHOLD_AND_SCHEDULE is recommended because of the shape real deposit traffic has. The threshold consolidates a large deposit within minutes instead of leaving it in a single-use address until the next cron; the schedule batches the long tail of small balances into groups whose fee is worth paying. A schedule frequent enough to do the first job does the second one badly.

The threshold is evaluated against the whole sweepable balance on that chain, not against one deposit — so five 15-USDC deposits crossing a 50-USDC threshold together fire it, which a per-deposit comparison would miss.

scheduleCron is the six-field Spring form, seconds first: 0 0 */4 * * * is every four hours.

The economics guards

These are what stop a sweep engine destroying more value than it moves, and they are the settings most worth getting right.

maxFeeRatioBps — at 300 bps, a sweep whose gas cost exceeds 3% of the amount is skipped and the address deferred to the next cycle, where it may have accumulated enough to be worth moving. Without this, sweeping a 2 USDT deposit on Ethereum destroys more value than it consolidates.

dustFloor — balances at or below this are skipped. A floor of zero on Ethereum pays gas to move every 2 USDT deposit, every cycle, forever. This is the single most consequential field on the policy and it is the reason the preview exists.

abortIfGasExceeds — a hard ceiling on the gas price at which the entire job stops, whatever any individual address's ratio works out to. The ratio guard is about one address; this is about a gas spike making the whole cycle a bad idea.

requireConfirmedOnly — defaults true. A deposit that has not reached its confirmation threshold can still be taken back by a reorg, and sweeping it would leave you having moved money you never received. This is honoured by reading the ledger rather than the chain: a node reports a balance including deposits a reorg can still reverse, so no on-chain read can answer the question.

pauseOnConsecutiveFailures — after this many failed cycles the policy pauses itself and emails the account owner. A policy that fails repeatedly is usually failing for a reason no number of retries fixes, and each retry costs a fee. It stays paused until somebody resumes it; resuming also clears the failure counter.

retainGasFloat — leaves a multiple of one transfer's fee behind on an account-chain address, so the next sweep of it does not need a gas top-up round trip first. Defaults true on account chains and is meaningless on Bitcoin, where the fee comes out of the inputs.

Preview before you enable

POST /v1/treasury/sweeps/preview
{ "chain": "polygon", "token": "USDC", "dustFloor": "1000000", "maxFeeRatioBps": 300 }
{
"eligibleAddresses": 147,
"totalSweepableFormatted": "48,920.45",
"estimatedGasCostFormatted": "1.28",
"feeRatioBps": 3,
"gasTopUpsRequired": 12,
"skipped": [
{ "reason": "SKIPPED_DUST", "count": 892, "totalValue": "410220000" },
{ "reason": "SKIPPED_FEE_RATIO", "count": 31, "totalValue": "2140000" }
],
"wouldProceed": true,
"blockedReason": null
}

It signs nothing, persists nothing and costs no fee, and it works on a policy that does not exist yet — which is the point. The mistake that costs most is invisible in the request body above, and the only cheap moment to see it is before the first cycle runs. The portal makes this step mandatory before a policy can be enabled.

skipped is the part worth reading. 892 addresses below your floor is the line that tells you the floor is wrong. Set too low, it shows up as nine hundred tiny sweeps you are about to pay for; set too high, as a total that never moves.

gasTopUpsRequired is how many addresses hold no native currency. Each of those is a second transaction and a second confirmation wait, so it is the number that determines how long a job takes.

Jobs, and what PARTIAL means

A sweep job moves PLANNED to GAS_FUNDING to SWEEPING to CONFIRMING to COMPLETED, with FAILED and PARTIAL as branch outcomes; each address within it runs its own chain from PENDING to COMPLETED, with failed, expired and skipped outcomes.

Two state machines: one per job, one per address inside it.

PARTIAL is a first-class outcome, not an error. A 200-address job where 180 succeed and 20 run short of gas is a normal day. The failures are retried on the next cycle, and GET /v1/treasury/sweeps/jobs/{id} gives the per-address breakdown. Treating PARTIAL as a failure hides the 180 that moved.

POST /v1/treasury/sweeps/jobs/{id}/retry re-runs the failed operations only, never the skipped ones. A skip was a decision the guards made — the balance was dust, the fee ratio was too high — and retrying it reaches the same answer at the cost of another estimate. Change the policy instead.

Every operation carries a deterministic idempotency key, sha256(accountId : chain : token : addressId : jobId). It is derived rather than generated so that two processors reaching the same decision compute the same value and the second write fails instead of sweeping a balance that is already moving. A broadcast operation is never resolved from local state alone: the chain is always consulted first, which is what makes recovery after a restart safe.

Reserve floors

Three chains immobilise part of a balance, and none of them by a constant:

ChainFloor
XRP1 XRP base, plus 0.2 XRP for every owned ledger object — trust lines count
Stellar(2 + subentries) × 0.5 XLM — 1 XLM for a bare account, 0.5 XLM more per trust line
SolanaThe rent-exempt minimum, 890,880 lamports, below which the runtime collects the account

ChainOS queries the live requirement rather than using a constant, because owner counts change as trust lines are added. An address pinned entirely by its reserve is reported as SKIPPED_RESERVE and not as dust — it holds a balance and can move none of it, and those have different remedies.

A native sweep moves spendable − exact fee − retained float, where spendable already has the reserve deducted.

The chain-specific traps

ChainWatch for
BitcoinInputs are batched into one transaction, which is much cheaper and permanently links those addresses on-chain. Inputs worth less than they cost to spend are dropped. Signing a consolidation is not implemented yet — see the warning above
Ethereum · BSC · Polygon · Avalanche · BaseEvery token sweep needs native gas at the source address first. This dominates the work and the elapsed time
PolygonTwo USDCs circulate — native and bridged USDC.e — and only the native one is in the catalogue. There is nothing to sweep for the bridged form because nothing credits it
AvalancheThe address is Ethereum's but the gas is not. Its tank holds AVAX, and funding the Ethereum tank does nothing for it
BaseThe same mistake, harder to see: the address and the asset symbol are Ethereum's, and the balance still is not. Budget headroom for the L1 data fee
TRONEnergy, not gas. An address with no frozen TRX and no energy pays in burned TRX
SolanaLeave the account's own 890,880-lamport minimum, or the runtime collects the account
XRPLeave the 1 XRP base reserve. Emptying below it does not close the account — the send is refused
StellarA trust line's reserve stays locked for as long as the trust line exists

Each chain's page has the detail: Bitcoin, TRON, Solana, XRP, Stellar, Polygon, Avalanche, Base.

Billing

Sweeps are never billed. Not as settled transactions, not as overage, and the database refuses to record a sweep as billed rather than trusting the billing code to remember.

A sweep is an artifact of the architecture rather than customer value, and charging for it would create an incentive to avoid sweeping — which leaves funds scattered across thousands of deposit addresses and makes your treasury operations worse. Sweep as often as the fees justify; ChainOS is not the cost you are optimising against.

See What is billed.

Doing it yourself instead

The engine is optional. A sweep is a withdrawal from a deposit address to an address you control, so POST /v1/transactions has always been enough to build your own — and if your policy does not fit the model above, that path is unchanged and unrestricted.

What you would be reimplementing is the guards: the fee-ratio comparison, the dust floor, the reserve arithmetic on three chains, the gas top-up ordering, the in-flight check that stops two triggers sweeping one balance twice, and the confirmation-aware candidate selection. Send an Idempotency-Key keyed on something stable — the date, the chain and the address, not a fresh UUID — and treat INSUFFICIENT_GAS as a queue rather than a failure.

Do you need to sweep at all?

Consider not. Funds sitting at deposit addresses are:

  • Safe. Derived from your mnemonic, recoverable with the stored derivationPath and no ChainOS involvement.
  • Monitored. Balances and further deposits are tracked whether or not you consolidate.
  • Spendable in place. A withdrawal can go directly from a deposit address to a customer's destination without passing through treasury.

Sweeping is worth doing when your treasury operations need one balance to look at, when you are paying for cold storage of a single address, or when a downstream system expects a single source. It is not worth doing because it feels tidier — every sweep costs a fee and links addresses that were previously unlinked.