docs: sync from backend 7688f57 — sweep gas strategy: PermitPull + GasTopUp signers

This commit is contained in:
Siavash Sameni
2026-05-29 10:13:44 +04:00
parent 8623762b85
commit eeb8066b87
4 changed files with 202 additions and 1 deletions

View File

@@ -0,0 +1,172 @@
# 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