Files
nick-doc/PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md

9.5 KiB
Raw Permalink Blame History

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