TillsafeDocstillsafe.comRequest early access

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

text
 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)
StepTouches the networkNeeds your keysWhat it does
planreads onlynoreads balances and prices, applies your flush policy, prints what it would move and what it costs
buildreads onlynore-reads balances, writes unsigned transactions to a file
signneveryeschecks every transaction, then signs them all (or none)
broadcastsendsnosends 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 wallet export), 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, build and broadcast;
  • the offline build, for sign: it physically contains no network code (no HTTP client, no async runtime, no TLS), only sign and addresses. 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

toml
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 h

BSC / Ethereum example

toml
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_estimateGas

Check the addresses the config derives before anything else:

powershell
cryptopay-sweep addresses --config sweep.toml

They must be the addresses shown as your pool in the Tillsafe dashboard.

5. The routine

Step 1: plan (online, read-only)

powershell
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):

text
  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:

json
{ "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)

powershell
cryptopay-sweep build --config sweep.toml

Re-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:

  1. fund: from your gas wallet, the gas each pool address needs (EVM: exactly gas limit × max fee minus what it already holds; TRON: see section 6), and on TRON the activation of addresses that have never held TRX;
  2. sweep: from each pool address, transfer(treasury, balance − dust);
  3. reclaim (TRON stake only): 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.

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

Type 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):

ChainCeilingsWhat they allow per transaction
TRON (tron:mainnet, tron:nile)--max-fee-limit-sun 3000000030 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 15000010 gwei × 150 000 gas = 0.0015 BNB
Ethereum (eip155:1, eip155:11155111)--max-fee-per-gas 100000000000 --max-gas-limit 150000100 gwei × 150 000 gas = 0.015 ETH
Other EVM chainsthe plan's worst-case max fee, plus a margin, and --max-gas-limit 150000

Key options (repeat --key as needed):

--keyfile contents
mnemonic=FILEyour BIP-39 words. Add --bip39-passphrase-file if you use a passphrase
xprv=FILEan xprv… (root, or the account key matching the pool xpub)
keystore=FILE or keystore=DIRencrypted JSON keystore(s); needs --keystore-password-file
rawkeys=FILEone 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), so sign refuses 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.

powershell
cryptopay-sweep broadcast --config sweep.toml --bundle sweep-signed.json

It 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_sourceHow the energy is paidWhat the tool buildsTypical cost per sweep
burnthe address burns TRX (getEnergyFee, 100 sun/energy today)a TRX top-up per address, sized to the burn~6.5 TRX
stakeyour gas wallet has TRX staked for energy (Stake 2.0) and delegates itdelegate → sweep → undelegate; the energy regenerates, the TRX stays yoursbandwidth of 2 small txs (~0.6 TRX if the gas wallet has no free bandwidth left)
rentedyou rent energy from a marketplace to each listed addressonly activationsyour 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.
  • stake needs 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. After build, rent energy to the addresses listed in the plan, then run broadcast. 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-run broadcast.

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.

SituationWhat happensWhat to do
broadcast crashed, was killed, or the connection droppedon 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 lostre-run broadcast later (--phase-timeout-secs to wait longer)
A fund transaction failedthat 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_secsre-run plan → build → sign → broadcast
Superseded (EVM)that address's nonce was used by another transactionre-run plan
You ran build twice and signed bothon 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 goneprefer broadcasting one bundle
sign refusedread 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 compromisedinvestigate 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 = true in the config, and --allow-mainnet when 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 = 1 with n ≥ 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.