9.5 KiB
PRD: Sweep Gas Strategy — Permit Pull + BSC Gas Top-Up
Status: Draft — 2026-05-29 Author: nick + claude Owner: backend (payments/wallets) Related:
PRD - Wallet, Multichain, Confirmations, AML, Trezor.md§1 (Task #7),sweepService.ts
Background
Task #7 delivered HD-derived destination wallets and a sweep service. The first live sweep test (2026-05-29) revealed a fundamental operational gap: derived destination addresses need the native gas token (BNB on BSC, ETH on Ethereum/Arbitrum/Base, MATIC on Polygon) to pay for the ERC-20 transfer() sweep transaction. Today that gas has to be manually seeded — unsustainable in production.
This PRD specifies the solution: a hybrid two-signer strategy that eliminates manual gas funding for all supported chains.
Chain-by-Chain Permit Audit (2026-05-29)
Verified on-chain by calling DOMAIN_SEPARATOR() + nonces(address) against each token contract:
| Chain | Token | Contract | EIP-2612 permit | Notes |
|---|---|---|---|---|
| BSC (56) | USDC | 0x8ac76a51… |
❌ NO | Binance-Peg — plain ERC-20 proxy, no gasless standard |
| BSC (56) | USDT | 0x55d398… |
❌ NO | Binance-Peg Tether — plain ERC-20, no gasless standard |
| Ethereum (1) | USDC | 0xA0b869… |
✅ YES | Circle FiatToken v2, EIP-2612 |
| Ethereum (1) | USDT | 0xdAC17F… |
❌ NO | Tether — no permit, never will |
| Arbitrum (42161) | USDC | 0xaf88d0… |
✅ YES | Circle native FiatToken v2.2, EIP-2612 |
| Arbitrum (42161) | USDT | 0xFd086b… |
✅ YES | USDT0 (bridged Tether v2 on Arbitrum — has permit) |
| Polygon (137) | USDC | 0x2791Bc… |
✅ YES | Circle FiatToken (PoS bridge), EIP-2612 |
| Polygon (137) | USDT | 0xc2132D… |
✅ YES | USDT0 on Polygon, EIP-2612 |
| Base (8453) | USDC | 0x833589… |
✅ YES | Circle native FiatToken v2.2, EIP-2612 |
Summary: BSC is the only chain where neither USDC nor USDT supports gasless approval. All other chains are permit-capable.
Solution: Two Signer Modes
Mode A — PermitPullSweepSigner (all non-BSC chains)
EIP-2612 permit is gasless: the derived address signs a typed-data message off-chain (no broadcast, no gas). The master wallet then calls permit(owner, spender, amount, deadline, v, r, s) followed by transferFrom(derived → master) — master pays all gas.
Derived addresses on ETH/ARB/Polygon/Base never need ETH/MATIC. Ever.
Flow:
- Backend derives the private key for the derived address (from
DERIVED_DESTINATION_XPRIV). - Signs EIP-712 permit message off-chain →
(v, r, s, deadline). - Master wallet broadcasts:
token.permit(derived, master, balance, deadline, v, r, s)→token.transferFrom(derived, master, balance). - Master wallet pays gas; derived address contributes zero native tokens.
New env vars:
SWEEP_MASTER_PRIVKEY=0x... # private key of ESCROW_WALLET_ADDRESS (master sweep destination)
Mode B — GasTopUpSweepSigner (BSC only)
BSC tokens have no permit. The derived address must call transfer() itself and needs BNB for gas. Before sweeping, the master wallet checks the derived address's BNB balance and tops it up if below threshold.
Flow:
- Query derived address BNB balance.
- If below
SWEEP_GAS_MIN_BNB(default: 0.001 BNB ≈ $0.60), sendSWEEP_GAS_TOP_UP_BNB(default: 0.002 BNB) from master wallet. - Wait 1 block for confirmation.
- Derived address calls
transfer(master, balance)as before.
New env vars:
SWEEP_MASTER_PRIVKEY=0x... # same key as above — master wallet pays gas top-up
SWEEP_GAS_MIN_BNB=0.001 # top up if below this
SWEEP_GAS_TOP_UP_BNB=0.002 # amount to send when topping up
Signer selection
getSweepSigner() in sweepService.ts is extended to accept chainId and tokenSymbol. It auto-selects:
chainId === 56 → GasTopUpSweepSigner
else + token has permit → PermitPullSweepSigner
fallback → build-only (unchanged)
The permit-capability table is encoded as a static map in sweepService.ts, seeded from the audit above. New chains/tokens can be added to the map as they are verified.
Implementation Plan
Phase 1 — PermitPullSweepSigner (non-BSC)
File: backend/src/services/payment/wallets/sweepService.ts
- Add
PermitPullSweepSignerclass implementingSweepSigner. signAndBroadcast(tx):- Derive private key from
DERIVED_DESTINATION_XPRIV+ relative path (existing pattern). - Compute EIP-712 domain: fetch
DOMAIN_SEPARATOR()from token contract, decode chainId + verifyingContract + name + version. - Build permit struct:
{ owner, spender: masterWallet, value: balance, nonce: token.nonces(owner), deadline: now + 1 hour }. - Sign with
ethers.Wallet(derivedPrivKey).signTypedData(domain, types, values)→ extractv, r, s. - Build
permit()calldata +transferFrom()calldata. - Master wallet sends
permit()tx → wait 1 block → sendstransferFrom()tx.
- Derive private key from
- Add
PERMIT_CAPABLE_TOKENSmap (chain → token address →true). - Extend
getSweepSigner(chainId, tokenAddress)to consult the map.
Phase 2 — GasTopUpSweepSigner (BSC)
File: backend/src/services/payment/wallets/sweepService.ts
- Add
GasTopUpSweepSignerclass. - Before sweep:
- Query BNB balance of derived address.
- If below
SWEEP_GAS_MIN_BNB, send top-up BNB from master wallet (plain ETH transfer). - Poll until top-up tx is mined (or timeout after 30s).
- Proceed with existing
HotKeySweepSignersign-and-broadcast flow.
Phase 3 — Env + docs
- Add
SWEEP_MASTER_PRIVKEYtobackend/.env.examplewith a comment explaining it's the private key ofESCROW_WALLET_ADDRESS. - Update
PRD - Wallet, Multichain, Confirmations, AML, Trezor.md§1 Decisions table. - Update
08 - Operations/Handoff - Request Network In-House Checkout - 2026-05-28.mdsweep section.
Security considerations
SWEEP_MASTER_PRIVKEYis a hot key that lives in env. It should be a dedicated sweep wallet — not the same private key used for any other purpose.- The sweep wallet only needs enough BNB/native token for gas top-ups. Keep its balance small (e.g. 0.1 BNB = ~$60 float). Replenish manually or via a cold-wallet scheduled transfer.
DERIVED_DESTINATION_XPRIVremains dev-only in production (Task #11 Trezor replaces it). Until Trezor is wired, a minimal-scope hot key for signing permits is acceptable on dev/staging.- Permit signatures use a 1-hour deadline — expired permits can't be replayed.
PermitPullSweepSignernever broadcasts from the derived address, so a leaked xpriv doesn't give an attacker the ability to drain funds (they can't pay gas). Gas-top-up amounts are tiny, so leakedSWEEP_MASTER_PRIVKEY+ BSC xpriv = attacker can drain BSC addresses only, bounded by balance.
Acceptance criteria
- Non-BSC sweep (e.g. Arbitrum USDC): derived address has zero ETH, master wallet calls
permit+transferFrom, sweep succeeds, derived address ETH balance unchanged. - BSC sweep: derived address starts with zero BNB, master sends top-up, BNB arrives,
transfer()fires, USDC lands on master, derived status =swept. - If token not in
PERMIT_CAPABLE_TOKENSmap and not BSC → falls through tobuild-only(no silent failure). - Dry-run mode still works for both signers (no on-chain tx, returns projected amounts).
- All existing
sweepServiceunit tests pass; 2 new integration tests added (one per signer mode).
Out of scope
- EIP-3009
transferWithAuthorization— Circle's USDC on some chains supports this as an alternative. Since EIP-2612permitis confirmed working on all target chains, 3009 adds complexity with no benefit here. - Meta-transaction relayers (GSN, Gelato) — overkill for infrequent sweep operations.
- Multi-call batching
permit+transferFrominto a single tx — nice-to-have, not required for v1.
Status
| Phase | Status |
|---|---|
| Chain permit audit | ✅ Done (2026-05-29) |
| Native token sweep (BNB/ETH residue cleanup) | ✅ Done (2026-05-29) — sweepNativeFromDestination(), queryNativeBalance(), GET /:id/native-balance, POST /:id/sweep-native, SweepConfig.sweepNative flag |
| Phase 1: PermitPullSweepSigner | ✅ Done — PermitPullSweepSigner class with EIP-712 off-chain signing, master wallet broadcasts permit() + transferFrom() |
| Phase 2: GasTopUpSweepSigner | ✅ Done — GasTopUpSweepSigner class checks BNB balance, tops up from master wallet, then derived key signs transfer() |
| Phase 3: Env + docs | ✅ Done — SWEEP_MASTER_PRIVKEY, SWEEP_GAS_MIN_BNB, SWEEP_GAS_TOP_UP_BNB added to .env.example; getSweepSigner(chainId, tokenSymbol) auto-selects signer; permit map seeded from audit |
Native sweep — what shipped (2026-05-29)
sweepNativeFromDestination(dest, { dryRun, masterWallet }):
- Queries BNB/ETH/MATIC balance of derived address via
eth_getBalance - Calculates gas cost for a plain transfer (21 000 gas × gasPrice × 1.2 buffer)
- If balance ≤ gas cost: skips with
skipReason: balance_too_low_to_cover_gas - Otherwise: derives private key from
DERIVED_DESTINATION_XPRIV(same as ERC-20 sweep), sendsbalance − gasCostWithBuffertoESCROW_WALLET_ADDRESS - Returns
{ success, txHash, amount }— same shape asSweepResult
Admin endpoints added:
GET /api/payment/derived-destinations/:id/native-balance— on-chain native balance in raw + ether unitsPOST /api/payment/derived-destinations/:id/sweep-native— trigger native sweep for one destination ({ dryRun: bool })- Existing
POST /api/payment/derived-destinations/sweepnow acceptssweepNative: trueto run native cleanup after ERC-20 sweep in the same call