diff --git a/07 - Development/Environment Variables.md b/07 - Development/Environment Variables.md index 2bb7d5f..b182971 100644 --- a/07 - Development/Environment Variables.md +++ b/07 - Development/Environment Variables.md @@ -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= ``` diff --git a/09 - Audits/Activity Log.md b/09 - Audits/Activity Log.md index 1fd8ac1..5224767 100644 --- a/09 - Audits/Activity Log.md +++ b/09 - Audits/Activity Log.md @@ -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` diff --git a/PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md b/PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md new file mode 100644 index 0000000..7a32c64 --- /dev/null +++ b/PRD - Sweep Gas Strategy - Permit Pull vs Gas Top-Up.md @@ -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 diff --git a/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md b/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md index b888343..cde1d6a 100644 --- a/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md +++ b/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md @@ -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: }`) 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: