diff --git a/services/rebalance/README.md b/services/rebalance/README.md index fe15bbd7..9bfe9f8e 100644 --- a/services/rebalance/README.md +++ b/services/rebalance/README.md @@ -103,7 +103,8 @@ owner (`session-key withdrawals are not supported`). So USDC leaving sub 15 land maker's wallet, signed by the market maker's key, and no delegation to this signer is possible. That leaves the operator in the loop for one step: withdraw, then forward to this signer. `check` -exists to make that step reliably prompted rather than remembered. Automating it properly means the +exists to make that step reliably prompted rather than remembered, and **[RUNBOOK.md](./RUNBOOK.md) +is the procedure it prompts for** — addresses, both withdrawal routes, and the traps. Automating it properly means the market maker doing the withdrawal itself — it already holds the key and already knows when USDC is piling up — which is a change to the Go service and a separate piece of work. diff --git a/services/rebalance/RUNBOOK.md b/services/rebalance/RUNBOOK.md new file mode 100644 index 00000000..a523d53b --- /dev/null +++ b/services/rebalance/RUNBOOK.md @@ -0,0 +1,98 @@ +# Runbook — `cNGN rebalance due` + +What to do when `pnpm rebalance check --alert` posts to the ops channel. Background and thresholds +are in [README.md](./README.md); this is the procedure. + +**The loop has four steps and the first one is manual**, because a withdrawal pays only to the +subaccount owner and cannot be delegated to this service (README, *Not done yet*). + +``` +sub 15 ──[ 1. withdraw ]──▶ MM wallet ──[ 2. transfer ]──▶ rebalance signer ──[ 3. swap ]──▶ cNGN ──[ 4. deposit ]──▶ sub 15 + owner-signed, plain ERC-20 pnpm rebalance pnpm rebalance + MM key approve + swap deposit +``` + +## The addresses + +| What | Address | +| --- | --- | +| Subaccount | `15` | +| Owner of sub 15 — **withdrawals pay here** | `0x3448ac0A3283951A2AFD5B3A582329ECA43CB47B` | +| Rebalance signer (KMS `alias/numo-exchange-rebalance`) | `0x1661AA54fA390cd916722F971e4A9Fe4c01889fB` | +| USDC token (6 dp) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` | +| Wrapped USDC escrow | `0x364058aFF6f36E01505fB2Cc870f8B6BD4835e84` | +| WithdrawalModule | `0x0a10AE2f5D2482cE1e43bC309D430B8861C2b5aB` | +| Matching | `0x9E90A9cD13d859Bd6a08168082FB1F6F7405F191` | + +`SubAccounts.ownerOf(15)` returns **Matching**, not the owner — the account is deposited. The owner +is `Matching.subAccountToOwner(15)`. Read the mapping, not the ERC-721. + +## 0. Confirm it is real + +```bash +pnpm rebalance check +``` + +Re-read the book before acting on a message that may be minutes old. If it now says `healthy`, +stop — the market maker may have rebalanced itself by trading. Only `rebalance` or `urgent` +justifies the steps below. + +## 1. Withdraw from sub 15 → MM wallet + +Signed by the **market maker's key**, for `action.owner == action.signer ==` +`0x3448ac0A…`. Two routes, both proven on this escrow: + +- **Through Matching** (what a deposited subaccount uses): an owner-signed action on the + WithdrawalModule, submitted via `Matching.verifyAndMatch` (`0x74d906c3`). Precedent: sub 19's + owner withdrew 1.999575 USDC this way in + [`0xffff17a9…5e0175`](https://basescan.org/tx/0xffff17a9814e32bedd2bcdeffb214dc1293bac59d634560af73200293a5e0175) + (block 51301502) — same escrow, same structure as sub 15. +- **Directly on the escrow** (`0x0ad58d2f`), only for a subaccount *not* deposited in Matching. + Sub 15 **is** deposited, so this route does not apply to it. + +`action.data` is `abi.encode(address asset, uint256 amount)` — exactly 64 bytes, **no recipient +field**. The amount is in the token's native decimals (**6**), while subaccount balances read back +in 18. Do not copy a ledger figure into the amount. + +## 2. Transfer USDC → rebalance signer + +A plain ERC-20 `transfer` from `0x3448ac0A…` to `0x1661AA54…`. Nothing venue-specific. + +This step exists only because step 1 cannot name a recipient. It is the step most likely to be +forgotten, because the alert fires about sub 15 and this touches neither the subaccount nor the CLI. + +## 3. Swap USDC → cNGN + +```bash +export BASE_RPC_URL=... # keyed Alchemy; the public endpoint rate-limits the SDK +pnpm rebalance quote 200 # sanity-check the rate first +pnpm rebalance approve 200 --execute +pnpm rebalance swap 200 --execute +``` + +`swap` places the intent, runs the auction and waits for the fill. If it does not fill, +`pnpm rebalance cancel --execute` reclaims everything but the 5 bps gateway fee. + +## 4. Deposit cNGN → sub 15 + +```bash +pnpm rebalance deposit --execute # whole cNGN balance +pnpm rebalance check # confirm the share moved +``` + +## Traps + +**A successful operation can report as failed.** Base RPC replicas lag, so a balance read straight +after a write may show the old value. `waitFor()` covers the paths the CLI uses, but if a step +reports failure, **check the chain before retrying** — retrying a completed swap or deposit spends +real money twice. + +**Both wallets need ETH on Base.** A full cycle is about $0.02, but a wallet at zero fails at the +worst moment. Check `0x3448ac0A…` and `0x1661AA54…` before starting. + +**Never grant the rebalance role `kms:Sign` on the market maker's key.** KMS grants are not +partial: it would confer authority to cancel every order and withdraw everything, undoing the +separation this split exists to create. + +**Do not route funds to the legacy CashAsset `0x6B232A21…6fc6`.** It holds ~2 USDC against ~69 of +claims. The escrow in this runbook (`0x364058aF…`) is a different contract and is solvent. diff --git a/services/rebalance/src/inventory.test.ts b/services/rebalance/src/inventory.test.ts index 1fed5f9b..f623edc7 100644 --- a/services/rebalance/src/inventory.test.ts +++ b/services/rebalance/src/inventory.test.ts @@ -52,6 +52,17 @@ test('an empty subaccount is empty, not lopsided', () => { test('says what to do, because the next step cannot be automated', () => { const v = assessInventory({ usdc: ledger(500), cngn: ledger(150_000), rate: RATE }, thresholds, SUB); assert.match(v.message, /Withdraw USDC from the subaccount/); + assert.match(v.message, /RUNBOOK\.md/); +}); + +test('the next step is described as two moves, not a withdrawal to the signer', () => { + // A withdrawal's data is (asset, amount) with no recipient, so it ALWAYS pays the subaccount + // owner. The message used to read "withdraw to the rebalance signer", which is an instruction the + // chain cannot carry out -- the operator is sent looking for an argument that does not exist. + const v = assessInventory({ usdc: ledger(500), cngn: ledger(150_000), rate: RATE }, thresholds, SUB); + assert.match(v.message, /pays the owner/); + assert.match(v.message, /forward it to the rebalance signer/); + assert.doesNotMatch(v.message, /Withdraw USDC from the subaccount to the rebalance signer/); }); test('refuses to value the cNGN side without a rate', () => { diff --git a/services/rebalance/src/inventory.ts b/services/rebalance/src/inventory.ts index ed6172db..4aa0990f 100644 --- a/services/rebalance/src/inventory.ts +++ b/services/rebalance/src/inventory.ts @@ -80,8 +80,13 @@ export function assessInventory(reading: InventoryReading, thresholds: Inventory const body = reasons.length ? `${balances}. ${reasons.join('; ')}.` : `${balances}.`; // The next step is a human one -- a withdrawal pays out only to the subaccount owner and cannot // be delegated -- so the message says what to do rather than just what is true. + // + // It is TWO moves, not one. An earlier version read "withdraw to the rebalance signer", which the + // chain cannot do: the action data is (asset, amount) with no recipient, so a withdrawal always + // pays the owner. An operator following that literally goes looking for an argument that does not + // exist. Full procedure in services/rebalance/RUNBOOK.md. const next = action === 'none' ? '' - : ' Withdraw USDC from the subaccount to the rebalance signer, then `pnpm rebalance swap --execute` and `pnpm rebalance deposit --execute`.'; + : ' Withdraw USDC from the subaccount (pays the owner), forward it to the rebalance signer, then `pnpm rebalance swap --execute` and `pnpm rebalance deposit --execute`. Runbook: services/rebalance/RUNBOOK.md.'; return { action, usdcUsd, cngnUsd, cngnShare, reasons, message: `${head}: ${body}${next}` }; }