Skip to content

Multi-Wallet + Strategy-Config Migration Runbook (2026-05-03) ​

Step-by-step migration of a single-wallet .env deployment to the encrypted multi-wallet store (wallets.db, FE-1) plus the SQLite strategy-config store (config.db, FE-2 + FE-5). Authoritative for the X2.2..X2.4 layer shipped 2026-05-03.

Companion documents:

1. Purpose (LLM-oriented) ​

Deliver a deterministic migration path for an operator running pm-bot against a single hot wallet via env/.env.<preset> to the encrypted multi-wallet store + SQLite strategy-config store. The migration is fully reversible — the env-var entry point remains first-class and bit-identical to pre-X2 behaviour when BOT_WALLET_LABEL and BOT_STRATEGY_LABEL are unset (ADR-015 D1, D10). No fail-fast knob is added; no deprecation deadline is published; no schema migration on the trading hot path; the trading binary keeps the prior behaviour byte- for-byte until the operator opts into the new selectors.

2. Invariants ​

#InvariantSource
INV-1BOT_WALLET_LABEL and BOT_STRATEGY_LABEL are semantic-Optional. Unset ⇒ pm-bot reads WALLET_PRIVATE_KEY / POLY_* / per-strategy env block from the --env file.ADR-015 D10 + crates/pm-bot/src/main.rs:214,469
INV-2When BOT_WALLET_LABEL is set, WALLETS_MASTER_PASSPHRASE MUST be set and non-empty; missing ⇒ startup aborts with the same fail-fast pattern as validate_required_env_or_panic.crates/pm-bot/src/main.rs:112-116
INV-3When BOT_WALLET_LABEL AND BOT_STRATEGY_LABEL are both set, the strategy row's wallet_label MUST equal BOT_WALLET_LABEL. Mismatch ⇒ startup aborts.crates/pm-bot/src/main.rs:210-228
INV-4wallets.db is encrypted at-rest (Argon2id KDF + AES-256-GCM, per-DB salt). config.db is plaintext (audit-log-friendly, ops-repo-friendly). The two SQLite files are physically separated by security tier (D1).ADR-015 §D1
INV-5The FE-5 budget predicate is a passive synchronous check at provisioning time only (pm-cli strategy add/enable/update), NOT a runtime actor. Trading hot path observes the configured budget_limit_usd exactly as the env-block path observes BOT_TOTAL_BUDGET.ADR-015 §D6
INV-6The original .env file is never edited by any pm-cli subcommand. Migration prints a removal hint listing the keys the operator should strip; the operator's audit trail stays explicit.WalletCommand::Migrate / StrategyCommand::Migrate
INV-7Trading hot path is bit-identical to pre-X2 behaviour when both selector env vars are unset. 1071 pm-bot lib tests preserved across X2.1..X2.5; baseline 1014 → 1071 is purely additive (multi-wallet + config tests).Test suite + ADR-015 §D11

3. Pre-flight ​

Before starting the migration:

  1. Backup the existing .env:

    bash
    cp env/.env.bucket_arbitrage env/.env.bucket_arbitrage.pre-x2.bak
  2. Confirm the workspace builds clean:

    bash
    cargo check --workspace
    cargo test -p pm-bot --lib   # expect 1071 passed / 0 failed / 1 ignored
  3. Stop the live pm-bot (if running). The new SQLite files lock at write time — there must be no live writer when the migration runs:

    bash
    pkill pm-bot   # or systemd / supervisor stop
  4. Pick the test wallet for dry-run validation:

  5. Choose a master passphrase for wallets.db:

    • Recommended: 32+ bytes of operator-only entropy. The Argon2id KDF accepts any non-empty string; longer passphrases extend the attacker's brute-force budget.
    • Store the passphrase in your operator-grade secret manager (1Password / pass / sops-encrypted file). The pm-cli does NOT persist the passphrase to disk — losing it means the wallet blobs are unrecoverable from wallets.db alone (see § 7 "Recovery procedures").
    • Decide the delivery mechanism for pm-bot startup: shell env (export WALLETS_MASTER_PASSPHRASE=…) or a systemd-style EnvironmentFile=/etc/pm-bot/secrets.env with mode 0400.
  6. Decide the database paths:

    • Default ./wallets.db and ./config.db resolve relative to pm-bot's working directory.
    • Override via --db-path flag (per pm-cli subcommand) or BOT_WALLETS_DB_PATH / BOT_CONFIG_DB_PATH env vars.
    • Recommended deployment layout: /etc/pm-bot/wallets.db (mode 0600, owned by trading user) + /etc/pm-bot/config.db (mode 0644, ops-repo-friendly).

