Uh oh!
There was an error while loading. Please reload this page.
fix(cutover): auto-create the missing USDT wallet instead of killing the address - #493
fix(cutover): auto-create the missing USDT wallet instead of killing the address#493islandbitcoin wants to merge 4 commits into
Conversation
…the address Fixes ENG-544. Accounts predating the cutover that never got a USDT wallet hit CashWalletMissingUsdtWalletError on every usdt-presentation path — including accountDefaultWallet, which is what lightning-address resolution calls. Those accounts simply could not be paid. Decision (Jabari): converge the account to the cutover target on first touch rather than route funds into a wallet being retired. The self-heal sits in resolveCashWalletPresentationForAccount, so every caller heals — consumer-account wallets, usd-wallet, cash-wallet-history — not just the address path, and it covers both presentations that demand the wallet (usdt AND legacy_usd_compat). The cares, each pinned by a test: - This runs on an effectively UNAUTHENTICATED path (anyone can resolve a lightning address), and persistNew is NOT idempotent — every call mints a fresh IBEX account. Creation runs under a new account-scoped redlock (LockService.lockAccountId), and the loser of a concurrent race re-lists inside the lock and returns the winner's wallet. - A lock failure is creation failure, never permission to create unlocked — unlocked creation is exactly the double-mint the lock exists to prevent. - Creation failure degrades to the original missing-wallet error: the account is no better off, never worse, and the next resolution retries. - The steady state stays a pure read: with the wallet present, the ensure path is never invoked. 207 suites / 2230 tests green; tsc and eslint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
- Converge the stored default-wallet pointer during the heal, mirroring the migration path (addWalletIfNonexistent + updateDefaultWalletId): flip it to the created USDT wallet when it points at the retired legacy USD wallet, so balance notifications, the operator dashboard, and discovery stop seeing the retired wallet as the default. A deliberate non-cash default stays put; a failed flip degrades to a warn span without undoing the heal. - Pass the resolver's injected walletsRepo through to ensureUsdtWallet so heal reads/creates go through the same repo as the resolver's reads (persistNew optional, falling back to the real repo). - Correct EnsureWalletsRepository.persistNew's declared return type to ApplicationError, matching IWalletsRepository. - Pin WalletType.Checking exactly in the creation assertion instead of expect.anything(). - Add a legacy_usd_compat self-heal test (old client, cutover complete) plus pointer-flip and injected-repo routing tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Design-analysis finding on #493, and it is the load-bearing safety of the heal. The usdt presentation HIDES the legacy USD wallet: its balance resolver redirects legacy-wallet queries to the settlement wallet (usd-wallet.ts) — a design that assumes migration MOVED the funds first. The affected accounts are unmigrated (the missing wallet is exactly what blocked their migration: discovery status missing_destination_usdt), so healing one that still holds a legacy balance would flip its app to an empty USDT view with the money out of sight. Funds intact on the ledger, invisible to the customer — worse than the dead address this fixes. The gate, each branch pinned by a test: - usdt presentation + legacy wallet present: heal only when the balance reads ZERO. Nonzero -> keep the original error, warn span with a searchable attribute; the account stays on the operator queue (discovery still flags it) and its money moves via a migration run, never a lazy write on the resolution path. - balance unreadable -> also skip; creating blind is the flip the gate exists to prevent. Next resolution retries. - no legacy wallet at all -> heal without consulting the reader. - legacy_usd_compat -> heals unconditionally; that presentation keeps the legacy wallet visible AND default, so a balance hides nothing. The reader is injected and its default LAZY-imports the real IBEX-backed getBalanceForWallet (whose 404/absent-balance cases already map to ZERO — drained and never-funded accounts, the fleet this heal exists for). A static import would construct the IBEX client and its Redis connection as a module side effect; the first spec run proved it by hanging. 207 suites / 2240 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
bobodread876
commented
Aug 24, 2026
Design analysis follow-up (requested by Jabari): is auto-create the best solution? — Yes, with one safety the PR was missing, now added in 56df80e. Why these accounts exist: the cutover migration never created wallets — The hole the analysis found: the The gate (56df80e): under The operator half (not in this PR): count prod accounts lacking USDT wallets, split by legacy balance; the nonzero ones need a migration run, which this PR unblocks by letting their wallets be created as part of that run rather than lazily. 207 suites / 2240 tests green. |
Fixes ENG-544.
The bug
Accounts predating the cutover that never got a USDT wallet hit
CashWalletMissingUsdtWalletErroron everyusdt-presentation path (presentation.ts:31) — includingaccountDefaultWallet, which is what lightning-address resolution calls. Those accounts could not be paid at all.The fix (design decided with Jabari: auto-create, not fallback)
Converge the account to the cutover target on first touch instead of routing funds into a wallet being retired. The self-heal sits in
resolveCashWalletPresentationForAccount, so every caller heals — consumer-account wallets, usd-wallet, cash-wallet-history — not just the address path, and it covers both presentations that demand the wallet (usdtandlegacy_usd_compat).The cares, each pinned by a test
persistNewmints a fresh IBEX account every call. Creation runs under a new account-scoped redlock (LockService.lockAccountId); the loser of a concurrent race re-lists inside the lock and returns the winner's wallet.Surface
src/app/cash-wallet-cutover/ensure-usdt-wallet.ts— new, the locked idempotent creatorsrc/app/cash-wallet-cutover/presentation-for-account.ts— the hook, error-triggered onlysrc/services/lock+ types —lockAccountId(releasing redlock, own namespace)Blast radius
Every currently-dead lightning address starts working on its next resolution, creating one USDT wallet per affected account (IBEX account + lnurlp + mongo record — the same path account creation uses). Worth running a count of affected accounts after deploy to watch convergence.
207 suites / 2230 tests green; tsc + eslint clean.