Sweeping pool addresses
Consolidate the USDT on your own pool addresses into your treasury, with keys that never leave an offline machine.
This guide is for merchants whose deposit addresses are their own wallet addresses (an EOA pool, see importing your addresses), which is every live merchant today. Forwarder addresses would need none of this: their funds can only flow to your payout address, and anyone may trigger that.
1. How it works, in one picture
online machine offline machine (your keys) online machine
───────────────────────────── ───────────────────────── ────────────────
plan ──► sweep-plan.json
build ──► sweep-unsigned.json ──USB──► sign ──► sweep-signed.json ──USB──► broadcast
(no secrets) (no network code at all) (resumable)| Step | Touches the network | Needs your keys | What it does |
|---|---|---|---|
plan | reads only | no | reads balances and prices, applies your flush policy, prints what it would move and what it costs |
build | reads only | no | re-reads balances, writes unsigned transactions to a file |
sign | never | yes | checks every transaction, then signs them all (or none) |
broadcast | sends | no | sends the signed transactions phase by phase; safe to re-run |
Every step writes its output file atomically and can be re-run. Nothing is ever half-written.
2. What you need
- A treasury address on each chain: where everything ends up. Ideally a hardware wallet or a multisig.
- A gas wallet on each chain: a normal wallet holding a little TRX / BNB / ETH. It pays the gas that your pool addresses need to move their USDT, and (on TRON) activates new addresses. Keep only what a few sweeps need in it.
- Your pool keys, in one of these forms, on the signing machine only:
- the mnemonic (12–24 words) your pool addresses were derived from;
- an xprv (the root key, or the account-level key that matches your pool xpub);
- keystore files (the encrypted JSON files geth, MetaMask, TronLink and
cast walletexport), one file or a folder of them; - a plain file of private keys, one per line (imported addresses).
- An RPC endpoint per chain. For TRON, TronGrid works; set an API key for more than a few dozen
addresses (see
rpc_api_key_env).
3. Get the tool
The sweep tool, cryptopay-sweep, is not publicly downloadable yet: during early access it comes with
onboarding, in two builds:
- the online build, for
plan,buildandbroadcast; - the offline build, for
sign: it physically contains no network code (no HTTP client, no async runtime, no TLS), onlysignandaddresses. Its test suite checks that this build links no networking libraries at all, so "sign never talks to the network" is a property of the binary, not a promise.
The ideal signing machine is air-gapped (never connected). A laptop with networking turned off is the practical minimum. Move files with a USB stick.
4. Configure: sweep.toml
One file per chain. It contains no secrets: only addresses, an xpub, and your policy. Copy it to
the signing machine too if you like, but sign does not need it.
TRON example
chain = "tron:mainnet" # tron:nile for testing
rpc_url = "https://api.trongrid.io"
rpc_api_key_env = "TRON_PRO_API_KEY" # name of an environment variable; the key is never in this file
allow_mainnet = true # required for any mainnet: real money moves
treasury = "T..." # where every USDT goes
[token]
address = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" # USDT (TRC-20)
symbol = "USDT"
decimals = 6
[gas_wallet]
address = "T..." # pays TRX for activations / energy
# hd_path = "m/44'/195'/1'/0/0" # only if its key comes from the same mnemonic as the pool
[pool]
xpub = "xpub6C..." # account-level xpub, e.g. m/44'/195'/0'
xpub_path = "m/44'/195'/0'" # default for TRON
indexes = "0..200" # derive m/44'/195'/0'/0/0 … /0/199
addresses = ["T...", "T..."] # plus any imported addresses (keys found by address)
[policy]
min_balance = "500" # sweep an address once it holds ≥ 500 USDT …
max_age_secs = 2592000 # … or its oldest unswept payment is ≥ 30 days old (needs --state)
retain_dust = "0.000001" # leave 1 base unit behind (see "Why leave dust?")
max_batch = 50 # at most 50 sweeps per run
max_unit_price = "210" # defer threshold-only sweeps while energy costs > 210 sun
[tron]
energy_source = "stake" # burn | stake | rented (see section 6)
rent_price_sun = 60 # your rental price, for the cost comparison
energy_margin_bps = 1000 # +10 % on simulated energy
max_fee_limit_sun = 50000000 # never let one sweep burn more than 50 TRX
expiration_secs = 21600 # built transactions stay valid 6 hBSC / Ethereum example
chain = "eip155:56" # 97 = BSC testnet, 1 = Ethereum, 11155111 = Sepolia
rpc_url = "https://bsc-dataseed.bnbchain.org"
allow_mainnet = true
treasury = "0x..."
[token]
address = "0x55d398326f99059fF775485246999027B3197955" # USDT (BEP-20, 18 decimals)
symbol = "USDT"
decimals = 18
[gas_wallet]
address = "0x..."
[pool]
xpub = "xpub6C..." # account-level xpub at m/44'/60'/0'
indexes = "0..200"
[policy]
min_balance = "500"
retain_dust = "0.000000000000000001"
max_unit_price = "5000000000" # defer threshold-only sweeps above 5 gwei
[evm]
# max_fee_per_gas = "3000000000" # default: 2 × base fee + priority fee
gas_limit_margin_bps = 2000 # +20 % on eth_estimateGasCheck the addresses the config derives before anything else:
cryptopay-sweep addresses --config sweep.tomlThey must be the addresses shown as your pool in the Tillsafe dashboard.
5. The routine
Step 1: plan (online, read-only)
cryptopay-sweep plan --config sweep.toml [--state pool-state.json]It prints each address it would sweep, how much it moves and keeps, and why (Risk, Age,
Balance), the gas each needs, and the totals. It writes sweep-plan.json. Example (a local test
chain):
0xA40c…5F6f move 29.999999 mUSDT keep 0.000001 (Risk) units 61584 top-up 0.00011 native
0x6Fac…b9C0 move 999.999999 mUSDT keep 0.000001 (Balance) units 61584 top-up 0 native
Not swept now:
0xb671…2D7A 30 BelowThreshold
0xF3f5…718E 0.000001 NothingAboveDust
Totals
sweeps 4
to treasury 2380.499996 mUSDT
cost (expected) 0.00037 native
gas wallet needs 0.00044 native (has 10000)The flush rule is Tillsafe's pool policy: sweep an address when its balance ≥ min_balance,
or its oldest payment is older than max_age_secs, or it is risk-flagged. Risk first, then
age, then the largest balances, up to max_batch. While the gas/energy price is above
max_unit_price, sweeps that only hit the balance threshold wait; age and risk sweeps always go.
--state is an optional per-address file telling the tool what a balance cannot: how old the oldest
unswept payment is, and whether the address is risk-flagged (for example a payer later appeared on a
blacklist). Tillsafe knows both; until the dashboard offers it as a download, write it by hand:
{ "addresses": [ { "address": "T...", "age_secs": 3000000, "risk_flag": false },
{ "address": "T...", "risk_flag": true } ] }Without it the tool only sees balances, so only the balance trigger applies.
Step 2: build (online, read-only)
cryptopay-sweep build --config sweep.tomlRe-reads every balance and nonce, then writes sweep-unsigned.json. If an address's balance fell
since the plan, it is left out (re-run plan). If the gas wallet does not hold what the plan needs,
build stops (fund it, or --force).
The file holds three phases:
- fund: from your gas wallet, the gas each pool address needs (EVM: exactly
gas limit × max feeminus what it already holds; TRON: see section 6), and on TRON the activation of addresses that have never held TRX; - sweep: from each pool address,
transfer(treasury, balance − dust); - reclaim (TRON
stakeonly): take the delegated energy back.
On TRON, transactions expire: sign and broadcast within expiration_secs (6 h by default).
Step 3: sign (offline)
Copy sweep-unsigned.json to the signing machine.
cryptopay-sweep sign --bundle sweep-unsigned.json `
--chain tron:mainnet --allow-mainnet --max-fee-limit-sun 30000000 `
--treasury T...your-treasury... `
--gas-wallet T...your-gas-wallet... `
--token TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t `
--key mnemonic=E:\pool-mnemonic.txt `
--key keystore=E:\gas-wallet.json --keystore-password-file E:\pw.txtType the treasury address yourself (from your hardware wallet, not from a file that came from the
online machine). sign refuses to sign anything unless every sweep pays exactly that address. The
same goes for --chain, --gas-wallet and --token (USDT's contract on that chain, from the
issuer's site): the unsigned file names all three, but only what you type counts. A mainnet chain also
needs --allow-mainnet.
Fee ceilings are required on every chain, testnets included: --max-fee-limit-sun on TRON;
--max-fee-per-gas and --max-gas-limit on EVM. Starting values (raise them only if sign refuses an
honest bundle you have checked, for example during a fee spike):
| Chain | Ceilings | What they allow per transaction |
|---|---|---|
TRON (tron:mainnet, tron:nile) | --max-fee-limit-sun 30000000 | 30 TRX (a sweep to a treasury with no USDT yet needs ~13 TRX of energy) |
BSC (eip155:56, eip155:97) | --max-fee-per-gas 10000000000 --max-gas-limit 150000 | 10 gwei × 150 000 gas = 0.0015 BNB |
Ethereum (eip155:1, eip155:11155111) | --max-fee-per-gas 100000000000 --max-gas-limit 150000 | 100 gwei × 150 000 gas = 0.015 ETH |
| Other EVM chains | the plan's worst-case max fee, plus a margin, and --max-gas-limit 150000 |
Key options (repeat --key as needed):
--key | file contents |
|---|---|
mnemonic=FILE | your BIP-39 words. Add --bip39-passphrase-file if you use a passphrase |
xprv=FILE | an xprv… (root, or the account key matching the pool xpub) |
keystore=FILE or keystore=DIR | encrypted JSON keystore(s); needs --keystore-password-file |
rawkeys=FILE | one hex private key per line; # comments allowed |
Secrets are only read from files, never from the command line (which would leave them in your shell
history). File contents, the seed and every private key are wiped from memory when sign is done
with them, and they are never printed or logged, not even in error messages (a mistyped mnemonic is
reported as "not a valid mnemonic", without the words).
What sign checks before signing anything. The unsigned file came from an online machine, so
sign treats it as untrusted and checks the transactions themselves, not their labels:
- every token movement is
transfer(<the treasury you typed>, amount)on the token, with no value attached; - every TRX / BNB / ETH movement goes from your gas wallet to one of your own pool addresses (one whose key you just provided and which has a sweep in the same file), never anywhere else;
- TRON energy delegations go to such an address and are never locked;
- the bytes to sign are rebuilt from the checked fields and must match the file byte for byte;
- the chain, the gas wallet and the token are the ones you typed, and every EVM chain id matches (no replay onto another chain);
- a top-up never sends a pool address more than its sweep can spend on fees;
- every TRON transaction expires within 24 hours of its timestamp (the network's own limit), so a signed file cannot stay usable for longer;
- fee ceilings you set, required on every chain:
--max-fee-per-gas <wei>and--max-gas-limit <gas>(EVM),--max-fee-limit-sun <sun>(TRON). Without them a hostile file could make you burn your gas wallet on fees (never send it anywhere), sosignrefuses to run without them. A mainnet bundle also needs--allow-mainnet.
If any check fails, nothing is signed. Otherwise it prints a summary (the chain, the token, how
much goes to the treasury, how much gas goes from your gas wallet to your own addresses, the most
fees can burn) and asks before it writes sweep-signed.json: type yes. In a script, read the
summary of a dry run first, then pass --yes; without a terminal and without --yes, nothing is
written. Signing is deterministic: signing the same file twice gives the same bytes.
Step 4: broadcast (online)
Copy sweep-signed.json back.
cryptopay-sweep broadcast --config sweep.toml --bundle sweep-signed.jsonIt sends the fund phase, waits until it is confirmed, then the sweeps, then (TRON stake) the
reclaims. It prints a line per transaction and the treasury balance before and after, and writes
sweep-signed.json.report.json and an append-only journal sweep-signed.json.journal.jsonl.
Exit codes: 0 all confirmed · 3 something is still pending or was not sent (re-run the same
command) · 4 finished, but some transactions were skipped, failed, expired or refused (read the
report) · 2 an error (bad config, RPC down, …): read it, fix it, re-run; everything already sent
is picked up again.
6. Costs, per chain
EVM (BSC, Ethereum)
Each pool address pays its own sweep, so it first receives gas from the gas wallet: exactly
gas limit × max fee per gas, minus any native balance it already has. The plan shows the expected
cost (at the current base fee) and the worst case (at your max fee). A little gas is usually left on
each address afterwards; it is used by the next sweep.
TRON: energy
A USDT transfer costs about 65 000 energy (to a treasury that already holds USDT; about 130 000 to one that holds none). Bandwidth for the sweep itself comes from each address's free daily allowance. The plan prices the energy three ways for every address:
energy_source | How the energy is paid | What the tool builds | Typical cost per sweep |
|---|---|---|---|
burn | the address burns TRX (getEnergyFee, 100 sun/energy today) | a TRX top-up per address, sized to the burn | ~6.5 TRX |
stake | your gas wallet has TRX staked for energy (Stake 2.0) and delegates it | delegate → sweep → undelegate; the energy regenerates, the TRX stays yours | bandwidth of 2 small txs (~0.6 TRX if the gas wallet has no free bandwidth left) |
rented | you rent energy from a marketplace to each listed address | only activations | your rental price × energy |
- Activation. An address that has only ever received USDT does not exist on TRON yet. The first TRX sent to it creates it and costs the sender ~1.1 TRX (once per address, ever). The gas wallet pays this as part of the fund phase.
stakeneeds enough staked TRX on the gas wallet: the plan shows the delegation total and warns if the gas wallet cannot cover it. Delegations are never locked, so the reclaim phase can undo them immediately.rented: the tool does not talk to rental markets. Afterbuild, rent energy to the addresses listed in the plan, then runbroadcast. Before each sweep it checks the address really has the energy (or the TRX to burn for it); if not, that sweep is skipped, not sent (TRON never refunds energy spent on a failed transaction). Rent, then re-runbroadcast.
Why leave dust?
retain_dust leaves 1 base unit (0.000001 USDT) on each address. A USDT transfer to an address that
holds zero USDT costs the payer about twice the energy of one to an address that holds some. Your
customers pay less, and it costs you nothing. Set "0" to empty addresses completely.
7. When something goes wrong
Every step is safe to re-run. The chain, not the tool's memory, is the source of truth.
| Situation | What happens | What to do |
|---|---|---|
broadcast crashed, was killed, or the connection dropped | on re-run, every transaction is looked up on chain first: confirmed ones are never re-sent; the same signed bytes are re-sent for the rest (a chain cannot include a transaction twice) | re-run broadcast |
| Exit code 3 (pending after the timeout) | nothing is lost | re-run broadcast later (--phase-timeout-secs to wait longer) |
| A fund transaction failed | that address's sweep is skipped (it would only burn a fee) | re-run plan → build → sign |
A sweep shows Skipped: balance … below … | the funds already moved (an earlier run, or you moved them by hand) | nothing |
Expired (TRON) | the bundle is older than expiration_secs | re-run plan → build → sign → broadcast |
Superseded (EVM) | that address's nonce was used by another transaction | re-run plan |
You ran build twice and signed both | on EVM, both bundles use the same nonces, so only one of each can ever be mined. On TRON, the second sweep of the same address is skipped because the balance is already gone | prefer broadcasting one bundle |
sign refused | read the message: it names the transaction and the rule it broke. Do not work around it. If the unsigned file was tampered with, your online machine is compromised | investigate before signing anything |
8. Security summary
- Tillsafe never sees your pool keys. They are only ever read by
sign, on your machine. - The signing build contains no network code. The online commands never read keys.
- What a hostile or buggy unsigned file can make you sign is bounded: USDT only to the treasury you typed; gas only to your own addresses; fees only up to the fee ceilings (mandatory on every chain).
- Mainnet runs need
allow_mainnet = truein the config, and--allow-mainnetwhen signing. - Keep the gas wallet small. Keep the treasury on a hardware wallet or a multisig.
9. Limits (today)
- One token per config file (run it once per token and chain).
- The tool does not rent energy for you and does not return leftover gas from pool addresses.
- Keystores whose scrypt parameters break RFC 7914 (
r = 1withn ≥ 65536, used only by one old test vector) are refused; keystores from geth, MetaMask, TronLink and Foundry are fine. - Reads are sequential: a pool of a few hundred addresses takes a minute or two on public RPCs.