Converts position_manager from a single-signer process into ONE process that trades from N signer wallets concurrently — a “wallet axis” running parallel to the existing “source axis”. Built 2026-07-01, merge-ready on branch, NOT yet deployed (mode OFF, 179 offline tests green).
For Agents
Repo:
/Users/levander/coding/pmv2/position_manager. Branchfeat/multi-signer-wallet, 18 commits froma501ddd, HEADc6bd9d8— LOCAL/UNPUSHED. Design spec:docs/superpowers/specs/2026-07-01-pm-multi-signer-wallet-design.md. Plan:docs/superpowers/plans/2026-07-01-pm-multi-signer-wallet.md. SDD ledger:.superpowers/sdd/progress.md. Ships mode OFF / fail-closed; entries only route once the registry + routing tables are seeded together.
What Changed (single-signer → wallet axis)
Before, PM signed every order with one global signer. Now PM arms a fleet of wallets and each order is routed to one (or more) wallet(s). Two independent axes:
- Source axis (existing) — which producer/
source_idemitted the order (see pmv2-source-id-3way-invariant-2026-06-23). - Wallet axis (new) — which signer wallet actually posts + signs the trade.
New Schema
| Table | Purpose |
|---|---|
pmv2_wallets | Wallet registry. wallet_id PK, maker_proxy, signature_type, enc_private_key/key_nonce (BYTEA, AES-256-GCM), mode, per-wallet caps (total_exposure, daily_spend, max_open, max_copies_per_event), size_scale NUMERIC DEFAULT 1.0. |
pmv2_source_wallets | source_id ↔ wallet_id many-to-many routing map. |
pmv2_autotrade_orders | Gained wallet_id column; dedup index changed to (wallet_id, dedup_key). |
Three migrations total (registry, routing, orders wallet_id + dedup index).
Core Design Decisions
- Routing — configurable per source→wallet, many-to-many via
pmv2_source_wallets. One source can fan out to multiple wallets; one wallet can serve multiple sources. - Isolation — enforced by per-wallet caps + ONE global breaker (not per-wallet breakers).
- Key storage — private keys encrypted in DB (AES-256-GCM), master key from env
PM_WALLET_ENC_KEY. Decrypted at arm time intosigner_for(wallet_id). - Ops surface — per-wallet + an
allselector. (Currently bridged over awallet:-prefix ops hack untilpmv2-contractsadds a realwalletwire field.) - Execution — one durable NATS consumer per wallet (
pmv2_orders_<wallet_id>). Distinct durable names = JetStream fan-out, so every wallet consumer sees every routed order. - Mode resolution is 3-way:
effective = min(global_ceiling, wallet.mode, source.mode), fail-closed. (Old path was 2-waymin(global, source).) - Per-wallet
size_scalemultiplier on the producersize_usd, applied pre-quantization.1.0= identical sizing to today; a smaller-bankroll wallet uses< 1.0.
Cap Breach = Advisory, Breaker = Faults Only
Semantic split
A per-wallet cap breach is ADVISORY — it skips/rejects that order but does NOT trip the breaker. The global breaker trips only on genuine faults: balance / auth / network / settlement.
breaker_countsno longer includes a"cap"category. This keeps a chatty over-cap wallet from taking down the whole fleet.
Wallet-Aware Entry & Exit
- Entry — the authoritative money gate
place_entryresolves viaresolve_wallet_entry_mode(wallet_id, source_id);place_buy/place_sellsign viasigner_for(wallet_id). - Exit — each exit row loads its own
wallet_idand signs with the position’s OWN wallet. A missing signer →BenignRace(skip), so PM can never sell a position from the wrong wallet.
Gotchas / Lessons (these bit us mid-build)
SAFETY GAP — wallet-blind money gate (fixed
b2faddf)The authoritative entry gate
place_entrywas originally wallet-BLIND: it used the 2-wayresolve_entry_mode(global, source). A wallet set todrywhile global+source werelivewould have POSTed a LIVE order. Fix: callresolve_wallet_entry_mode(wallet_id, source_id)at the money gate. Lesson: the multi-wallet mode clamp MUST be enforced at PM’s authoritative re-resolve, not just at the consumer’s early skip. The early skip is an optimization; the money gate is the guarantee.
BOOT-CRASH BLOCKER — nonce panic crashes the whole fleet (fixed
c6bd9d8)
decrypt_private_keyusedNonce::from_slice, which PANICS on a non-12-byte nonce. The backfill migration seeds a 1-byte placeholderkey_nonce = E'\\x00'for wallet1. Booting withPM_WALLET_ENC_KEYset before the real-ciphertext UPDATE runs would panic and crash the ENTIRE fleet —arm_walletsisawaited directly in boot (not spawned in a JoinSet), so one bad wallet takes down the process. Fix: length-check the nonce → returnErr→ isolated per-wallet arm failure (PM runs unsigned/DRY until wallet1 is properly seeded). Lesson:GenericArray::from_slice(andNonce::from_slice) panic on length mismatch. Always length-check bytes before constructing fixed-size crypto arrays, especially from DB-sourced placeholders.
Legacy single-env fallback is fail-closed for ENTRIES
When the registry (
pmv2_wallets) is empty,pmv2_source_walletsis also empty, so every entry SkipAcks (“not routed”). In real rollout the backfill seeds both tables together, so the registry path is used. Exits still work in legacy mode (they carry their ownwallet_idsemantics), only entries go dark.
Exit fan-out is O(N×M) — future efficiency target
Every wallet consumer processes each exit signal and fetches the CLOB book per row before the
mark_sellingCAS; only the first wins (othersBenignRace). Correct, but wastes N−1 book fetches per row → extra CLOB rate-limit pressure. A candidate optimization is to gate the book fetch behind ownership/CAS first.
Deploy-Gated (NOT done — off-session when going live multi-wallet)
- Apply the 3 migrations to prod.
- Seed each wallet’s encrypted key via the
pm-encrypt-keybin (stdin key → BYTEA literals, run by deploy) into the seedUPDATE. - Bump
pmv2-contractsrev to add a realwalletwire field replacing thewallet:-prefix ops bridge.
Accept-as-Followup Cleanup
- Orphaned dead pub API:
resolve_entry_mode(mode.rs),load_source_caps/SourceCaps(ledger.rs) + dormant nullable source-cap columns onpmv2_autotrade_sources. is_mapped(tests-only)./statustop usage line +/walletsuses section headers — operator to pick prefix-vs-header presentation.
Related
- position_manager — service overview + fail-closed mode resolution (the single-signer baseline this extends)
- pmv2-polymarket-deposit-wallet-live-trading-solution-2026-06-26 — how one signer wallet trades live (maker/signer/funder, Poly1271); multi-wallet arms N of these
- pmv2-signer-prearm-proxy-owner-gotcha-2026-06-25 — signer pre-arm
owner()check per wallet - pmv2-fak-no-match-reject-single-line-2026-06-30 — FAK reject path that per-wallet execution reuses
- pmv2-source-id-3way-invariant-2026-06-23 — the source axis the wallet axis runs parallel to
- pmv2-data-api-positions-pnl-classification-2026-06-28 —
/walletsPnL surface that grows a per-wallet dimension