Skip to content

feat(wallet-storage): restore complete Core wallet snapshots from SQLite - #5210

Draft
lklimek wants to merge 17 commits into
fix/sqlite-asset-lock-reconciliationfrom
feat/sqlite-wallet-restore
Draft

lklimek wants to merge 17 commits into
fix/sqlite-asset-lock-reconciliationfrom
feat/sqlite-wallet-restore

Conversation

@lklimek

@lklimek lklimek commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

TL;DR

Reopening a SQLite-backed wallet restores its Core address reservations and used-key positions, so previously handed-out funding keys are not offered again.

User story

As a wallet user, I can restart the app with my saved Core balances, pending spends and address reservations intact, and receive an error when required saved data cannot be restored.

Scenario

Use funding addresses or reserve a new address, close the wallet, and reopen the same database. The wallet restores the used and reserved addresses before allocating another key. Concurrent database writes cannot mix different snapshots within one load.

Detailed discussion

Dependencies and overlap

Stacked on #5207 (fix/sqlite-asset-lock-reconciliation). Also incorporates #5208 at 338067889e4c01651eb1742d5ba83e09955c1834 and its #5150 prerequisites. The new implementation commit changes four files. Their history replay, pending-spend reservations, accounting repair and migrations are reused unchanged. Until those prerequisites land in the base stack, GitHub's cumulative diff includes their commits; this PR's new implementation is limited to SQLite pool restoration, coherent loading and focused tests.

What was done?

  • Restore persisted address-pool entries across managed Core account roles, including special funding keys and provider key pools. Preserve used state, reservation timestamps, derivation positions and reverse lookups.
  • Preserve existing per-address accounting and reject conflicting typed keys, addresses or derivation indices.
  • Load wallet state inside one SQLite read transaction and preserve typed storage errors through reconstruction.
  • Declare the existing WALLET_RESTORE capability after completing its Core-snapshot restore path. Capability meanings and bit values remain unchanged. PROVIDER_TRANSACTIONS is not newly declared.
  • Keep the existing watch-only restore model; no secret import, new schema migration or automatic transaction resend is introduced.

WALLET_RESTORE is the existing persisted Core wallet snapshot contract. Separate token caches, DashPay profile overlays, deferred contact queues and invitation UI records are not made complete by this flag; their readback remains separate. DET's #1036 pin remains on the narrower #5207.

How Has This Been Tested?

  • 83 unique targeted tests passed across wallet restore, persistence roundtrip, provider keys, spent-output replay, pool readers and recovery mode.
  • Six new tests exercise reopen and real manager hydration, special funding-key nonreuse, reservation timestamps, exact pending-spend/address accounting, malformed-key rejection before partial manager load, a concurrent SQLite writer, and ProReg/ProUpReg key-account history/positions.
  • The reservation, coherent-snapshot and missing-capability regressions failed before implementation.
  • cargo fmt -p platform-wallet-storage -- --check, scoped all-target/all-feature Clippy with --no-deps -D warnings, and diff checks passed.
  • No live-network or user-funds testing. Full workspace CI was not reproduced locally; inherited Swift changes from fix(platform-wallet-storage)!: harden wallet history restore after #5150 review #5208 were not compiled on this Linux host.

Breaking Changes

None. No FFI callback, layout or capability-contract change.

Checklist

  • Self-review performed
  • Existing PRs checked and overlapping work reused
  • Reopen and real manager-hydration regression coverage
  • Validation limitations documented

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 15 commits September 29, 2026 16:31
V019 and the per-round history repair decoded stored records strictly, so
one undecodable record_blob aborted the migration (blocking every open,
Recovery included) and made each later write of that txid fail.

History repair is best-effort accounting: unreadable stored data is now
logged and skipped. Each record is repaired inside its own savepoint so a
skipped one leaves no partial writes, the corrupt row is left untouched,
and a later write of the same transaction replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
V019 and the per-round history repair rewrote stored transaction records
in place and marked owned inputs spent without saying which transaction
spent them, so a wrong repair could not be undone after the one-off
pre-migration backup.

Before its first rewrite, a record's original blob is now copied verbatim
into the append-only core_transaction_record_originals table (V019 is
unreleased and edited in place), and a repair's spent mark records its
spender in spent_in_txid. Automatic release after a reorg is left as a
TODO pending verification of upstream reorg handling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
V019 called the live per-round history repair, so any later edit to that
code (or to the queried tables) would silently change what V019 does for
users upgrading from V018 or earlier.

