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

@@ -318,6 +318,14 @@ DERIVED_DESTINATION_MIN_SWEEP_AMOUNT=0
DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000
DERIVED_DESTINATION_SWEEP_AUTOSTART=true
# Master sweep wallet private key (pays gas for permit() + transferFrom() on non-BSC
# chains; sends BNB gas top-ups on BSC). Should be a dedicated low-balance hot wallet
# — NOT the same key used for escrow release/refund.
SWEEP_MASTER_PRIVKEY=
# BSC gas top-up thresholds (in BNB). If derived address BNB balance is below MIN, top up by TOP_UP.
SWEEP_GAS_MIN_BNB=0.001
SWEEP_GAS_TOP_UP_BNB=0.002
# OAuth
GOOGLE_CLIENT_ID=
```

View File

@@ -11,6 +11,17 @@ entries on top. Maintained by agents per the rule in `../AGENTS.md`.
---
### 2026-05-29 — backend@7688f57 — Sweep gas strategy: PermitPull + GasTopUp signers
**Commits:** backend `7688f57`
**Touched:**
- Backend: `src/services/payment/wallets/sweepService.ts`, `__tests__/sweep-service.test.ts`, `.env.example`
**Why:** Implement hybrid two-signer sweep strategy per `PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md`. `PermitPullSweepSigner` uses EIP-2612 permit for non-BSC chains (ETH, Arbitrum, Polygon, Base) so derived addresses never need native gas. `GasTopUpSweepSigner` handles BSC by topping up BNB from a master wallet before the derived address calls `transfer()`. `getSweepSigner(chainId, tokenSymbol)` auto-selects the correct signer. Static `PERMIT_CAPABLE_TOKENS` map seeded from on-chain audit 2026-05-29.
**Verification:** `tsc --noEmit` clean. `npx jest __tests__/sweep-service.test.ts` — 31/31 pass (including 16 new tests for auto-selection and permit capability matrix).
**Linked docs updated:** [[07 - Development/Environment Variables]], [[PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up]]
---
### 2026-05-28 — deployment@4e8658d — Gatus monitoring: Docker service + config
**Commits:** deployment `1ac2e74``4e8658d`

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

View File

@@ -1,6 +1,6 @@
# PRD: Wallet, Multichain, Confirmations, AML, Trezor
> Status: **Living — last edit 2026-05-28 (after Task #7 core shipped in backend/frontend 2.6.42)**
> Status: **Living — last edit 2026-05-29 (live sweep probe passed; gas strategy decided; sweep gas PRD created)**
> Author: nick + claude
> Owner: backend (payments) + frontend (admin UI + checkout)
> Related: `PRD - Request Network In-House Checkout.md`, `01 - Architecture/Request Network Integration Constraints.md`, `08 - Operations/Handoff - Request Network In-House Checkout - 2026-05-28.md`
@@ -42,6 +42,16 @@ These were open questions in the original draft; the shipped implementation lock
| Derivation index allocation | Monotonic counter in a `counters` Mongo collection (`{_id: 'derived_destination_index', seq: <int>}`) updated atomically via `findByIdAndUpdate { $inc: { seq: 1 } }`. No re-derivation, no race window. |
| Sweep strategy | **Cron-based** by default (`DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000`, i.e. 5 min) **plus manual admin trigger**. Both go through the same `sweepService` and the same Transaction Safety Provider checks. Auto-start on backend boot is not wired yet — admins start the cron via `POST /api/payment/derived-destinations/cron/start`. |
| Signing | **`DERIVED_DESTINATION_SWEEP_SIGNER=build-only`** in prod — the backend builds the sweep tx but doesn't sign it (Trezor flow in Task #11 will). For local dev, `DERIVED_DESTINATION_SWEEP_SIGNER=hot-key` plus `DERIVED_DESTINATION_XPRIV` lets the backend sign — DO NOT USE IN PROD. |
| Gas strategy | **BSC only: gas top-up before sweep** (master wallet sends BNB, then derived address calls `transfer`). All other chains: **EIP-2612 permit-pull** — derived address signs off-chain, master wallet calls `permit()` + `transferFrom()`, derived address never needs native gas. Audit confirmed: BSC USDT/USDC have no permit; ETH/ARB/Polygon/Base USDC+USDT all have EIP-2612. See `PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md`. |
### Live probe results (2026-05-29)
First real sweep executed on dev:
- Address: `0xF83bDD716724442693a2005dBeD06ad67089f830` (derivation index 1, `m/44'/60'/0'/0/1`)
- Funded with: 0.1 USDC (BSC) + 0.001 BNB (for gas, manually seeded)
- Sweep tx: `0x80cdb9ca104d624bc9783215618f82d5d758c6b2714b1ef13b999d51173b219d`
- Result: ✅ 0.1 USDC transferred to master `0xa3049825c0785095EEd5E7976E0E539466c84044`
- Bug found and fixed: `HotKeySweepSigner` was calling `derivePath("m/44'/60'/0'/0/1")` on a node already at depth 3 — must strip base prefix and pass only relative path `"0/1"`. Fixed in commit `1594f32`.
### Still open
1. **Multi-seller cart UX** — not built. Today's frontend assumes 1 Payment per checkout page. The PRD copy from the original draft still applies: