Skip to content

Commit 4807eaa

Browse files
committed
🛠️ feat(wallet): add v2 migration backfill tooling
Add an idempotent wallet_crypto_v2 migration script with dry-run/batch/user targeting, expose it via just recipe, and document operational rollout/verification in runbooks.
1 parent 3faccdc commit 4807eaa

File tree

5 files changed

+256
-1
lines changed

5 files changed

+256
-1
lines changed
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Backfill wallet_crypto_v2 from legacy wallet fields.
2+
3+
Version A migration strategy:
4+
- keep legacy fields untouched (rollback-safe)
5+
- fill wallet_crypto_v2 where migration is possible
6+
- skip wallets that require user PIN/password (use_pin in 1,2)
7+
8+
Usage examples:
9+
python scripts/migrate_wallet_crypto_v2.py --dry-run
10+
python scripts/migrate_wallet_crypto_v2.py --batch-size 200
11+
python scripts/migrate_wallet_crypto_v2.py --user-id 123456
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import asyncio
18+
import os
19+
import sys
20+
from dataclasses import dataclass
21+
22+
from loguru import logger
23+
from sqlalchemy import select
24+
25+
# Add bot package root for direct script execution.
26+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27+
28+
from db.db_pool import db_pool
29+
from db.models import MyMtlWalletBot
30+
from infrastructure.services.encryption_service import EncryptionService
31+
32+
33+
@dataclass
34+
class Counters:
35+
scanned: int = 0
36+
migrated: int = 0
37+
skipped_has_v2: int = 0
38+
skipped_missing_secret: int = 0
39+
skipped_requires_user_pin: int = 0
40+
skipped_decrypt_failed: int = 0
41+
failed: int = 0
42+
43+
44+
def _wallet_kind(is_free: bool, is_ton: bool) -> str:
45+
if is_ton:
46+
return "ton_free"
47+
return "stellar_free" if is_free else "stellar_user"
48+
49+
50+
def _mode(use_pin: int) -> str:
51+
# In current product model, no-password flow is treated as free/server mode.
52+
return "free" if use_pin == 0 else "user"
53+
54+
55+
async def migrate_wallets(
56+
*,
57+
dry_run: bool,
58+
batch_size: int,
59+
user_id: int | None,
60+
) -> Counters:
61+
counters = Counters()
62+
encryption = EncryptionService()
63+
64+
async with db_pool.get_session() as session:
65+
stmt = select(MyMtlWalletBot).where(MyMtlWalletBot.need_delete == 0)
66+
if user_id is not None:
67+
stmt = stmt.where(MyMtlWalletBot.user_id == user_id)
68+
69+
result = await session.execute(stmt)
70+
wallets = result.scalars().all()
71+
72+
for wallet in wallets:
73+
counters.scanned += 1
74+
75+
if wallet.wallet_crypto_v2:
76+
counters.skipped_has_v2 += 1
77+
continue
78+
79+
if not wallet.secret_key:
80+
counters.skipped_missing_secret += 1
81+
continue
82+
83+
try:
84+
is_ton = wallet.secret_key == "TON"
85+
kind = _wallet_kind(is_free=bool(wallet.free_wallet), is_ton=is_ton)
86+
mode = _mode(int(wallet.use_pin or 0))
87+
88+
secret_plain: str | None = None
89+
seed_plain: str | None = None
90+
91+
if is_ton:
92+
secret_plain = "TON"
93+
seed_plain = wallet.seed_key
94+
elif mode == "free":
95+
secret_plain = encryption.decrypt(
96+
wallet.secret_key,
97+
str(wallet.user_id),
98+
)
99+
if secret_plain and wallet.seed_key:
100+
seed_plain = encryption.decrypt(wallet.seed_key, secret_plain)
101+
else:
102+
counters.skipped_requires_user_pin += 1
103+
continue
104+
105+
if not secret_plain:
106+
counters.skipped_decrypt_failed += 1
107+
continue
108+
109+
wallet.wallet_crypto_v2 = encryption.encrypt_wallet_container(
110+
secret_key=secret_plain,
111+
seed_key=seed_plain,
112+
mode=mode,
113+
wallet_kind=kind,
114+
pin=None,
115+
)
116+
counters.migrated += 1
117+
118+
if not dry_run and counters.migrated % batch_size == 0:
119+
await session.commit()
120+
logger.info(
121+
"Committed batch: migrated={} scanned={}",
122+
counters.migrated,
123+
counters.scanned,
124+
)
125+
126+
except Exception as exc: # pragma: no cover - defensive reporting path
127+
counters.failed += 1
128+
logger.exception(
129+
"Failed to migrate wallet id={} user_id={} error={}",
130+
wallet.id,
131+
wallet.user_id,
132+
exc,
133+
)
134+
135+
if dry_run:
136+
await session.rollback()
137+
else:
138+
await session.commit()
139+
140+
return counters
141+
142+
143+
def parse_args() -> argparse.Namespace:
144+
parser = argparse.ArgumentParser(description="Migrate legacy wallet secrets to v2")
145+
parser.add_argument(
146+
"--dry-run",
147+
action="store_true",
148+
help="Do not persist updates; only print migration counters",
149+
)
150+
parser.add_argument(
151+
"--batch-size",
152+
type=int,
153+
default=100,
154+
help="Commit frequency for non-dry-run mode",
155+
)
156+
parser.add_argument(
157+
"--user-id",
158+
type=int,
159+
default=None,
160+
help="Migrate only a specific user_id",
161+
)
162+
return parser.parse_args()
163+
164+
165+
async def main() -> int:
166+
args = parse_args()
167+
counters = await migrate_wallets(
168+
dry_run=args.dry_run,
169+
batch_size=max(args.batch_size, 1),
170+
user_id=args.user_id,
171+
)
172+
logger.info(
173+
(
174+
"Migration result: scanned={} migrated={} skipped_has_v2={} "
175+
"skipped_missing_secret={} skipped_requires_user_pin={} "
176+
"skipped_decrypt_failed={} failed={} dry_run={}"
177+
),
178+
counters.scanned,
179+
counters.migrated,
180+
counters.skipped_has_v2,
181+
counters.skipped_missing_secret,
182+
counters.skipped_requires_user_pin,
183+
counters.skipped_decrypt_failed,
184+
counters.failed,
185+
args.dry_run,
186+
)
187+
return 0 if counters.failed == 0 else 1
188+
189+
190+
if __name__ == "__main__":
191+
raise SystemExit(asyncio.run(main()))

