# 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: 1. Backend derives the private key for the derived address (from `DERIVED_DESTINATION_XPRIV`). 2. Signs EIP-712 permit message off-chain → `(v, r, s, deadline)`. 3. Master wallet broadcasts: `token.permit(derived, master, balance, deadline, v, r, s)` → `token.transferFrom(derived, master, balance)`. 4. 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: 1. Query derived address BNB balance. 2. If below `SWEEP_GAS_MIN_BNB` (default: 0.001 BNB ≈ $0.60), send `SWEEP_GAS_TOP_UP_BNB` (default: 0.002 BNB) from master wallet. 3. Wait 1 block for confirmation. 4. 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` 1. Add `PermitPullSweepSigner` class implementing `SweepSigner`. 2. `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)` → extract `v, r, s`. - Build `permit()` calldata + `transferFrom()` calldata. - Master wallet sends `permit()` tx → wait 1 block → sends `transferFrom()` tx. 3. Add `PERMIT_CAPABLE_TOKENS` map (chain → token address → `true`). 4. Extend `getSweepSigner(chainId, tokenAddress)` to consult the map. ### Phase 2 — GasTopUpSweepSigner (BSC) **File:** `backend/src/services/payment/wallets/sweepService.ts` 1. Add `GasTopUpSweepSigner` class. 2. 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). 3. Proceed with existing `HotKeySweepSigner` sign-and-broadcast flow. ### Phase 3 — Env + docs 1. Add `SWEEP_MASTER_PRIVKEY` to `backend/.env.example` with a comment explaining it's the private key of `ESCROW_WALLET_ADDRESS`. 2. Update `PRD - Wallet, Multichain, Confirmations, AML, Trezor.md` §1 Decisions table. 3. Update `08 - Operations/Handoff - Request Network In-House Checkout - 2026-05-28.md` sweep section. --- ## Security considerations - `SWEEP_MASTER_PRIVKEY` is 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_XPRIV` remains 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. - `PermitPullSweepSigner` never 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 leaked `SWEEP_MASTER_PRIVKEY` + BSC xpriv = attacker can drain BSC addresses only, bounded by balance. --- ## Acceptance criteria 1. Non-BSC sweep (e.g. Arbitrum USDC): derived address has zero ETH, master wallet calls `permit` + `transferFrom`, sweep succeeds, derived address ETH balance unchanged. 2. BSC sweep: derived address starts with zero BNB, master sends top-up, BNB arrives, `transfer()` fires, USDC lands on master, derived status = `swept`. 3. If token not in `PERMIT_CAPABLE_TOKENS` map and not BSC → falls through to `build-only` (no silent failure). 4. Dry-run mode still works for both signers (no on-chain tx, returns projected amounts). 5. All existing `sweepService` unit 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-2612 `permit` is 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` + `transferFrom` into 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), sends `balance − gasCostWithBuffer` to `ESCROW_WALLET_ADDRESS` - Returns `{ success, txHash, amount }` — same shape as `SweepResult` Admin endpoints added: - `GET /api/payment/derived-destinations/:id/native-balance` — on-chain native balance in raw + ether units - `POST /api/payment/derived-destinations/:id/sweep-native` — trigger native sweep for one destination (`{ dryRun: bool }`) - Existing `POST /api/payment/derived-destinations/sweep` now accepts `sweepNative: true` to run native cleanup after ERC-20 sweep in the same call