4. Step 1 — Wallet migration ​

4.1 Migrate the existing .env private key ​

bash
pm-cli wallet migrate \
    --label hot-1 \
    --from-env env/.env.bucket_arbitrage \
    --db-path /etc/pm-bot/wallets.db

The subcommand reads WALLET_PRIVATE_KEY, POLY_API_KEY, POLY_SECRET, POLY_PASSPHRASE, and (if present) POLY_FUNDER_ADDRESS from --from-env. It derives the wallet address from the private key, prompts twice for WALLETS_MASTER_PASSPHRASE (no echo), encrypts both blobs with AES-256-GCM, and INSERTs the row into wallets.db.

Stdout (success):

[migrate] read 5 keys from env/.env.bucket_arbitrage
[migrate] derived address 0xba561b30f68530485491547fb75baf6f03d0b7f4
[migrate] enter master passphrase: ****************
[migrate] confirm passphrase:       ****************
[migrate] inserted row label=hot-1 address=0xba56…d0b7f4
[migrate] reminder — strip the following keys from env/.env.bucket_arbitrage:
              WALLET_PRIVATE_KEY POLY_API_KEY POLY_SECRET POLY_PASSPHRASE
              (POLY_FUNDER_ADDRESS preserved as it is not stored separately)

The runbook does not edit env/.env.bucket_arbitrage. The operator removes the listed keys manually after step 4.3 confirms the wallet is loadable.

4.2 Verify the wallet appears in the store ​

bash
pm-cli wallet list --db-path /etc/pm-bot/wallets.db

Expected output (table — metadata only, no decrypt):

label   address                                       chain_id  clob_url                            created_at
------  --------------------------------------------  --------  ----------------------------------  -------------------
hot-1   0xba561b30f68530485491547fb75baf6f03d0b7f4    137       https://clob.polymarket.com         2026-05-03T11:02:14Z

If the row is missing, list returns the empty table; re-check the --db-path value (the most common mistake is operating on a fresh ./wallets.db in the cwd while the migration wrote to /etc/pm-bot/wallets.db).

4.3 Boot pm-bot once with the new wallet (dry-run) ​

bash
WALLETS_MASTER_PASSPHRASE='<the passphrase you typed in 4.1>' \
BOT_WALLETS_DB_PATH=/etc/pm-bot/wallets.db \
BOT_WALLET_LABEL=hot-1 \
  cargo run --release -p pm-bot -- --dry-run \
      --strategy bucket-arb \
      --env env/.env.bucket_arbitrage

Look for these markers in the early stdout:

[pm-bot] BOT_WALLET_LABEL=hot-1 — unlocking from wallets.db
[pm-bot] wallet hot-1 unlocked address=0xba56…d0b7f4
[pm-bot] strategy=bucket-arb (env-block path; BOT_STRATEGY_LABEL unset)

When this works the wallet portion of the migration is complete. Stop the dry-run (Ctrl-C) and proceed to step 2.

4.4 Optional — strip the legacy keys from .env ​

After 4.3 confirms the new path works, remove the four keys (WALLET_PRIVATE_KEY, POLY_API_KEY, POLY_SECRET, POLY_PASSPHRASE) from env/.env.bucket_arbitrage. Keep POLY_FUNDER_ADDRESS if your deployment uses a separate funder.

To audit the strip:

bash
diff env/.env.bucket_arbitrage env/.env.bucket_arbitrage.pre-x2.bak

Expected: 4 deleted lines, no insertions.

If you prefer to keep the .env whole as a rollback artefact, leave the keys in place — pm-bot ignores them when BOT_WALLET_LABEL is set (the wallets.db unwrap is the source of truth).

5. Step 2 — Strategy migration (per strategy) ​

Repeat this section once per strategy you intend to drive through the config.db path. Strategies that should keep using the env-block path need no migration — pm-bot resolves them via BOT_STRATEGY + the per-strategy env block exactly as before.

5.1 Migrate per-strategy env block to config.db ​

bash
pm-cli strategy migrate \
    --label bucket-prod \
    --type bucket-arb \
    --from-env env/.env.bucket_arbitrage \
    --wallet hot-1 \
    --budget 50.00 \
    --db-path /etc/pm-bot/config.db

The subcommand picks every BUCKET_ARB_* line (or the matching <PREFIX>_* block per --type: DIRECTIONAL_, MAKER_, MAKER_REBATE_, MERGE_ARB_) and stores them as a flat JSON object inside the definition column of a new row. The five known prefixes are wired in crates/pm-cli/src/main.rs:506-523.

Stdout (success):

