fix(platform-wallet-storage)!: harden wallet history restore after #5150 review - #5208
Conversation
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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository: dashpay/platform/.coderabbit.yaml Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
|
🕓 Queued for automated review — 3rd in line, estimated start in ~45 min (commit 2a98ef8)
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
The PR improves wallet history restoration, but three blocking defects remain in the new recovery paths: persisted InstantSend locks are applied before transaction records exist, corrupt confirmed records can be deleted without scheduling a rescan, and legacy height 0 records are misclassified as confirmed. The replay ordering and migration blob-size handling also need hardening to preserve spend reservations and bounded allocation behavior.
🔴 3 blocking | 🟡 3 suggestion(s)
Review provenance
Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: muse-spark-1.3-contributor (agent: phase1-reviewer, role: architecture-layering); reviewer 3: muse-spark-1.3-contributor (agent: phase1-reviewer, role: ffi-engineer); reviewer 4: muse-spark-1.3-contributor (agent: phase1-reviewer, role: platform-versioning); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 7: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 8: gpt-6-astra (agent: phase2-reviewer, role: architecture-layering); reviewer 9: gpt-6-astra (agent: phase2-reviewer, role: ffi-engineer); reviewer 10: gpt-6-astra (agent: phase2-reviewer, role: platform-versioning); reviewer 11: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); reviewer 12: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)
- Triage:
criticalbygpt-6-astra(effort low) — The large, intricate diff changes critical wallet storage migrations and rehydration/accounting logic that affects spend-state restoration, funds movement, and coin selection. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— architecture-layering (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— ffi-engineer (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— platform-versioning (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— security-auditor (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-3.8-flash-high(antigravity below 15% reserve: weekly 13% left, 5h 100% left),glm-5.3-flash(not used above high effort; tier asks max) - Fresh verifier:
gpt-6-astra— final-verifier; agentastra-verifier - Phase 2 reviewers:
gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— security-auditor (completed, effort xhigh); agentphase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs:413-458: Replay does not restore InstantSend context from persisted locks
The persisted InstantSend locks are applied at lines 413-415 before `restore_recorded_transactions` replays any transaction records. `mark_instant_send_utxos` can only update a record that already exists; at this point it merely records the txid in the lock set. The replay then passes each stored record's unchanged `record.context` to `check_core_transaction`, so a record originally stored as `Mempool` remains a mempool record rather than becoming `InstantSend`. Because the lock txid is already present in the lock set, a later call to `mark_instant_send_utxos` is deduplicated and does not repair the record context or run the InstantSend conflict sweep. After restart, the transaction can therefore remain evictable and conflicting outputs can remain incorrectly credited. Apply the locks after records have been replayed, or make the replay resolve each record's context from the persisted lock map before checking it.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs:526-555: Replay all records in dependency order, not confirmed records before pending records
`replay_order` always emits every record with `block_info()` before any pending record, and only topologically sorts the pending partition. A confirmed child can therefore be replayed before a stale mempool parent. This state is reachable because a height-only confirmation update intentionally does not replace an existing blob-bearing mempool record (`core_state::apply` has `WHERE core_transactions.record_blob IS NULL`), while the child can have a confirmed record. The child is checked before its parent output exists, so its input is not attributed or reserved; replaying the parent later as mempool does not perform a final spend reservation. Use a dependency-aware ordering over the complete record set, while retaining deterministic block-position ordering where there is no dependency.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs:528-555: Add direct tests for replay ordering invariants
`replay_order` now encodes the spend-reservation ordering invariant, but the tests only exercise selected end-to-end cases. There is no direct regression test for a child-first unconfirmed chain, a confirmed-position tie-break, or the cycle fallback. A focused deterministic unit test would make a future refactor less likely to reintroduce an ordering that loses reservations during load.
In `packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs:83-109: Create a sync-state row when forcing a rescan
`drop_for_resync` deletes the corrupt confirmed record and then uses an `UPDATE ... WHERE wallet_id = ?1` to lower `synced_height`. SQLite reports success when that UPDATE matches zero rows, so a wallet without a `core_sync_state` row proceeds as though the rescan was scheduled even though no checkpoint was created. Such wallets are possible: wallet creation does not create a sync-state row, and `core_state::apply` only upserts one when a changeset carries a height or chain lock, while transaction records may be stored without those fields. The migration can therefore delete the only record that can restore the transaction and return success without arranging for Core to redeliver it. Use an upsert that inserts the wallet's rescan checkpoint when the row is absent.
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs:68-90: Do not treat legacy height 0 as a confirmed record
V019 currently treats any `Some(height)` as block-confirmed. However, the V014 migration explicitly documents that pre-V014 writers encoded unconfirmed records with `height = 0`; only positive heights identify a confirmation, while post-V014 unconfirmed rows use NULL. A corrupt pending record from a pre-V014 database therefore enters `drop_for_resync`, gets deleted along with its input index, and the migration succeeds even though an SPV rescan cannot redeliver a mempool transaction. Its reservation can then be lost and the input can become selectable for a conflicting spend. The rescan path must require a strictly positive height.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/sqlite/migrations/legacy_v019.rs:30-47: Check the record length before fetching the BLOB
`read_record` selects both `length(record_blob)` and `record_blob`, and the query closure materializes the payload with `row.get(1)?` before `blob::check_size(len)` runs. An oversized or corrupt record is therefore copied into a `Vec<u8>` before the migration rejects it. This bypasses the pre-materialization size guard used by the normal SQLite readers and permits an allocation larger than the 16 MiB blob budget, subject only to the connection's SQLite limit. Read and validate the length first, then fetch the payload in a second query.
Out-of-scope follow-up suggestions (5)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Release repair spend claims after reorg — Repaired confirmed history sets
spent_in_txid, but the current design does not release that claim when the spender later leaves the active chain. This can strand a legitimately spendable UTXO after a reorg; the PR already marks this as deferred and it requires verifying upstream downgrade semantics.- Follow-up: Track a separate reorg-reconciliation issue before enabling release of repaired spend claims.
- Bound startup replay of persisted history — The new load path replays all persisted records on every startup, so a large amount of wallet-relevant history can make startup work grow without bound. The PR explicitly defers this because pruning records must not remove finality or spend guards.
- Follow-up: Design a checkpointed or chain-lock-bounded replay strategy that preserves guards for older records.
- Swift redefines the wallet direction classifier — Out of scope — the PR deliberately keeps the Swift and SQLite persistence rules separate, adds mirrored case-table tests, and explicitly excludes an upstream shared-classifier change.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
- Core transaction replay is owned by the SQLite adapter — Out of scope architecture redesign — this PR's SQLite persister is the backend that decodes persisted records and replays them, while the separate FFI startup path has different inputs; no incorrect result was demonstrated for the current supported backends.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
- Upstream sync checker API lives in rust-dashcore/key-wallet — Out of scope — this is an upstream API improvement rather than a defect in the PR, and the current checker is intentionally verified to complete on its first poll.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
…llowups Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed contact label - Import core_history, ManagedCoreFundsAccount and restore_recorded_transactions at the top instead of inline super::/fully qualified paths (coding conventions: imports at the top). - Drop the function-local UtxoCreditVerdict imports the test module already receives through `use super::*`. - Split over-long SQL literals with `\` continuations so rustfmt formats the enclosing calls again. - Bind the contact watch-only label from a DASHPAY_EXTERNAL_LABEL const shared with account_type_db_label instead of a SQL string literal, so a label change cannot silently stop contact exclusion. The frozen legacy_v019 migration keeps its literal. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t replay order - A script the store tracks only on a contact's watch-only chain, but that a rebuilt funds account still derives, is credited by load replay; assert that copy does not survive load (ties the load-time contact filter to the replay retain). - Same-height records without a block position replay in stored order; assert a stale unspent projection ends with the spent output excluded whichever of funding and spend replays first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TXO map loadWalletList built its outpoint map with Dictionary(uniqueKeysWithValues:), which traps on a duplicate key and would crash every launch before the non-fatal accounting error path. Build it with uniquingKeysWith, keeping a live row over a deleted one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…styling - PersistentTransaction: build the owning-wallet set once (owningWalletIds), format both amount variants through one format(duffs:) (magnitude, so Int64.min no longer traps), and add displayNetAmount/displayDirectionCode/displayFormattedAmount(for:) so views stop hand-rolling the wallet-scope fallback. - Make CoreDirectionCode public and use it instead of raw 0/1/2/3 in directionName and the example-app views; move it off TransactionTypeKind's doc comment, which it had split from its enum. - Name the OP_RETURN check (TransactionDecoder.Output.isOpReturn). - Example app: one TransactionDirectionStyle mapping for the list row and the detail view, so an internal transfer is red with the circular-arrows icon and CoinJoin is blue with the shuffle icon in both (the detail view had drifted to blue for internal transfers). - Mark the missing round-end accounting failure-path test as a TODO. Swift was not compiled or tested in this environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as TODO Body conflict, unknown network and net-amount overflow in core_history reuse BlobDecode because no existing variant matches them exactly and a new variant would break exhaustive matches on WalletStorageError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… unknown networks and amount overflow
History repair reported three non-decode failures as BlobDecode. Each now
has its own variant with the context an operator needs:
- TransactionBodyConflict { wallet_id, txid }: a stored txid re-arrives
with a different raw body. Kind: Constraint, non-transient.
- UnknownWalletNetwork { wallet_id, label }: wallets.network holds a label
this build does not know. Kind: Fatal, non-transient.
- NetAmountOverflow { wallet_id, txid, value: i128 }: owned outputs minus
owned inputs does not fit i64. Kind: Constraint, non-transient.
All three are wired into is_transient, persistence_kind and
error_kind_str (still wildcard-free). The frozen V019 migration keeps
its own BlobDecode sites untouched. Its drop-for-resync path only
inspects read_record errors, so those sites never affect stored data.
BREAKING CHANGE: WalletStorageError is now #[non_exhaustive], and it gains
TransactionBodyConflict, UnknownWalletNetwork and NetAmountOverflow.
Matches outside the crate need a wildcard arm, and callers that matched
BlobDecode for these failures must match the new variants.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ification table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…classification table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e error classification table" Duplicates the comment added in aa56693. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the blob V019's read_record fetched record_blob into a Vec inside the same row closure that read its length, so an oversize record was materialized before blob::check_size rejected it. Read the length first and fetch the payload only once it passes, matching core_state::load_state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tory by dependency A lock that arrives after its transaction was stored is persisted only in core_instant_locks; the stored record keeps its mempool context. Load marked the lock first (so later lock events dedupe) and then replayed the record as mempool, leaving it evictable and skipping the InstantSend conflict sweep. Replay now upgrades a mempool record with a persisted lock to InstantSend. replay_order sorted every confirmed record ahead of every unconfirmed one, but a parent's record can still say mempool after it confirmed (a height-only confirmation never rewrites an existing record) while its child is recorded confirmed. Order the whole set topologically, keeping chain order (height, block position, then unconfirmed; txid ties) where there is no dependency, with direct tests for the ordering invariants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry replay Cover the dependency-aware replay_order for confirmed spends within one block: in-block position keeps its order around a funding/spend pair, and the observed-spent path excludes the spent output whichever record is stored first, with and without stored positions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eplay upgrade - Load replay upgrades a mempool record to InstantSend only when the persisted lock names that record's txid and the record's transaction really hashes to it; a mismatch is logged and the record replays in its stored context, so a misfiled lock cannot sweep legitimate history. - Track the lack of expiry for unconfirmed spend reservations (TODO(expire-unconfirmed-spend-reservations)). - Document the dependency-ordered history replay. - V019 (unreleased, edited in place): split long SQL literals so rustfmt formats the calls, report UnknownWalletNetwork / NetAmountOverflow like the runtime repair does, and name the shared live helpers in the module doc. SQL text is unchanged; the DDL fingerprint still pins. - Tests: share one V018 rewind helper, add a receive-address wallet fixture, and drop redundant local imports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on codes - Clear accountingDirty at beginChangeset and after an out-of-round save rollback, so a round never reconciles rows staged (or rolled back) by out-of-round writers; load-time accounting repair covers those rows. - Use CoreDirectionCode in the direction case table test and the storage explorer's direction filter. - Move TransactionDirectionStyle to its own file (folder-synced project, no pbxproj change). - Say "amount unavailable" for unresolved asset-lock amounts, matching PersistentTransaction.formattedAmount; fix the assetLocks query doc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR: Follow-up fixes from the multi-model review of #5150: unconfirmed spends survive restart, history repair is reversible and recoverable, Swift per-wallet amounts are correct,
CoreHistoryReplayis removed, and history integrity failures get precise error variants.Stacked on #5150 (
fix/pr-5126).Siblings on #5150: #5207 (asset-lock reconciliation without
WALLET_RESTORE); #5210 (full Core wallet snapshot restore, stacked on #5207) already includes this PR at338067889e.Issue being fixed or feature implemented
Addresses the remaining review findings on #5150 (#5126). The three HIGH findings (duplicate UTXO after replay, load-time reconcile inside an open changeset, contact-only outputs loaded as spendable) are fixed directly in #5150.
What was done?
Unconfirmed spends restored on load: load also replays mempool and InstantSend records, in dependency order (parents first, otherwise chain order), so redelivering a funding transaction after restart can no longer release outputs reserved by a pending spend.
Reversible history repair (V019, unreleased, edited in place): V019 adds
core_transaction_record_originals(append-only), which stores each record's pre-repair blob. Every repair-setspentflag recordsspent_in_txid.Frozen V019: the migration's repair logic lives in
migrations/legacy_v019.rs, so later changes to the shared repair cannot change what V019 did. A V018→V019 pinning test locks the result.Corrupt records:
synced_heighttobirth_height - 1, forcing an SPV rescan that re-delivers it.No async bridge: the transaction checker completes on its first poll, so replay polls it once instead of calling
dash_async::block_on. This removesWalletStorageError::CoreHistoryReplayand thedash-asyncdependency. A unit test fails if the checker ever stops completing on first poll.One direction rule: Rust's direction rule is extracted as
repaired_direction, and Swift uses the same rule. Both sides share one case table in tests. Upstream rust-dashcore keeps its own rule, which is out of scope here.Swift accounting:
netAmount(for:)checkspendingInputsbefore the single-wallet fast path.Swift load: a load-time accounting failure rolls back only its own pass, logs a distinct event, and no longer blocks wallet restore. Address lookups are batched.
Low-severity cleanups:
save_failed.## Unreleasedsection removed fromCHANGELOG.md.Second review round (grumpy review of fix(platform-wallet)!: restore Core spending state and repair persisted accounting #5150):
useimports at the top instead of inline paths; long SQL literals split so rustfmt formats the calls; oneDASHPAY_EXTERNAL_LABELshared by the account label and the contact-only query.Int64.mintrap) andCoreDirectionCodeare shared.isOpReturnreplaces0x6a. The list row and the detail view use one direction style, so an internal transfer is now red in both and CoinJoin shows the shuffle icon.WalletStorageErrorgainsTransactionBodyConflict(Constraint),UnknownWalletNetwork(Fatal) andNetAmountOverflow(Constraint) instead of reusingBlobDecode. The enum becomes#[non_exhaustive].Changelog entry (moved from
CHANGELOG.md): platform-wallet-storage: Restore confirmed Core spend and finality state on SQLite load so old funding transactions cannot make already-spent outputs selectable again.Bot review fixes:
InstantSend.replay_orderis a topological sort over all records, so a stale mempool parent replays before its confirmed child. Independent records keep chain order.read_recordchecks the blob length before fetching the payload.Deferred (marked with
TODO(...)in code)bound-load-history-replay: replay only records above the last chain lock. This is performance only and touches finality/guard correctness.release-repair-spends-after-reorg: release rows whosespent_in_txidspender left the chain. Needs verification of how upstream downgrades a stored context on reorg.unify-record-coalescing:coalesce_newest_winsvs the second coalesce helper. This changesMergesemantics and predates fix(platform-wallet)!: restore Core spending state and repair persisted accounting #5150.test-round-accounting-failure: fault-injection test for a round-end reconcile failure. The handling is already in place; the test needs a Swift toolchain to pin which fetch fails.persist-accounting-backfill-marker: the Swift full-history pass runs on every launch. A marker needs a SwiftData schema version (V4) or aUserDefaultsside channel, which is a product decision.How Has This Been Tested?
cargo nextest run -p platform-wallet-storage: 1011 passed, 2 skipped (after the second round); clippy--all-targets -D warningsclean;cargo fmt --checkclean.Breaking Changes
WalletStorageError(public,platform-wallet-storage) is now#[non_exhaustive]and gainsTransactionBodyConflict,UnknownWalletNetworkandNetAmountOverflow. Downstream exhaustive matches need a wildcard arm. Errors previously reported asBlobDecodefor these three cases now carry their own variant and metric tag. This PR also removes the unreleasedCoreHistoryReplayvariant added by #5150. No FFI changes. V019 is unreleased and is edited in place instead of adding a new migration. It gains thecore_transaction_record_originalstable, which the V019 repair itself writes to, so the table cannot move to a later migration. As a result, a development database that already ran #5150's V019 fails refinery's divergent-migration check and must be recreated.Checklist:
🤖 Generated with Claude Code