Skip to content

fix(cutover): auto-create the missing USDT wallet instead of killing the address - #493

Open
islandbitcoin wants to merge 4 commits into
mainfrom
fix/autocreate-usdt-wallet
Open

fix(cutover): auto-create the missing USDT wallet instead of killing the address#493
islandbitcoin wants to merge 4 commits into
mainfrom
fix/autocreate-usdt-wallet

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Fixes ENG-544.

The bug

Accounts predating the cutover that never got a USDT wallet hit CashWalletMissingUsdtWalletError on every usdt-presentation path (presentation.ts:31) — including accountDefaultWallet, 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

  • Unauthenticated write path, non-idempotent creator. Anyone can resolve a lightning address, and persistNew mints 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.
  • A lock failure is a creation failure, never permission to create unlocked — unlocked creation is exactly the double-mint the lock exists to prevent. Next resolution retries.
  • Creation failure degrades to the original error. The account is no better off, never worse — no silent routing into a wallet Jabari rejected as a target.
  • The steady state stays a pure read. With the wallet present, the ensure path is never invoked — pinned so the self-heal can't quietly become a write on every resolution.

Surface

  • src/app/cash-wallet-cutover/ensure-usdt-wallet.ts — new, the locked idempotent creator
  • src/app/cash-wallet-cutover/presentation-for-account.ts — the hook, error-triggered only
  • src/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.

…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
@linear

linearBot commented Aug 24, 2026

Copy link
Copy Markdown

ENG-544

bobodread876and others added 3 commits August 24, 2026 08:32
- 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

Copy link
Copy Markdown
Collaborator

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 — discovery.ts flags an account without one as missing_destination_usdt and skips it. So the affected fleet is exactly "accounts whose migration was blocked by the missing wallet," stranded when the cutover completed globally.

The hole the analysis found: the usdt presentation hides the legacy USD wallet (its balance resolver redirects to the settlement wallet — a design that assumes migration moved funds first). Healing an account that still holds a legacy balance would flip its app to an empty USDT view with the money out of sight — worse than the dead address this fixes. And the review's defaultWalletId flip made that flip stick harder.

The gate (56df80e): under usdt, heal only when the legacy wallet is absent or reads zero (IBEX 404/absent-balance already map to zero — the drained/dormant fleet). Nonzero or unreadable → keep the original error, warn span (skipped_nonzero_legacy_balance / skipped_balance_unreadable), account stays on the operator queue that discovery already maintains. legacy_usd_compat heals unconditionally — it keeps the legacy wallet visible and default, so nothing hides.

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@islandbitcoin@bobodread876