Move V019's repair into the migrations-local legacy_v019 module, following
the legacy_v008 precedent, with its own record reader. Only the low-level
blob codec stays shared; TransactionRecord's encoding is owned upstream. A
pinning test fixes V019's result on a V018-shaped database: the repaired
record bytes, the preserved original, the input index and the spent marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on load

Load replayed only block-confirmed records, so a mempool or InstantSend
spend never reached the account's spent set. A redelivered funding
transaction (rescan, reorg re-connect, or the funding confirming after a
restart) then re-credited the output that spend reserves, and the round
wrote it back to SQLite as unspent.

Replay unconfirmed records after the confirmed ones, parents before
children, so the checker skips re-crediting reserved outputs. The
persisted-unspent filter still keeps their own outputs from being credited
unless persistence holds them. The regression tests now redeliver funding
after reload, for confirmed and still-unconfirmed funding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…idge

The wallet checker is async only by trait shape and never awaits, yet load
drove it through dash_async::block_on, which spawns a thread and runtime
on current-thread runtimes, and surfaced bridge failures through a new
public WalletStorageError::CoreHistoryReplay variant (a breaking change,
as the enum is exhaustive).

Poll the replay once instead. If a future upstream checker ever suspends,
load logs an error and keeps the pre-replay projection rather than failing
the wallet; a unit test pins first-poll completion so such a change fails
in CI. This removes the variant and the dash-async dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Swift accounting reconcile and the SQLite history repair disagreed on
direction: Swift reported an asset lock as internal even when an output
left the wallet, and a spend with no remaining outputs as internal.

Swift now uses the Rust repair's rule (internal only when nothing leaves
the wallet and something stays in it, or an asset lock burns into
Platform). The Rust rule moves into a pure helper, and both sides test the
same case table. The upstream rust-dashcore recompute stays out of scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The asset-lock fixtures used an empty-script output as the burn, which the
aligned direction rule rightly treats as leaving the wallet. Use a real
OP_RETURN so the fixtures match what an asset lock carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
netAmount(for:) returned the stored scalar through its single-wallet fast
path before checking for unresolved inputs, and the accounting reconcile
counted an address-matched output of any local wallet, so a transfer to
another local wallet whose TXO was not linked yet showed the sender only
part of what it paid.

Unresolved inputs now make every amount provisional, and an unlinked
address-matched output counts only when it belongs to a spending wallet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard that keeps a funded asset lock's Core debit and fee from being
overwritten by a context-only zero update required an already-linked
owned input. With every prevout still pending, the synthetic update
erased the debit and fee, and reconcile could not restore them.

A stored negative amount is itself the proof the wallet funded the lock,
so the guard now keys on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
loadWalletList's history accounting pass returned a load failure on any
fetch, reconcile or save error, so a problem in display-only amounts left
every wallet unrestored. It also fetched a PersistentCoreAddress per
unlinked output.

A failed pass now rolls back its own edits, logs a distinct event and lets
the restore continue; addresses are read once into a lookup. A persisted
completion marker is deferred (TODO) because it needs a SwiftData shape
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Transaction views treat an unresolved per-wallet amount as unavailable
  everywhere, so the fee no longer shows beside "Amount unavailable".
- A failed accounting reconcile no longer fails the persistence round and
  is reported as persistence_transaction_accounting_failed, not save_failed.
- Name the Swift transaction-kind and direction values instead of 1/3/6.
- Rename the verdict test that never covered Uncredited and add one that
  does; V019's confirmed-spend marking is pinned by the V019 fixture test.
- Drop the hand-written Unreleased section from the generated CHANGELOG.
- Mark the divergent record-coalescing helpers with a TODO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… restores it

Rework of 63a7f8e, which skipped undecodable records everywhere.

Normal operation is strict again: the per-round repair and
preserve_known_details treat a corrupt stored record as an error.

V019 (which has no recovery mode) drops an undecodable record only when a
Core resync re-delivers it: the row has a block height, so a filter
rescan finds it again. It then deletes the row and its input-index rows
and lowers that wallet's synced_height to just below its birth height;
load hands that checkpoint to SPV, which rescans from birth and re-records
the transaction. Spent marks and outputs it already produced are kept
until the rescan confirms them. An unconfirmed corrupt record cannot be
restored that way, so the migration still fails and rolls back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: OpenAI Codex <noreply@openai.com>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit b27eb83. Normal review starts when eligible; priority review starts as soon as a slot is available.

This branch has not been deployed

No deployments
Sign up for free to 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