[migrate] picked 17 keys with prefix BUCKET_ARB_ from env/.env.bucket_arbitrage
[migrate] inserted strategy id=1 label=bucket-prod type=bucket-arb wallet=hot-1 budget=50.00 enabled=false
[migrate] audit row id=1 op=migrate
[migrate] reminder — strip the following lines from env/.env.bucket_arbitrage when ready:
              BUCKET_ARB_*
              BOT_STRATEGY=bucket-arb (when ready to switch to BOT_STRATEGY_LABEL=bucket-prod)

5.2 Enable the row + run the FE-5 predicate ​

The migration inserts the row with enabled = 0 so the operator has a chance to inspect the JSON and run the budget predicate before the strategy starts trading.

bash
pm-cli strategy enable \
    --label bucket-prod \
    --wallet-balance 250.00 \
    --db-path /etc/pm-bot/config.db

--wallet-balance is the operator-supplied USD balance for the wallet (query via pm-cli → on-chain pUSD.balanceOf or read from a recent bot_sessions.wallet_balance_usd_at_open row). When supplied, the FE-5 predicate runs:

budget_limit_usd <= wallet_balance + Σ(other enabled strategies' limits on wallet_label)

Failure path:

[enable] FE-5 predicate FAILED:
   strategy bucket-prod budget_limit_usd=50.00
   wallet hot-1 balance=10.00
   peer budgets on hot-1=0.00
   net headroom=-40.00
   refusing to enable — raise --wallet-balance or lower budget via `pm-cli strategy update`

Pass path:

[enable] FE-5 predicate PASSED (headroom=200.00)
[enable] strategy bucket-prod enabled=1
[enable] audit row id=2 op=enable

When --wallet-balance is omitted, the predicate is skipped with a single WARNING line; the row enables anyway. This is intentional for operators who validate wallet balances out-of-band (e.g. a pre-deploy spreadsheet). The WARNING keeps the audit trail honest.

5.3 Verify the row ​

bash
pm-cli strategy list --db-path /etc/pm-bot/config.db

Expected:

id  label         type        wallet  budget  enabled  updated_at
--  ------------  ----------  ------  ------  -------  -------------------
1   bucket-prod   bucket-arb  hot-1   50.00   1        2026-05-03T11:14:02Z

5.4 Repeat for additional strategies ​

A typical multi-strategy operator layout:

bash
# directional (harvester preset)
pm-cli strategy migrate --label harvester-prod --type directional \
    --from-env env/.env.harvester --wallet hot-1 --budget 30 \
    --db-path /etc/pm-bot/config.db
pm-cli strategy enable --label harvester-prod --wallet-balance 250.00 \
    --db-path /etc/pm-bot/config.db

# maker-rebate (paired emission off by default — TD-7 X5.x)
pm-cli strategy migrate --label rebate-prod --type maker-rebate \
    --from-env env/.env.maker_rebate --wallet hot-1 --budget 80 \
    --db-path /etc/pm-bot/config.db
pm-cli strategy enable --label rebate-prod --wallet-balance 250.00 \
    --db-path /etc/pm-bot/config.db

The FE-5 predicate runs against the rolling sum of enabled rows on the same wallet. Adding harvester-prod (30) and rebate-prod (80) to a wallet with bucket-prod (50) requires a wallet balance of at least 160.00 before the third enable succeeds (50 + 30 + 80 = 160).

6. Step 3 — Startup verification ​

6.1 Boot pm-bot through both selectors ​

bash
WALLETS_MASTER_PASSPHRASE='<passphrase>' \
BOT_WALLETS_DB_PATH=/etc/pm-bot/wallets.db \
BOT_CONFIG_DB_PATH=/etc/pm-bot/config.db \
BOT_WALLET_LABEL=hot-1 \
BOT_STRATEGY_LABEL=bucket-prod \
  cargo run --release -p pm-bot -- --env env/.env.bucket_arbitrage

Note: --env is still required when ANY env var (e.g. RTDS_*, BOT_LOCAL_API_*, BOT_BUDGET_PER_MARKET) lives in the .env file. The strategy block is consumed from config.db; everything else remains in .env. This is intentional per ADR-015 D10 — the multi- wallet layer migrates secrets and strategy bag, not cross-cutting operator knobs.

Expected boot markers:

[pm-bot] BOT_WALLET_LABEL=hot-1 — unlocking from wallets.db
[pm-bot] wallet hot-1 unlocked address=0xba56…d0b7f4
[pm-bot] BOT_STRATEGY_LABEL=bucket-prod — loading from config.db
[pm-bot] strategy bucket-prod type=bucket-arb wallet=hot-1 budget=50.00 enabled=true
[pm-bot] cross-check: row.wallet_label='hot-1' matches BOT_WALLET_LABEL='hot-1' — OK
[pm-bot] action_executor: started

6.2 INV-3 cross-check failure path ​

If the strategy row's wallet_label does not match BOT_WALLET_LABEL, pm-bot aborts:

ERROR — strategy bucket-prod has wallet_label='hot-2' but
        BOT_WALLET_LABEL='hot-1' — refusing to start to avoid
        signing with the wrong key

This is the INV-3 enforcement at crates/pm-bot/src/main.rs:210-228. The fix is one of:

  • Update the strategy row to point to the correct wallet: pm-cli strategy update --label bucket-prod --field wallet_label --value hot-1.
  • Set BOT_WALLET_LABEL to match the row.
  • Unset BOT_WALLET_LABEL — pm-bot then refuses with a different error: "BOT_WALLET_LABEL is unset — refusing to start (would fall back to env-path while strategy row pins a different wallet)". This is intentional belt-and-braces.

6.3 Smoke validation (mandatory before declaring ready) ​

Run the operator-grade smoke procedure in multi-wallet-smoke-runbook-2026-05-03.md. The smoke covers 21 probes (CLI + WebUI + pm-bot startup + trading hot-path bit-identity). Do NOT declare the migration complete until every probe is [OK].

7. WebUI alternative (X2.4) ​

For operators who prefer a graphical flow, the WebUI configurator shipped in X2.4 (commit fe880bc) covers the same surface as steps 4–5 above. The CLI and WebUI write to the same SQLite files; either path is authoritative.

7.1 Boot the dashboard stack ​

bash
# Terminal 1 — pm-bot (no LIVE_API needed for configurator alone, but
# enabling it gives the SPA the live feed for /strategies sanity check)
BOT_LOCAL_API_ENABLED=true \
BOT_LOCAL_API_BIND=127.0.0.1:31339 \
BOT_LOCAL_API_TOKEN=$(openssl rand -hex 16) \
  cargo run --release -p pm-bot -- --dry-run \
      --strategy bucket-arb --env env/.env.bucket_arbitrage

# Terminal 2 — pm-dashboard backend (REST + SSE proxy)
DASHBOARD_BIND_ADDR=127.0.0.1:31337 \
BOT_WALLETS_DB_PATH=/etc/pm-bot/wallets.db \
BOT_CONFIG_DB_PATH=/etc/pm-bot/config.db \
  cargo run --release -p pm-dashboard

# Terminal 3 — Vue 3 SPA (dev — Vite hot reload)
cd frontend/pm-dashboard-web && pnpm dev
# production: pnpm build && serve dist/ via Caddy per ADR-012

Open http://localhost:5173/ (dev) or the Caddy-proxied production URL.

7.2 Add a wallet via WebUI ​

  1. Click Wallets in the top-bar (route /wallets).
  2. Click Add wallet.
  3. Fill the form:
    • Label — hot-1.
    • Private key — paste hex (0x…-prefixed or bare).
    • Master passphrase — paste from the operator's secret manager.
    • Chain ID — 137 (default).
    • CLOB URL — https://clob.polymarket.com (default).
  4. Submit. The dashboard backend forwards the payload to POST /api/wallets; the server-side handler runs the same Argon2id
    • AES-256-GCM encryption as the CLI before writing to wallets.db.
  5. The form clears and the new row appears in the list.

The master passphrase is held in the SPA only for the duration of the form submission — the response does NOT echo it back, and the Pinia store does not cache it. Subsequent sessions prompt again.

7.3 Add a strategy via WebUI ​

  1. Click Strategies in the top-bar (route /strategies).
  2. Click Add strategy.
  3. Pick a strategy type from the dropdown — the form below renders the per-type structured fields (5 strategy types ⇒ 5 distinct schemas, see frontend/pm-dashboard-web/src/views/StrategyAddView.vue).
  4. Fill the structured form (e.g. for bucket-arb: BUCKET_ARB_MIN_PROFIT_PCT, BUCKET_ARB_DRIFT_CANCEL_MARGIN, …).
  5. Set wallet label (dropdown — populated from /api/wallets).
  6. Set budget limit USD — the FE-5 predicate runs server-side.
  7. Submit. On predicate failure the form surfaces a 422 response with a structured error body ({code:"fe5_predicate_failed", headroom:-40.00, peer_budgets:…}) and the form remains editable. On success the row enters enabled = false; the operator clicks the row's Enable action to flip it on (and re-run the predicate).

7.4 Rotate master passphrase via WebUI ​

The Wallets view exposes a "Rotate master" action gated behind a paranoid double-confirm (operator must type the literal label string twice). The handler at POST /api/wallets/rotate-master runs the same re-encrypt-everything-in-one-transaction logic as the CLI (pm-cli wallet rotate-master).

8. Recovery procedures ​

ScenarioRecovery
Lost master passphraseThe encrypted blobs in wallets.db are unrecoverable. Restore from the .env backup (step 3.1), re-import via pm-cli wallet add --label hot-1 with a NEW master passphrase. Update the systemd / supervisor secret store. The on-chain wallet itself is untouched — only the encrypted SQLite copy is gone.
Corrupted wallets.db (file truncated, journal-replay failure, sqlite header mismatch)First, copy the file aside for forensics: cp wallets.db wallets.db.corrupt-2026-05-03. Restore from the most recent backup if you keep one (recommended cadence: daily). Otherwise re-create from .env: re-run pm-cli wallet migrate --from-env <backup> for each wallet. The audit history of WHEN keys were added is lost; the keys themselves are recoverable.
Corrupted config.dbSame playbook. config.db is plaintext + ops-repo-friendly, so most operators will track it in a private git repo (gitignored from the public workspace). Restore from git history OR re-run pm-cli strategy migrate --from-env <backup> per strategy.
BOT_WALLET_LABEL does not match strategy row's wallet_labelINV-3 abort. Either update the row (pm-cli strategy update --label X --field wallet_label --value Y --wallet-balance Z) or change the env var. Re-run pm-cli strategy list to confirm.
WALLETS_MASTER_PASSPHRASE rejected as wrongThe Argon2id KDF derives a key that fails AES-GCM authentication. Likely causes: typo, wrong env file sourced, recent pm-cli wallet rotate-master not yet propagated to the operator's secret store. Use pm-cli wallet export --label <X> interactively to validate the passphrase against a single row before retrying pm-bot startup.
FE-5 predicate keeps failing on enableRe-check --wallet-balance value (live on-chain pUSD vs USDC.e — the predicate budgets are USD denominated; pUSD = USD 1:1). Reduce a peer strategy's budget_limit_usd via pm-cli strategy update --label <peer> --field budget_limit_usd --value <new>. Predicate re-runs on every enable / update / add.
WebUI configurator shows blank wallet dropdownThe GET /api/wallets request 503'd because BOT_WALLETS_DB_PATH was not set on pm-dashboard or the file is unreadable. Check the dashboard log for the bind-time line; verify file permissions (mode 0600 owned by the dashboard process user).
Want to roll back entirelySee § 9.

9. Rollback ​

The migration is reversible at any step.

  1. Stop pm-bot:

    bash
    pkill pm-bot
  2. Unset the selector env vars:

    bash
    unset BOT_WALLET_LABEL
    unset BOT_STRATEGY_LABEL
    unset WALLETS_MASTER_PASSPHRASE   # optional but tidy
  3. Restore the original .env (if you stripped keys per step 4.4):

    bash
    cp env/.env.bucket_arbitrage.pre-x2.bak env/.env.bucket_arbitrage
  4. Boot pm-bot the legacy way:

    bash
    cargo run --release -p pm-bot -- --env env/.env.bucket_arbitrage

The trading hot path is byte-for-byte identical to pre-X2 — confirmed by the 1014 → 1071 baseline (additive only, no behavioural change in the env path). The wallets.db and config.db files remain on disk but are not consulted; you can rm them at leisure or keep them for a later re-attempt.

10. Operational checklist (post-migration) ​

After the migration is complete, the operator should:

  • [ ] Confirm pm-cli wallet list shows every expected wallet.
  • [ ] Confirm pm-cli strategy list shows every expected strategy with the correct wallet, budget, and enabled flag.
  • [ ] Confirm BOT_WALLETS_DB_PATH and BOT_CONFIG_DB_PATH are set in the systemd / supervisor unit file (or the operator's wrapper shell script).
  • [ ] Confirm WALLETS_MASTER_PASSPHRASE is sourced from the secret store, NOT hard-coded in the unit file or shell history.
  • [ ] Confirm file permissions: wallets.db mode 0600, config.db mode 0644 (or stricter), both owned by the trading-user.
  • [ ] Run the smoke procedure multi-wallet-smoke-runbook-2026-05-03.md to validate the full stack.
  • [ ] Schedule the first nightly backup of wallets.db (e.g. cron-based copy + age-encrypt + S3 upload).
  • [ ] Update the operator's runbook bookmark list to include this migration runbook + the smoke runbook.
  • [ ] Document the rollback drill in the team's incident-response wiki (operators should rehearse §9 once before relying on the new path in production).

11. Cross-references ​