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

173 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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