docs/exec-plans/active/2026-03-01-wallet-crypto-v2-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Operational rollout without feature flags:
102102
- legacy fallback.
103103
- Verify `routers/ton.py` user flows are unchanged.
104104

105-
7. [ ] Add migration script (safe and idempotent).
105+
7. [x] Add migration script (safe and idempotent).
106106
- Create script in `bot/scripts/` to backfill `wallet_crypto_v2`.
107107
- Script behavior:
108108
- scan wallets with empty v2,

docs/runbooks/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ Operational checklists for common incidents and CI/test failures.
55
- `ci-failure-triage.md`: default first-response flow.
66
- `e2e-pipeline.md`: PR smoke + external + nightly canary strategy.
77
- `task-intake-and-execution.md`: start every non-trivial task in AI-first mode.
8+
- `wallet-crypto-v2-migration.md`: operational steps for v2 backfill.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Wallet Crypto V2 Migration
2+
3+
## Goal
4+
5+
Backfill `wallet_crypto_v2` while keeping legacy `secret_key`/`seed_key` intact
6+
for rollback-safe Version A rollout.
7+
8+
## Preconditions
9+
10+
- Version A image is deployed (dual-read + dual-write enabled in code).
11+
- `WALLET_KEK` is configured in runtime secrets.
12+
- Optional: `WALLET_KEK_OLD` if key rotation fallback is needed.
13+
14+
## Commands
15+
16+
Dry-run:
17+
18+
```bash
19+
just migrate-wallet-crypto-v2
20+
```
21+
22+
Real migration:
23+
24+
```bash
25+
just migrate-wallet-crypto-v2 args="--batch-size 200"
26+
```
27+
28+
Single user verification:
29+
30+
```bash
31+
just migrate-wallet-crypto-v2 args="--dry-run --user-id 123456"
32+
```
33+
34+
## Expected Counters
35+
36+
Script prints:
37+
38+
- `scanned`
39+
- `migrated`
40+
- `skipped_has_v2`
41+
- `skipped_missing_secret`
42+
- `skipped_requires_user_pin`
43+
- `skipped_decrypt_failed`
44+
- `failed`
45+
46+
Notes:
47+
48+
- `skipped_requires_user_pin` is expected for non-free wallets with PIN/password.
49+
- Those records migrate lazily when user successfully authenticates.
50+
51+
## Rollback Safety
52+
53+
Version A keeps writing legacy fields and does not delete them.
54+
Old image can continue to read legacy fields during rollback window.
55+
56+
## Exit Criteria Before Version B
57+
58+
- No migration failures (`failed=0`).
59+
- `wallet_crypto_v2` coverage is acceptable for active wallets.
60+
- CI gates green (`just check-fast`, smoke/external suites).

justfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ metrics:
7272
bench-kdf:
7373
uv run --with argon2-cffi python bot/scripts/argon2_benchmark.py
7474

75+
migrate-wallet-crypto-v2 args="--dry-run":
76+
cd bot && uv run --package mmwb-bot python scripts/migrate_wallet_crypto_v2.py {{args}}
77+
7578
start-task task_id title="":
7679
uv run python .linters/create_exec_plan.py {{task_id}} --title "{{title}}"
7780

0 commit comments

Comments
 (0)