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.
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.

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
| Mode | Behaviour |
|---|---|
MANUAL | API or portal only |
THRESHOLD | Evaluated when a deposit confirms on that chain |
SCHEDULE | Cron; sweeps everything above the dust floor |
THRESHOLD_AND_SCHEDULE | Both — 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

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:
| Chain | Floor |
|---|---|
| XRP | 1 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 |
| Solana | The 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
| Chain | Watch for |
|---|---|
| Bitcoin | Inputs 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 · Base | Every token sweep needs native gas at the source address first. This dominates the work and the elapsed time |
| Polygon | Two 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 |
| Avalanche | The address is Ethereum's but the gas is not. Its tank holds AVAX, and funding the Ethereum tank does nothing for it |
| Base | The 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 |
| TRON | Energy, not gas. An address with no frozen TRX and no energy pays in burned TRX |
| Solana | Leave the account's own 890,880-lamport minimum, or the runtime collects the account |
| XRP | Leave the 1 XRP base reserve. Emptying below it does not close the account — the send is refused |
| Stellar | A 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
derivationPathand 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.