Repository navigation
Conversation
The SQLite persister replayed its stored transaction records through the wallet checker inside `load()`, so only SQLite rebuilt the in-memory spend guards (`spent_outpoints`, `observed_spent_outpoints`, IS-lock upgrades) that stop a redelivered funding transaction from resurrecting a spent output. Any other persister got no guard for confirmed spends. Move the replay (`replay_order`, lock matching, the persisted-UTXO-set retain filter) into `platform_wallet::manager::history_replay` and run it from `load_from_persistor` for every persister. Persisters now hand their stored history over as `ClientWalletStartState::recorded_history` (`RecordedHistory` / `StoredTransaction`); SQLite supplies its records and locks instead of replaying them itself. The replay runs at the async boundary and awaits the checker, so the first-poll `poll_ready` shim and its suspension rollback are gone. Records an account already holds (a persister's own raw restores) are skipped, and an unconfirmed outgoing send present in the history is only re-dispatched, not accounted twice. BREAKING CHANGE: `ClientWalletStartState` gains the public field `recorded_history`; struct-literal constructors must set it (`Default::default()` keeps the old behaviour). `SqlitePersister::load` no longer returns a replayed projection: callers that bypass `load_from_persistor` must run `replay_recorded_history` themselves. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Bit 13 attests that a persister's load hands back the wallet's complete stored transaction history, so the shared load replay rebuilds the spend guards for confirmed spends too. The SQLite persister attests it; the FFI mirrors the bit value as a C constant (host wiring follows). Diagnostic only: `load_from_persistor` warns once per load when a persister restores wallets without it, and no operation is gated on it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The FFI load path handed Rust only the host's unspent UTXO rows plus its unconfirmed outgoing sends, so confirmed spends were never replayed: after restart a redelivered funding transaction (rescan, gap-limit rediscovery) re-credited an output a confirmed spend had already consumed. Hosts can now hand back every stored transaction through `WalletRestoreEntryFFI::recorded_transactions` (`RecordedTransactionRestoreFFI`: txid, bytes, context, block fields, stored net amount and direction). `build_wallet_start_state` decodes them into `ClientWalletStartState::recorded_history` for the shared load replay. A row whose bytes do not hash to its txid, that does not decode, or that carries an unknown context is dropped with a counted warning; InstantSend rows replay as mempool because the host keeps no lock bytes. The FFI persister attests `CORE_HISTORY_RESTORE` when the host declares it and wires the wallet-list load pair. Android does not supply history yet (null/0, TODO in the JNI bridge). BREAKING CHANGE: `WalletRestoreEntryFFI` gains two trailing fields and has no size field, so the host and the library must be rebuilt together (the same lockstep contract as `unconfirmed_outgoing_tx_records`). Co-Authored-By: Claude Opus 5.5 <[email protected]>
`loadWalletList` now hands every wallet-owned `PersistentTransaction` back to Rust as `WalletRestoreEntryFFI.recorded_transactions` (txid, bytes, context, block fields, stored net amount and direction), so the shared load replay rebuilds the spend guards of confirmed spends and a funding transaction redelivered after restart cannot resurrect a spent output. The handler declares `CORE_HISTORY_RESTORE`. A failed history fetch rejects the snapshot (`errored`), like the unspent TXO fetch: an empty history would claim there is nothing to guard. Rows without a 32-byte txid, without bytes, or confirmed without a 32-byte block hash are skipped with one logged count. Not compiled locally (no Swift toolchain on the authoring host). Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
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
|
|
⛔ Final review complete — 9 blocking finding(s) (commit ecc4921) · triage: critical |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
History replay is correctly centralized, and the normal FFI path test demonstrates that recorded confirmed spends guard redelivered funding outputs. However, the FFI also raw-inserts unresolved asset-lock records before shared replay, while the new held-txid skip suppresses replay of the same confirmed funding and spender rows; their spend guards therefore remain absent and a redelivered funding transaction can resurrect an already-spent output. This is a blocking correctness issue.
🔴 1 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: rust-quality); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: security-auditor); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 8: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 9: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 10: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — This is a large, intricate cross-language persistence and wallet-replay change that directly alters funds movement and spent-output accounting in platform-wallet load paths, including FFI deserialization and storage restoration. - 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— 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.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— 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/src/manager/history_replay.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:93-95: Do not skip history merely because a raw-restored transaction record exists
The `held` set only proves that an account contains a transaction-map entry; it does not prove that the checker restored the transaction's spend effects. The FFI load path raw-inserts unresolved asset-lock records, including confirmed spenders of asset-lock inputs, through `transactions_mut().insert(...)` before `recorded_history` is replayed. Swift supplies those same rows in both the unresolved-asset-lock array and the recorded-history array. Because their txids are already held, this branch skips them, and the raw synthetic records do not populate `spent_outpoints` or `observed_spent_outpoints`. A later redelivery of the funding transaction can therefore credit an output already consumed by the confirmed spender. The existing FFI regression does not include the raw unresolved-record array, so it does not exercise this overlap. Stage retention-only records after shared replay, avoid raw-inserting records that will be checker-replayed, or otherwise rebuild the spend guards independently; add a regression with a raw-restored confirmed spender and funding redelivery.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/history_replay.rs:63-72: Build the held transaction set once instead of scanning all accounts per record
The `held` filter reconstructs `all_accounts()` and scans every account for every stored transaction. Since load replays the full persisted history on each startup, this adds repeated account-collection work proportional to history size. Build an owned txid set from all account transaction maps once before filtering; this also avoids retaining references into `wallet_info` across the subsequent mutable replay.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:7140-7149: Prefetch transaction relationships during history restore
The new history fetch retrieves every `PersistentTransaction`, then evaluates `walletOwnsTransaction` for each restorable wallet. That predicate touches `involvedAccounts`, `outputs`, `inputs`, and `pendingInputs`, but this descriptor does not prefetch any of them. On a cold launch, relationship faults can therefore be triggered repeatedly while filtering the history for each wallet. Prefetch these relationships, or bucket ownership in one pass, to keep history restoration from scaling poorly as wallets and transaction history grow.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:5245-5257: Reject trailing bytes in recorded transaction rows
`consensus_decode(&mut &bytes[..])` accepts a valid transaction prefix without requiring the input cursor to be exhausted. Consequently, a row containing a valid transaction followed by arbitrary bytes passes the txid check and is replayed, despite the decoder's fail-closed contract and the surrounding use of full `deserialize` for other restore paths. Require complete cursor consumption before accepting the row; apply the same exact-consumption rule to the unconfirmed decoder for consistency.
Out-of-scope follow-up suggestions (1)
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.
- Define expiry for persisted unconfirmed spend reservations — The shared replay now also replays host-supplied FFI mempool rows, so a never-mined or invalid observed spender can reserve an outpoint across every restart until a conflicting confirmed or InstantSend transaction is observed. This is an availability concern, but the same policy already exists on the SQLite path and is explicitly tracked by the moved expiry TODO rather than being introduced as an implementation mistake in this change.
- Follow-up: Track a separate policy change defining how persisted unconfirmed reservations are revalidated or expired, including safe release of dependent transactions.
Replay raw restored history records through the checker while retaining proof lookup records and user metadata. Sweep fallback records against final transactions, prefetch Swift history relationships, and reject trailing transaction bytes at both restore boundaries. Co-Authored-By: Codex <[email protected]> <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
The shared history replay fixes the confirmed-spend reconstruction gap and the four prior findings are resolved. Two replay-order defects remain: load-time chain-lock finalization can erase asset-lock conflict evidence before it is seeded, and final conflict reconciliation is skipped when the checker regenerates a conflicting raw record. Both can leave incorrect asset-lock wait behavior or wallet balance/spend state after restore.
🔴 2 blocking
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: rust-quality); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — This is a large, intricate cross-language change that directly alters wallet transaction-history replay, spent-output reconstruction, and spend reservations in persistence and loading paths, affecting funds movement and coin-selection state across SQLite, FFI, Swift, and Rust. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (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) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— 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/src/manager/history_replay.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:168-170: Preserve asset-lock conflict evidence before load-time finalization
The replay finalizes the restored chain lock after rebuilding history, but `load_from_persistor` seeds `observed_input_conflicts` only afterward. With the default `keep-finalized-transactions = OFF`, `apply_chain_lock` evicts a confirmed spender record once its block is buried, leaving only its finalized txid. The subsequent seeder reads `transaction_history()` and the cache, so a restored confirmed spender that was needed to identify an `AssetLockInputContested` conflict disappears before it can be recorded. This regresses the bounded proof-wait behavior for a Built/Broadcast asset lock whose input was spent by a restored transaction. Capture conflict evidence before applying the restored chain lock, or seed the cache from the replayed records before finalization while retaining the finalization needed to rebuild spend guards.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:162-166: Reconcile final conflicts after raw-history restoration
`sweep_conflicts` is invoked only when `restored_fallback` becomes true. If a final winner is replayed before a conflicting mempool loser, all raw records have already been detached, so the winner's initial sweep cannot see the loser. The checker can then regenerate the loser because its wallet-owned change output is attributable, causing metadata restoration to take the existing-record branch at lines 152–155 and leaving `restored_fallback` false. For an InstantSend winner, the checker also does not populate `observed_spent_outpoints`, so the loser can recreate change and reserve additional inputs. Because the persisted projection filter preserves that change when it was present in the restored UTXO set, both conflicting state and an inflated balance can survive load. Reconcile all final transactions after the complete replay and metadata-restoration pass, independently of whether fallback records were reinserted; add a regression with an attributable loser and an InstantSend winner replayed first.
Out-of-scope follow-up suggestions (2)
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.
- Revalidate indefinitely retained unconfirmed spend reservations — Stored incoming or outgoing mempool records are replayed across restarts without expiry or chain revalidation. A never-mined transaction can therefore keep a wallet outpoint reserved indefinitely. This behavior predates the shared replay and is explicitly tracked by
TODO(expire-unconfirmed-spend-reservations), so it should not be fixed in this PR.- Follow-up: Track a separate change for safe admission and reservation revalidation that does not release inputs belonging to genuinely broadcast transactions merely because peers become silent.
- Bound startup history replay without discarding spend guards — A peer can accumulate many wallet-relevant unconfirmed records, increasing retained history and startup replay work on every launch. The SQLite path already replayed complete history, and this PR moves that behavior into the shared implementation; truncating history here would reopen spent-output resurrection. This is explicitly tracked by
TODO(bound-load-history-replay)and is outside this fix.- Follow-up: Design a separate bounded-history mechanism backed by durable spend-guard or finality checkpoints, with adversarial history-size coverage.
Keep this branch's removal of the SQLite-only replay from rehydrate.rs and port the two restart-replay fixes that landed there into the shared history_replay, which runs for every persister at load: - Height-only funding: StoredTransaction gains `owned_inputs`, filled from TransactionRecord::input_details. Before replay, every owned input whose funding no replayed record credits is staged, so the spender rebuilds its account spent mark; the retention pass drops leftovers. The FFI decode supplies none yet (TODO(ffi-recorded-input-details)), so the guard is not rebuilt on the SwiftData path until the host sends per-input ownership. - InstantSend sibling order: records replayed as InstantSend re-run their conflict sweep after the full replay, and the swept txids join the set that keeps held raw records from returning as fallbacks. Staging makes more losers attributable, so a sweep can remove a parent before the raw-record fallback restore, leaving its unattributable child unreachable by the final sweeps. Held unconfirmed, unlocked descendants of swept transactions are now dropped transitively, matching the checker's own descendant rule. The storage integration tests merged from fix/pr-5126 await the async replaying load helper. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
Static verification confirms two prior blockers remain: block-confirmed conflicts can retain persisted losing change, and load-time finalization can erase asset-lock conflict evidence before recovery caches it. The new descendant cleanup also fails to remove already-reconstructed descendants and introduces avoidable quadratic startup work; four prior findings are fixed. No builds or tests were run; the supplied CI snapshot confirms Kotlin validation but contains no Rust or native Swift validation results.
🔴 3 blocking | 🟡 1 suggestion(s)
2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The intricate cross-persister replay in packages/rs-platform-wallet/src/manager/history_replay.rs::replay_recorded_history changes spend reservations, conflict resolution, and spendable UTXO reconstruction, directly affecting funds availability and coin selection after wallet reload. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (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 39% left, 5h 11% left),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (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/src/manager/history_replay.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:157-163: Remove reconstructed descendants when their parent was already swept
drop_swept_descendants only extends the swept set, and this continue only skips restoration of the held original. Neither operation removes a live descendant already reconstructed by the checker. The permitted replay order loser → InstantSend winner → mempool child exposes this: the winner removes the parent while the child is detached, then a child with a wallet-owned output is reconstructed and its persisted output survives retention. The post-replay InstantSend sweep cannot discover that child because the conflicting parent record is already absent; the pinned conflict walker returns when it finds no direct loser. This helper identifies the child through held history, but skipping its original leaves the live record and selectable output intact. Remove reconstructed records and UTXOs for the swept descendant closure, with appropriate spend-mark cleanup, or prevent those descendants from being applied. The existing orphan regression uses an unattributable child and does not exercise this case.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/history_replay.rs:202-226: Index held descendants instead of repeatedly scanning them
This closure repeatedly scans every followable held record until no new txid is added. Held records are collected in account/transaction-map order rather than dependency order, so a deep descendant chain can require one pass per generation, producing O(depth × held history) startup work. The pinned checker's conflict walker already avoids this scaling problem with a parent-to-children index. Build that index once and traverse newly swept txids with a worklist, preserving the existing confirmed/locked exclusions while making traversal linear in records and input edges.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:175-179: Reconcile final conflicts after raw-history restoration
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4153965344)
The final reconciliation runs only when an unattributable held record was reinserted. A block-confirmed winner replays before its conflicting mempool loser while the held loser is detached, so the initial sweep cannot remove it. If the loser pays a wallet-owned address, the checker subsequently reconstructs its record. The pinned checker's observed-spend guard returns before modifying UTXOs, which prevents new credits but leaves the loser's already-restored change untouched. That change passes the projection retention filter, and restoring the held record takes the metadata-only branch, leaving restored_fallback false. The unconditional sweep at lines 122–123 covers only InstantSend winners, so neither the reconstructed loser nor its selectable change is removed. Reconcile block-confirmed winners after complete replay and raw restoration independently of fallback insertion, and cover an attributable loser whose change is present in the restored UTXO projection.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:181-184: Preserve asset-lock conflict evidence before load-time finalization
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4153965337)
Replay applies the restored chain lock before load_from_persistor constructs PlatformWalletInfo and calls seed_observed_input_conflicts at load.rs:283. With keep-finalized-transactions disabled, as it is by default, the pinned key-wallet implementation promotes and evicts covered InBlock spender records, retaining only their txids. Swift explicitly supplies confirmed spenders of unresolved asset-lock inputs through its unresolved-record buffer, but restoring those records before this finalization does not preserve their input-to-spender association afterward. The seeder reads only surviving transaction_history(), and the new conflict cache starts empty, so a Built/Broadcast asset lock loses its conflict-capped proof wait and contested-input verdict. Already-chainlocked history can also be evicted during checker replay when no held fallback preserves it. Capture the relevant input, spender, and height observations before either eviction point and carry them into PlatformWalletInfo while retaining core finalization. Cover manager load with a competing confirmed spender and a higher persisted chain lock under the default retention configuration.
…et-load-history-replay Bring in the base (fix/pr-5126): the v5.1 rust-dashcore port and the wallet accounting repairs. Conflicts: sqlite/persister.rs, sqlite/rehydrate.rs. The base now replays stored history inside the SQLite load transaction: the replay returns the conflict sweeps, the loader applies them to the database, rebuilds the snapshot from the repaired rows, restores the durable spent claims after finality, and commits (Strict) or rolls back (Recovery). A replay that runs after `load()` returns cannot take part in that transaction, so the SQLite half of the hoist is superseded: - rehydrate.rs and tests/sqlite_spent_rehydration.rs are the base's versions verbatim. - persister.rs is the base's version plus this branch's additions: it still declares CORE_HISTORY_RESTORE and hands back an empty `recorded_history`, so the shared load does not replay the same records a second time. The shared replay in `manager/history_replay.rs` is unchanged and keeps serving every persister that hands its records back (the FFI one). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The shared load replay swept conflicts only as each record replayed, then again for InstantSend winners, and for confirmed winners only when a raw fallback record had been restored. Two cases survived that: - A confirmed winner always replays before a mempool sibling. A loser that pays the wallet was then recorded after the winner's sweep, and its persisted change stayed selectable: an inflated balance. - A loser's descendant that replays after the winner swept its parent was rebuilt from the output it pays the wallet, and no later sweep reached it without the parent. Same for a descendant paying another account, which the per-account sweep never follows. Port the planning the SQLite replay already does: settle each stored context first (a persisted chain lock finalizes the blocks it covers, a matching lock makes a mempool record InstantSend), resolve every conflict on a scratch copy that holds the whole history in one account, and keep the losers out of the replay and their outputs out of the projection. One unconditional sweep of the settled survivors then replaces the two conditional ones. Planning covers every held record, so the repeated-scan descendant closure is gone. Also correct three doc comments the base merge made inaccurate: the SQLite persister replays inside its own load transaction and hands back an empty history. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ad replay The double-spend screen's memory is seeded at load from the restored wallet history. The shared replay runs first and leaves two kinds of confirmed spender out of that history: one buried under the restored chain lock, whose record the replay's finalization evicts under the default `keep-finalized-transactions = OFF`, and one the checker cannot attribute, which never gets a record. A Built or Broadcast asset lock whose input such a transaction spent then fell back into the unbounded proof wait. Read the evidence from the stored records before the replay consumes them (`recorded_input_conflicts`) and start the memory from it; the history-based seeding still runs on top. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]>
The comment above the settling loop only covered the InstantSend upgrade; the loop also finalizes blocks the restored chain lock covers. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pick up the storage fixes from the base (cached replay queries, mempool claimant provenance, replay amount guard, recovery fixtures). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]>
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
Static verification at f6f2004 confirms that all eight prior findings are addressed. Two in-scope blockers remain: unchecked monetary operands in shared replay and attacker-amplifiable whole-history conflict sweeps; rebroadcasting transactions already swept as losers is a lower-impact issue. No builds or tests were run in this lane; the supplied exact-head CI snapshot shows successful Kotlin checks but no dedicated Rust-wallet or native Swift validation.
🔴 2 blocking | 🟡 1 suggestion(s)
1 finding(s) not shown inline (the lines are not part of this PR's diff)
🟡 Suggestion: Do not rebroadcast unconfirmed sends that the history replay swept as losers
packages/rs-platform-wallet/src/manager/load.rs:373-387
Shared replay excludes defeated transactions from accounting, but pending_resends still receives the complete unconfirmed_outgoing_txs list because replay returns only a count. A send present in both lists can therefore be swept as a loser and then handed back to SPV's broadcast/retry machinery. Swift's outgoing selection reduces this overlap but does not eliminate it: a losing send with an additional wallet input can remain selected through that still-unspent input even after a winner takes the shared input. This retries a transaction the restored history has already classified as defeated and, for an attributable transaction, routes it through mempool processing again after its record was removed. Return or otherwise expose the swept txids and exclude them from pending_resends, while retaining rebroadcast for surviving overlapping sends.
source: muse-spark-1.3-contributor (phase1-reviewer: general)
Review provenance
Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 8: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The intricate replay in packages/rs-platform-wallet/src/manager/history_replay.rs changes spent-output reconstruction, conflicting-spend resolution, and balance accounting during wallet load, directly affecting which funds remain available to spend. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (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 14% left, 5h 100% left),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— 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/src/manager/history_replay.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:128-139: Validate replay amounts before invoking the wallet checker
Shared replay stages stored input values and invokes the checker without the operand validation already used by SQLite's restore_recorded_transactions. The FFI decoder checks complete encoding, txid and context, but those checks do not bound transaction amounts. In the exact pinned key-wallet dependency, ManagedCoreFundsAccount::record_transaction calculates `received as i64 - sent as i64`, and wallet_checker performs the equivalent subtraction for wallet totals. A stored transaction spending a restored 1-duff wallet coin and paying `1_u64 << 63` duffs to a wallet address therefore reaches `i64::MIN - 1`: overflow-checked builds panic during load, while unchecked builds produce wrapped accounting. Owned-output accumulation can overflow as well, and the projection filter runs only after this arithmetic. Add a checked preflight before mutating the restored state, covering individual values and aggregate checker operands from existing coins, replayed parent outputs, owned-input details and asset-lock credit outputs. Handle unsafe records through a controlled restore outcome; excluding an unsafe mempool record must preserve its input reservations rather than silently making those coins spendable. Add shared/FFI regressions corresponding to SQLite's operand-safety coverage.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:273-278: Avoid whole-history sweeps for every contested historical spender
The planner's contested-input filter does not bound the total sweep work. The scratch account contains the complete stored history, and the pinned drop_conflicted_transactions scans that account's transaction map on every sweep_conflicts call, including calls that remove nothing. A wallet-paying mempool transaction listing inputs already spent by N distinct stored confirmed transactions puts all N confirmed transactions into the precomputed winners list. The first sweep removes the mempool competitor, but the other N−1 winners remain eligible and each scans the retained confirmed history, producing Ω(N²) planning work from an O(N)-sized conflicting record. This is reachable from an unauthenticated SPV peer: mempool handling routes transactions through wallet relevance checks, and a matching wallet output still produces a transaction record even when update_utxos rejects its outputs as doomed by an observed block spend. The FFI store retains rows removed only by shared replay, so the same startup amplification recurs on subsequent loads. Resolve conflicts with an input-to-spenders index and a shared descendant worklist, or invalidate candidates as their remaining competitors disappear while keeping work restricted to affected records. Preserve finality precedence and cross-account descendant removal.
In `packages/rs-platform-wallet/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:373-387: Do not rebroadcast unconfirmed sends that the history replay swept as losers
Shared replay excludes defeated transactions from accounting, but pending_resends still receives the complete unconfirmed_outgoing_txs list because replay returns only a count. A send present in both lists can therefore be swept as a loser and then handed back to SPV's broadcast/retry machinery. Swift's outgoing selection reduces this overlap but does not eliminate it: a losing send with an additional wallet input can remain selected through that still-unspent input even after a winner takes the shared input. This retries a transaction the restored history has already classified as defeated and, for an attributable transaction, routes it through mempool processing again after its record was removed. Return or otherwise expose the swept txids and exclude them from pending_resends, while retaining rebroadcast for surviving overlapping sends.
Out-of-scope follow-up suggestions (2)
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.
- Preserve pre-finalization asset-lock conflict evidence on SQLite load — SQLite finalizes history inside its own load transaction and returns an empty recorded_history, so the manager's new pre-replay evidence capture cannot preserve SQLite's equivalent conflict evidence. This is an unchanged, explicitly acknowledged limitation tracked by #5379, not an unresolved instance of the shared-replay finding.
- Follow-up: Keep #5379 as the separate follow-up for capturing evidence before SQLite finalization and carrying it through the startup snapshot.
- Authenticate or explicitly manage peer-originated spend reservations — The underlying unauthenticated mempool reservation policy predates this PR and is explicitly acknowledged by TODO(expire-unconfirmed-spend-reservations). A broader authentication, expiry or user-recovery policy is outside this change; the newly introduced startup-work amplification is retained separately as an in-scope finding.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
Bring in the base branch at d280e72: rust-dashcore pinned to the reworked dashpay/rust-dashcore#1112 (844da0e8), the dev-based pin and Platform adaptations from the dependency PR, and v5.1-dev. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
The conflict planner picked its winners once: every settled record sharing an input with any unconfirmed record. Each of them then ran a sweep over a scratch account holding the whole stored history, also after an earlier sweep had already removed the only competitor. One unconfirmed record naming the inputs of N confirmed spenders therefore cost N whole-history scans at every load, and the store keeps that row. - The scratch account now holds only the unconfirmed records: losers and their descendants are never anything else. - A winner is swept only while another unconfirmed record still spends one of its inputs; the count is kept current as sweeps remove records. - The pass after the replay applies the same test instead of sweeping for every settled survivor. Finality precedence and cross-account descendants stay with the upstream sweep. The plan reports its sweep count, logged once per load. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
… checker The shared load replay handed every stored record to the wallet checker, which casts each unsigned amount to `i64` before a signed subtraction. A stored record spending a 1-duff coin and paying `1 << 63` duffs to a wallet address overflows it: a panic at every load where overflow is checked, a wrapped net amount where it is not. Nothing authenticates an unconfirmed transaction, so those amounts are peer-supplied. `unsafe_replay_amounts` checks every operand first: restored coins, outputs (asset-lock credit outputs included) and recorded owned inputs, each alone and summed. It takes only the wallet and each transaction's txid, body and input details, so the SQLite replay can adopt it. A record it rejects is left out of the replay; the load never fails. No valid transaction carries such amounts, so a confirmed one is store corruption (logged as an error), and this path has no recovery mode. Such a record still takes part in conflict settlement on its inputs: - a stored settled winner sweeps it during planning; - otherwise the restored coins it spends stay reserved through spend claims naming it, and an unconfirmed one stays on record, unchecked, so a winner delivered after the load sweeps it and releases the coin the winner does not spend itself. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
The shared replay addresses eight of the eleven prior findings, including raw-record detachment, descendant exclusion, finality reconciliation, and complete-buffer decoding. Two blockers remain: cross-account duplication can bypass the amount preflight and overflow the checker, and independent competitors still cause quadratic conflict planning; swept-send rebroadcast and duplicate competitor counting also remain actionable. Validation was static only; the supplied head-matched CI snapshot had Rust workspace tests and several builds pending, wallet-specific tests skipped, and no native Swift build evidence.
🔴 2 blocking | 🟡 2 suggestion(s)
1 finding(s) not shown inline (the lines are not part of this PR's diff)
🟡 Suggestion: Do not rebroadcast unconfirmed sends that the history replay swept as losers
packages/rs-platform-wallet/src/manager/load.rs:381-386
History replay excludes defeated transactions and their descendants, but returns only a count, while this queue receives the original unconfirmed_outgoing_txs unchanged. The history_txids filter prevents duplicate accounting but does not distinguish surviving sends from swept ones. This is reachable through Swift's TXO-based selection: an unconfirmed loser spending A+B can remain selected through the unspent B row when a stored confirmed winner spends only A. Replay correctly removes the loser, yet resend_one checks only wallet-generation liveness before broadcasting it. The known loser is consequently handed back to SPV's rebroadcast timer on subsequent loads. Re-sending identical bytes does not authorize a new spend, but retrying transactions already defeated by restored finality is unnecessary and inconsistent with replay's settlement decision. Return swept txids from replay and filter the resend list, preserving eligible overlapping survivors; cover both the defeated parent and its descendants.
source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality)
2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The intricate replay_recorded_history implementation in packages/rs-platform-wallet/src/manager/history_replay.rs directly changes spend reservations, conflicting-transaction handling, and spendable UTXO reconstruction, affecting funds availability and coin selection. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (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 14% left, 5h 100% left),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— 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/src/manager/load.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/load.rs:381-386: Do not rebroadcast unconfirmed sends that the history replay swept as losers
History replay excludes defeated transactions and their descendants, but returns only a count, while this queue receives the original `unconfirmed_outgoing_txs` unchanged. The `history_txids` filter prevents duplicate accounting but does not distinguish surviving sends from swept ones. This is reachable through Swift's TXO-based selection: an unconfirmed loser spending A+B can remain selected through the unspent B row when a stored confirmed winner spends only A. Replay correctly removes the loser, yet `resend_one` checks only wallet-generation liveness before broadcasting it. The known loser is consequently handed back to SPV's rebroadcast timer on subsequent loads. Re-sending identical bytes does not authorize a new spend, but retrying transactions already defeated by restored finality is unnecessary and inconsistent with replay's settlement decision. Return swept txids from replay and filter the resend list, preserving eligible overlapping survivors; cover both the defeated parent and its descendants.
In `packages/rs-platform-wallet/src/manager/history_replay.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/history_replay.rs:467-475: Deduplicate transaction identities when counting live competitors
`unconfirmed_spends` counts account records rather than distinct spending transactions. The checker records a pooled or multi-account transaction in each affected account, but `is_contested` subtracts only one occurrence for the candidate itself. An InstantSend transaction represented in two accounts is therefore considered contested even when no other transaction spends its inputs. After replay, N independent multi-account InstantSend transactions trigger N additional whole-history sweeps that remove nothing, introducing quadratic work on legitimate restored history. Count each txid's inputs once before applying `is_contested`, and add a regression with a surviving InstantSend transaction represented in multiple accounts and no competing spender.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:325-333: Validate replay amounts before invoking the wallet checker
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4246956206)
The preflight bounds unique outpoints, but the checker aggregates account matches, and replay can temporarily duplicate an outpoint across accounts. Restore a coin worth `1_u64 << 62` in account A whose output script belongs to account B, and supply its funding transaction plus a child spending it to a 1-duff wallet output. Every individual value and preflight sum fits `i64`. Funding replay creates the coin in B without removing A's fallback; the child therefore matches the input in both accounts. In the pinned key-wallet revision, `ManagedAccountCollection::check_transaction` adds both matches into `total_sent`, yielding `1_u64 << 63`, and the wallet checker evaluates `1 - i64::MIN`. This panics with overflow checks or wraps without them. The `misplaced` cleanup occurs only after all checker calls. Normalize fallback duplicates before they can be spent during replay, or make the preflight bound actual cross-account checker operands. Add a two-account misplaced-projection regression.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:426-432: Avoid whole-history sweeps for every contested historical spender
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4246956215)
Removing confirmed records from the scratch map and updating competitor counts fixes the single competitor naming many historical inputs, but independent competitors still cause quadratic planning. With N confirmed winners, N separate unconfirmed competitors, and N unrelated unconfirmed bystanders, each winner remains contested until its own sweep. The pinned `drop_conflicted_transactions` scans the retained transaction map for direct losers and rebuilds its descendant index on every successful sweep, so the bystanders alone contribute N full scans. The second half of `should_plan_sweeps_only_while_a_competitor_remains` constructs this shape and permits 200 sweeps without bounding record visits. This is attacker-reachable: SPV's peer transaction handler forwards transactions to wallet relevance checking without validating their signatures or input spendability, and a matching wallet output produces a persisted record even when observed-spend guards prevent crediting it. The FFI store retains the losing rows, so the startup cost repeats across launches. Reuse an input-to-spenders index and a shared descendant worklist, or restrict each sweep to affected records while preserving finality precedence and cross-account descendants. Test total record visits for independent competitors and bystanders, not only sweep count.
Out-of-scope follow-up suggestions (2)
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.
- Consolidate backend-neutral replay while preserving transactional storage repair — Outside this PR's scope: SQLite's in-transaction replay is unchanged from the base, and the PR explicitly retains that implementation and defers consolidation. The confirmed-conflict evidence limitation is separately tracked in #5379; a broader persistence redesign is not an additional finding against this change.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
- Complete history restoration inputs on the remaining mobile paths — Outside this PR's scope: the PR explicitly documents Kotlin's null/zero history fields and the FFI decoder's empty owned-input details as deferred limitations. These are known follow-up areas, not newly undisclosed defects warranting expansion of this review.
- Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
`unconfirmed_spends` counted account records, and `is_contested` took one occurrence off for the candidate itself. A transaction the checker records in two accounts, such as a transfer between them, therefore looked contested by its own second record. Replayed with an InstantSend context and no competing spender, it triggered a conflict sweep over every live record that could remove nothing. The index now lists the distinct unconfirmed transactions spending each outpoint, and a record is contested only when a transaction other than itself is among them. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
The load projection can park a coin in a fallback account while its replayed funding credits it to the account that derives its address. The duplicate was dropped only after the whole replay, so a stored spend of the coin matched it in both accounts: - the fallback account recorded a spend of a coin it never owned, at any amount; - the checker sums the matches of all accounts before its signed subtraction, so two copies of a `1 << 62` coin overflowed it although every amount passed `unsafe_replay_amounts`, which counts an outpoint once. The fallback copy is now dropped right after the checker call that credits the coin elsewhere, so the amounts the checker sees are the ones the amount check bounded. The cleanup after the replay stays for a copy credited outside its own record's check. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4
Project asset-lock removals and DashPay retirement in the wallet owner, then apply exact effects in SQLite and synchronous FFI transactions. Swift marshals claim metadata and wallet-scoped row changes without choosing finality or claimant policy. Reentrant callbacks share one queue and storage scope; failures roll back before publication. Targeted Rust owner and adapter tests pass, including effect-write rollback and wallet-linked identities without a registration index. Scoped three-crate Clippy passes. Swift tests are written but cannot run on this Linux host; Apple compilation/runtime validation remains open. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Prepare outgoing sends and nonterminal tracked fundings before the shared authorization and finality checks. Retain the prepared identities until backends project their exact effects. Consumed proofs remain archival. Legacy raw persisters now fail load when a durable repair is required; only backend-owned replay outcomes can publish repaired history. Four new regressions went red first. All 40 selected owner, manager and adapter tests are covered by resolving runs, including tracked-only proof persistence and reopen. Updated legacy fixtures use actual checked proofs; the consumed-archive case preserves its rival. Scoped three-crate Clippy passes. No network transport is used by these tests. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
…sions Restore updated-record UTXO projection, contact-only exclusions and live late-funding attribution while keeping V020 accounting backfill pure. Preserve cross-account descendants of unsafe stored records and reject surviving mined records with unsafe operands after proof and conflict gates. Transfer original claim/size/migration regressions to their owning paths. Scoped tests and Clippy pass for the local fixes. Four enabled regressions still require the separately reviewed bulk-integrity and claim-attribution parents; this checkpoint does not include those integrations. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Adopt canonical history records while retaining validated legacy identity evidence in the immutable archive and preserving physical metadata anchors. Resolve historical prevouts for accounting without rewriting signed inputs, coins, claims or proof keys. Keep physical backend reads separate from the public alias projection and roll back incompatible confirmed contexts. Cover migration and redelivery with old-writer fixtures, including a real SIGHASH_ALL consumer, plus alias families, rollback and Recovery policy. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Exercise live SPV delivery before and after its signed transaction with an in-process transport, then persist and reopen through checked replay. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Pin spendable coin filtering, balance preservation, and durable hold effects for a legacy consumed asset lock with a stale materialized funding coin. Both regression tests currently fail on a27c681. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Merge the published #5781 feature parent beneath the historical identity alias follow-up. Keep the physical bulk decoder's identity validation and the separate alias projection without copying fixes between PR diffs. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex (GPT-6 Astra) <[email protected]>
Remove stale materialized inputs through the shared owner and project exact holds without promoting archived proofs or changing surviving provenance. Require atomic repair and reject adapters that leave the input spendable. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Keep raw restoration unauthenticated and leave positive upgrades to the shared checked replay tests. Record the unmerged compatibility dependency gate. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Preserve the failure result assertions while matching the raw snapshot's wallet, UTXO, transaction history, then asset-lock read sequence. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: Claude GPT-6 <[email protected]>
Merge wallet accounting, strict history decoding, and legacy alias prerequisites while preserving the Swift schema and accounting callback ABI. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex (GPT-6 Astra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e rust-dashcore bump The rust-dashcore update inherited from v5.1-dev makes the byte-array constructors and accessors inherent on the hash newtypes, so these `dashcore::hashes::Hash` imports fail clippy with -D warnings. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…n accounting rust-dashcore c9aeac0 (inherited from v5.1-dev) drops the `Hash` trait constructor on hash newtypes: build the P2PKH pubkey hash via `PubkeyHash::from_raw_hash(hash160::Hash::hash(..))` and remove the `dashcore::hashes::Hash` imports that are now unused. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Cross-repo note from the review of dashpay/rust-dashcore#1336 (which adds
Upstream follow-up idea, not blocking here: key-wallet could take authorized claims, e.g. a restore variant that receives the spending transaction and the funding 🤖 Co-authored by Claudius the Magnificent AI Agent |
Brings #5781 bulk history identity validation and #5783 AssetUnlock alias reconciliation into the replay branch. Updates the alias test fixture for V021: rewind its indexes with the schema, expect the current migration version, and advance highest_generated past the inserted address so pool generation terminates. Known failing at this commit (pre-existing on the local replay chain or exposed by the new alias tests); fixed in follow-up commits. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The base froze App Store schema 4.0.0 and this branch adds DashSchemaV5, so the hard-coded 4.0.0 in testPublishedV3MigratesOperationIdToNil went stale. Compare with the last schema of DashMigrationPlan instead. Co-Authored-By: OpenAI Codex <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]>
Break the broadcaster/runtime/event-handler cycle with weak references from balance and spend handlers to the manager-owned wallet map. Keep that owner alive in event-handler fixtures. Legacy and adopted history can reopen the same SQLite store after shutdown and drop. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex <[email protected]>
Share actual survivor selection between live SQLite sweeps and replay, including held inputs awaiting funding. Do not borrow a winner's mining height for another spender's claim. Merge legacy fallback metadata into an existing canonical replay record instead of duplicating its body. Validate V021 row indexes against authoritative blobs before discarding mined history: Recovery preserves an inconsistent wallet graph and Strict rejects it. An unmined body mislabeled mined cannot be recovered by rescan. Keep the accounting rollback regression and extend the claimant regression to missing funding. Open-time version assertions follow the full runner. All three full suites, scoped all-target/all-feature clippy and fmt pass. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex <[email protected]>
Preserve old migration assertions through V020, then assert V021's intentional mined-history rescan and coverage rewind. Use a valid encoded asset-lock lifecycle because V021 inspects finality evidence. Refresh schema fingerprints for the approved unmerged additive-V020 / rescan-V021 split and its two indexes; V001-V019 are unchanged. Bound empty-wallet loads to 21 statements for intentional claim/prevout reads, retaining constant-scaling checks. Assert external writer exclusion during IMMEDIATE replay and a fresh snapshot after the writer can commit. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex <[email protected]>
…ements Separate advisory proof discovery from the cursor tests' authoritative snapshot sequence, retaining their original publication-lock assertions. Wire all atomic replay callbacks in the reconciliation capability fixture and verify each missing callback removes the capability. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> Co-Authored-By: OpenAI Codex <[email protected]>
Merge d8bbf50, preserving shared replay, atomic persistence, durable claims, V021 and the wallet lifetime and recovery head fixes. Retain the parent's alias reconciliation, asset-lock input cap and Swift schema freeze. Resolve persistence imports with strict decoding, retain bounded SQLite blob reads and alias fixtures, and keep TransactionRecord for replay's detach/reattach helpers. Remove obsolete Hash imports for the new hash API. Migration numbering and schema fingerprints remain unchanged. Pin all rust-dashcore dependencies to 6f206d482d38def57b866f9dae1a6e98f84d25a9 from PR #1336, containing c9aeac0 and wallet-restore authorization. Update only its twelve lockfile packages; preserve unrelated dependency entries. Validation: formatting passes. Workspace check identifies the new explicit network argument to matches_transaction; adapt it in a focused follow-up. Co-Authored-By: OpenAI Codex <[email protected]>
Bind verified InstantLock evidence to the restoring wallet's network via the explicit matches_transaction argument. Select fixture networks explicitly, including Mainnet evidence in the cross-network rejection test. Adapt the in-process test transport to NetworkManager's shared-reference API. Await the invalid-lock progress counter instead of the removed validated:false event; retain both delivery orders, durable-state and restart assertions, and also reject verified proof events for invalid locks. Install only the valid fixture's random-key quorum cycle. Co-Authored-By: OpenAI Codex <[email protected]>
Live V5 carries both unreleased additions: PersistentTransactionAccounting from the parent and PersistentCoreInstantLock from this branch. Expect both in the V4 → V5 entity diff, list the InstantLock model in schema-models.json, and document it in SCHEMA_RELEASES.md. Co-Authored-By: OpenAI Codex <[email protected]> Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01WunYYijwZNbw3EjHtdMAHC
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
At ecc4921, shared replay, authorization checks, verified finality and atomic storage repair address most prior findings, but nine blocking issues remain in restore integrity, claim lifecycle, accounting persistence, legacy recovery, resource bounds and layer ownership. Current-head CI passes Kotlin build/tests but fails Storage Explorer coverage because the new InstantLock model is missing from three views; the review check remains pending. Verification was static, including inspection of the pinned and default upstream sources and the failed CI log; no builds or tests were run.
🔴 9 blocking
4 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: platform-versioning); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 8: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The large, intricate diff changes spend-state reconstruction in rs-platform-wallet/src/manager/history_replay.rs and persistent-state migrations in rs-platform-wallet-storage/src/sqlite/migrations/v021.rs, directly affecting whether spent funds become available again and how stored wallet data is restored. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-3.8-flash-high(antigravity quota lookup failed),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— platform-versioning (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— 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-ffi/src/persistence/replay.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence/replay.rs:347-351: Retire restored block-spend claims when their block is reorged out
The adapter discards StoredReplayClaim.mined_height and restores every hold through the lifetime-free restore_spent_outpoints API. Swift's loadReplayClaims exports ordinary pending-input/block-spend holds, and SQLite's load_one_wallet performs the same conversion for spent rows. In the pinned key-wallet, truncate_above removes reversible observed spends but does not retire spent_claims. Consequently, restoring a plain-block spend at height 101, truncating to 100 and redelivering its now-unspent funding can leave the output rejected by the restored claim. Narrowing irreversible_spend_claims fixed the direct replay helper, but this subsequent adapter restoration bypasses that rule. Interpret claim lifetime in the shared wallet owner and restore reversible evidence through a lifecycle-aware mechanism; cover the full backend load followed by truncation and funding redelivery.
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence/replay.rs:327-352: Move replay repair-and-reread policy out of the FFI adapter
replay_in_scope does more than transport callbacks and apply owner-computed effects: it chooses whether to repair and rerun replay, interprets which remaining domain outcomes invalidate restoration, filters resendable transactions and reconstructs wallet claims. replay_wallet_history_blocking does not own this complete acceptance/restoration contract, so SQLite independently reproduces it in load_one_wallet. This violates the book's 'FFI and mobile SDKs hold no business logic' rule and the explicit wrapper rule against assembling replay/state-reconstruction policy. Move the complete repair/reread/validation/claim-restoration operation into rs-platform-wallet behind a storage-adapter interface. Keep transaction-scope plumbing, C conversions, buffer ownership and application of typed storage effects in the FFI adapter.
In `packages/rs-platform-wallet/src/manager/load.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/load.rs:837-841: Do not let accounting backfill overwrite a newer live accounting row
Backfill selects unaccounted transactions and computes rows under a manager read lock, but releases that lock before this unconditional persistence round. The wallets are already published and SPV can update them. An address-pool extension or late attribution can therefore persist a more complete accounting row after computation but before the backfill write. FFIPersister::store_history_accounting uses the ordinary live accounting callback, and Swift's upsertTransactionAccounting overwrites an existing row without rechecking that it remains unaccounted. The stale backfill can replace the newer amount/direction and commit the completion marker, preserving the incorrect display across loads. Give backfill insert-if-still-unaccounted or version-checked semantics, distinct from live upserts, or fence computation through commit against live accounting updates. Add coverage for a live update committed before the stale backfill round.
In `packages/rs-platform-wallet/src/manager/history_replay.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:466-467: Provide recovery for legacy InstantSend observations without lock bytes
The required-evidence gate prevents the old silent finality downgrade, but an ordinary pre-upgrade InstantSend row without a sidecar or tracked proof has no recovery through the advertised synchronization/retry flow. The V4-to-V5 lightweight migration creates an empty PersistentCoreInstantLock table; replay_lock_candidates enumerates actual lock blobs rather than required txids, and verify_history_locks only verifies those candidates. The pinned DashSpvClient::verify_instant_lock explicitly performs no network request. Unless other stored evidence independently resolves the row, repeated loads therefore return MissingFinalityEvidence, and the wallet is never registered for normal catch-up to update its context. Distinguish missing historical proof material from a verifier that is merely not ready, and provide an explicit spend-safe recovery mode or proof-retrieval path in the Rust load owner. Cover migration and retry of a status-only legacy row without injecting proof directly.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:1100-1105: Allow valid stored-amount records to settle conflicts
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4389526541)
This filter excludes UnsafeAmounts::Stored records from winning conflicts even though that classification explicitly permits a valid transaction with inconsistent auxiliary amounts. A verified InstantSend spend A with an overflowing owned-input annotation can therefore fail to remove an authorized mempool competitor B and B's persisted change. A has no block_info(), so UnsafeMinedAmounts does not reject it; partitioning puts it in set_aside, and the closing sweep iterates only replay. The earlier mark_instant_send_utxos call cannot settle this case because matching records have already been detached, and upstream needs A's record to find its inputs. Resolve verified finality through topology-only settlement independently of arithmetic eligibility, or reject this snapshot explicitly before publication. Extend the unsafe-finality regression to assert removal of the mempool competitor and its outputs, not merely survival of A.
- [BLOCKING] packages/rs-platform-wallet/src/manager/history_replay.rs:907-913: Keep overlapping unsafe-record reservations releasable
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4249695152)
Two surviving Stored-only claimants still produce None, which the pinned key-wallet's release_spent_claims never releases. For A spending X/Y and B spending X/Z, a confirmed winner spending Y/Z can remove both records while the genuinely unspent X remains unavailable for the rest of the session. The current regression explicitly asserts this hold even after both records are swept. The Outputs/Stored distinction correctly prevents premature release, and rejecting the entire wallet is not required, but it does not fix the newly added reservation path's loss of availability. Add the owner-side multi-claimant lifecycle in key-wallet, or an equivalent shared-owner mechanism that retains X while any claimant survives and releases it after the last claimant disappears. The linked upstream limitation explains the missing capability; it does not make this introduced defect a safe deferral.
In `packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift:98-99: Add the InstantLock model to the required Storage Explorer views
PersistentCoreInstantLock is registered here but is absent from StorageExplorerView.swift, StorageModelListViews.swift and StorageRecordDetailViews.swift. The exact-head 'Verify explorer covers all SwiftData models' job fails for these three omissions: its log reports each missing reference and ends with 'FAILED: 3 model type(s) missing from Storage Explorer views' and exit code 1. The source and check script confirm the failure is caused by this schema addition. Add the model's explorer navigation/count, list and detail coverage, following the Transaction Accounting registration pattern.
In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:6095-6104: Do not turn unreadable confirmed history into missing spend evidence
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4389526583)
Malformed or identity-mismatched transaction bodies are counted and discarded while restoration succeeds. Swift's buildRecordedTransactionBuffer likewise skips rows missing bodies or confirmed block context. Independent claim loading does not make this safe: the supported no-replay-callback FFI route has no equivalent-claim requirement, and even a restored input hold cannot replace the missing confirmed transaction's conflict topology. If that row was the winner over a signed mempool competitor, replay can retain the competitor's persisted change because it never sees the winner. Return a typed corrupt/incomplete-snapshot result before publication, or carry explicit incomplete-history information into the shared wallet owner's recovery policy; do not represent unreadable evidence as absent history.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:7464-7475: Bound the attacker-amplifiable full-history replay during wallet load
(existing thread: https://github.com/dashpay/platform/pull/5220#discussion_r4265416171)
Startup still materializes the complete transaction table and prefetched relationships, filters them for each wallet, and copies selected bodies across FFI without an aggregate count/byte budget. Candidate discovery, initial snapshot loading and authoritative replay repeat history reads, while Rust clones the supplied history and a repair can require another replay. Valid payments to monitored addresses can grow this workload without the user's participation, eventually exhausting memory or monopolizing startup on every launch. The indexed planner, atomic transaction scope and per-claim buffer limit do not bound this initial allocation. Your objection to fetchLimit is correct: truncating history would discard spend evidence. Establish an early resource preflight with bounded allocations on both sides of FFI and an actionable recovery outcome, or bounded resumable replay backed by durable spend guards/checkpoints.
Out-of-scope follow-up suggestions (2)
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.
- Complete owner-side checked accounting for live and late-funding paths — Both the pinned and default key-wallet sources still contain unchecked signed accounting outside this PR's guarded replay. This pre-existing live-path exposure remains important, but the requested ownership follow-up is already tracked in dashpay/rust-dashcore#1213.
- Follow-up: Implement the checked, atomic accounting and topology-preserving restoration API in dashpay/rust-dashcore#1213, then replace downstream defensive sanitization with that owner API.
- Close the deferred owned-input finalization window — The recorded-history ABI still omits owned-input details. A plain-block spend with missing funding can lose provisional protection when its observed evidence is pruned at finality. This window predates the PR, is narrowed by replay and has an explicit tracked deferral in #5412.
- Follow-up: Resolve #5412 in a follow-up PR by transporting owned-input details or promoting provisional evidence at finality while retiring it on reorg.
| wallet.wallet_info.restore_spent_outpoints( | ||
| &claims | ||
| .iter() | ||
| .map(|claim| (claim.outpoint, claim.claimant)) | ||
| .collect::<Vec<_>>(), |
There was a problem hiding this comment.
🔴 Blocking: Retire restored block-spend claims when their block is reorged out
The adapter discards StoredReplayClaim.mined_height and restores every hold through the lifetime-free restore_spent_outpoints API. Swift's loadReplayClaims exports ordinary pending-input/block-spend holds, and SQLite's load_one_wallet performs the same conversion for spent rows. In the pinned key-wallet, truncate_above removes reversible observed spends but does not retire spent_claims. Consequently, restoring a plain-block spend at height 101, truncating to 100 and redelivering its now-unspent funding can leave the output rejected by the restored claim. Narrowing irreversible_spend_claims fixed the direct replay helper, but this subsequent adapter restoration bypasses that rule. Interpret claim lifetime in the shared wallet owner and restore reversible evidence through a lifecycle-aware mechanism; cover the full backend load followed by truncation and funding redelivery.
source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)
| let rows = accounted.rows; | ||
| let persister = Arc::clone(&self.persister); | ||
| let count = rows.len(); | ||
| if let Err(e) = run_blocking_load(move || { | ||
| persister.store_history_accounting(wallet_id, &rows, complete) |
There was a problem hiding this comment.
🔴 Blocking: Do not let accounting backfill overwrite a newer live accounting row
Backfill selects unaccounted transactions and computes rows under a manager read lock, but releases that lock before this unconditional persistence round. The wallets are already published and SPV can update them. An address-pool extension or late attribution can therefore persist a more complete accounting row after computation but before the backfill write. FFIPersister::store_history_accounting uses the ordinary live accounting callback, and Swift's upsertTransactionAccounting overwrites an existing row without rechecking that it remains unaccounted. The stale backfill can replace the newer amount/direction and commit the completion marker, preserving the incorrect display across loads. Give backfill insert-if-still-unaccounted or version-checked semantics, distinct from live upserts, or fence computation through commit against live accounting updates. Add coverage for a live update committed before the stale backfill round.
source: gpt-6.1-sol (phase2-reviewer: general)
| if !deferred.is_empty() { | ||
| return Err(HistoryReplayError::MissingFinalityEvidence { txids: deferred }); |
There was a problem hiding this comment.
🔴 Blocking: Provide recovery for legacy InstantSend observations without lock bytes
The required-evidence gate prevents the old silent finality downgrade, but an ordinary pre-upgrade InstantSend row without a sidecar or tracked proof has no recovery through the advertised synchronization/retry flow. The V4-to-V5 lightweight migration creates an empty PersistentCoreInstantLock table; replay_lock_candidates enumerates actual lock blobs rather than required txids, and verify_history_locks only verifies those candidates. The pinned DashSpvClient::verify_instant_lock explicitly performs no network request. Unless other stored evidence independently resolves the row, repeated loads therefore return MissingFinalityEvidence, and the wallet is never registered for normal catch-up to update its context. Distinguish missing historical proof material from a verifier that is merely not ready, and provide an explicit spend-safe recovery mode or proof-retrieval path in the Rust load owner. Cover migration and retry of a status-only legacy row without injecting proof directly.
source: gpt-6.1-sol (phase2-reviewer: rust-quality)
| state = self.load_snapshot_unlocked(Some(wallet_id))?; | ||
| let wallet = state | ||
| .wallets | ||
| .get_mut(&wallet_id) | ||
| .ok_or_else(|| PersistenceError::backend(ReplayAdapterError::MissingWallet))?; | ||
| claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; | ||
| let remaining = replay_wallet_history_blocking(wallet, evidence) | ||
| .map_err(PersistenceError::backend)?; | ||
| if !remaining.sweeps.is_empty() | ||
| || !remaining.authorization.rejected.is_empty() | ||
| || !remaining.withheld_asset_lock_inputs.is_empty() | ||
| { | ||
| return Err(PersistenceError::backend( | ||
| HistoryReplayError::UnsettledAfterReplay, | ||
| )); | ||
| } | ||
| wallet.unconfirmed_outgoing_txs = | ||
| outcome.resendable(std::mem::take(&mut wallet.unconfirmed_outgoing_txs)); | ||
| } | ||
| if let Some(wallet) = state.wallets.get_mut(&wallet_id) { | ||
| wallet.wallet_info.restore_spent_outpoints( | ||
| &claims | ||
| .iter() | ||
| .map(|claim| (claim.outpoint, claim.claimant)) | ||
| .collect::<Vec<_>>(), | ||
| ); |
There was a problem hiding this comment.
🔴 Blocking: Move replay repair-and-reread policy out of the FFI adapter
replay_in_scope does more than transport callbacks and apply owner-computed effects: it chooses whether to repair and rerun replay, interprets which remaining domain outcomes invalidate restoration, filters resendable transactions and reconstructs wallet claims. replay_wallet_history_blocking does not own this complete acceptance/restoration contract, so SQLite independently reproduces it in load_one_wallet. This violates the book's 'FFI and mobile SDKs hold no business logic' rule and the explicit wrapper rule against assembling replay/state-reconstruction policy. Move the complete repair/reread/validation/claim-restoration operation into rs-platform-wallet behind a storage-adapter interface. Keep transaction-scope plumbing, C conversions, buffer ownership and application of typed storage effects in the FFI adapter.
source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, platform-versioning, rust-quality, security-auditor)
| PersistentTransactionAccounting.self, | ||
| PersistentCoreInstantLock.self |
There was a problem hiding this comment.
🔴 Blocking: Add the InstantLock model to the required Storage Explorer views
PersistentCoreInstantLock is registered here but is absent from StorageExplorerView.swift, StorageModelListViews.swift and StorageRecordDetailViews.swift. The exact-head 'Verify explorer covers all SwiftData models' job fails for these three omissions: its log reports each missing reference and ends with 'FAILED: 3 model type(s) missing from Storage Explorer views' and exit code 1. The source and check script confirm the failure is caused by this schema addition. Add the model's explorer navigation/count, list and detail coverage, following the Transaction Accounting registration pattern.
source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: architecture-layering)
|
Merge-order note: #5762 lands in When this branch next merges its base:
|
Restores stored Core transaction history through the shared wallet load path so that rediscovered funding cannot make already-spent outputs available again.
Issue being fixed or feature implemented
Stacked on #5150 (
fix/pr-5126). Addresses the #5150 review thread "Confirmed-history replay is implemented only for SQLite".After a restart the wallet must know which outputs its confirmed transactions already spent (
spent_outpoints/observed_spent_outpoints). Without that, a redelivered funding transaction (rescan, gap-limit rediscovery) credits an already-spent output again. #5150 rebuilt this state only inside the SQLite persister. The FFI persister (iOS / SwiftData) replayed only unconfirmed outgoing sends, so confirmed spends were never replayed on that path.What was done?
Where the replay runs
rs-platform-wallet(manager/history_replay.rs,replay_recorded_history).load_from_persistorruns it, before the balance is mirrored into the wallet, for every persister that hands its stored records back throughClientWalletStartState::recorded_history(RecordedHistory { transactions, instant_locks }, types inchangeset/recorded_history.rs). Today that is the FFI persister (iOS/SwiftData). It runs inside the async load and awaits the checker.load()transaction, exactly as on fix(platform-wallet): correct wallet-level transaction history accounting #5150. It returns an emptyrecorded_history, so nothing is replayed twice, and declaresCORE_HISTORY_RESTORE.sqlite/rehydrate.rsis identical to the base.#[repr(C)] RecordedTransactionRestoreFFI, appended toWalletRestoreEntryFFIasrecorded_transactions/recorded_transactions_count.net_amount/direction. They are unused here, reserved for the follow-up that makes Rust the single source of transaction accounting.loadWalletListfetches the stored history once and hands each restorable wallet its own records. The fetch prefetches the relationships used to determine wallet ownership. A fetch error rejects the snapshot rather than claiming the wallet has no history.CORE_HISTORY_RESTORE(bit 14; bit 13 belongs to asset-lock operation IDs). SQLite and Swift declare it; the FFI attests it only when the wallet-list load callbacks are wired. It gates nothing:load_from_persistorlogs a warning when a persister restores wallets without it.What the replay does
StoredTransaction::owned_inputs) are staged before the replay, so a spend whose funding survives only as a height row rebuilds its spent mark.irreversible_spend_claims), so a funding delivered later is not credited again. A spend in a plain block or in the mempool is not claimed, because nothing retires a claim when a fork or an eviction drops its claimant.replay_recorded_historyreturnsHistoryReplay { replayed, retired, defeated }.retiredholds every stored transaction the replay removed or left out;defeatedholds only those a stored conflict winner beat, with their descendants. An unconfirmed send the replay retired is dropped from the list re-dispatched at load (HistoryReplay::resendable).Amounts the checker cannot take
unsafe_replay_amountschecks its operands: restored coins, outputs including asset-lock credit outputs, and recorded owned inputs, each alone and summed. A failing record is left out of the replay and the load continues. No valid transaction carries such amounts, so a confirmed one is store corruption and is logged as an error. The record still takes part in conflict settlement: a stored final winner removes it during planning, and one delivered after the load removes it and releases the input the winner does not spend.UnsafeAmounts).Outputsmeans the recorded transaction bodies prove the overflow on their own, so the transaction can never confirm.Storedmeans the overflow depends on a value stored beside the transaction (a restored coin or a recorded owned input), so the transaction itself may be valid.reservation_claimant). AmongOutputsrecords the smallest txid stands for all. A singleStoredrecord is named, so the coin stays guarded while that record stands. TwoStoredrecords leave no claimant whose removal proves the coin free.Tracked asset locks
recorded_input_conflicts), so that knowledge survives chain-lock finalization. The pending locks' inputs are indexed by outpoint once.InstantSendLocked,ChainLocked,ConsumedorRecoveredFromChain(final_lock_input_claims). A host may drop those inputs from the projection on the lock's status alone while the funding's own record guards nothing.add_tracked_instant_locks), so a funding stored as unconfirmed replays as InstantSend-locked: it sweeps an unconfirmed competitor and stands beside one in a plain block. The lock is taken only when it, the proof's transaction and the tracked funding name one transaction the history holds.ChainLocked,Consumed,RecoveredFromChain) proves a height and no block. Its funding's unconfirmed record is kept out of the replay (withhold_chain_final_fundings) unless a lock in the history settles it: its coins stay as stored and its inputs stay claimed. A history lock settles it only if the replay accepts that lock for the record (lock_matches_record); a lock filed under the funding that names another transaction does not. A record of that funding already attached to the restored wallet sits the replay out as well: the load detaches it before the replay and puts it back afterwards (detach_unconfirmed_records,reattach_records). A host attaches one when a sibling credit output of the same funding transaction is still atBroadcast.Known limitations:
TODO(ffi-recorded-input-details)) or provisional evidence promoted at finality; tracked in platform-wallet: a spent coin can return as spendable within a session on FFI hosts (recorded history carries no owned inputs) #5412.StoredTransaction::owned_inputs: a store that drops the coin of an unconfirmed spend from the load projection must record that spend's owned inputs. The replay guards an unconfirmed spend through a restored coin or its recorded owned-input details.Storedrecords stays claimed until the next load, even once both records are swept.Outputsrecords is held for the one with the smaller txid. Sweeping that record alone drops the guard while the other still names the coin; neither record can ever confirm, so no funds are at risk.i64::MAXstays in the projection and the balance; only records spending it are left out.TODO(bound-load-history-replay): every stored record is replayed on each load.TODO(expire-unconfirmed-spend-reservations): stored mempool records, incoming ones included on the FFI path, are replayed with no expiry.Out of scope:
key-wallet/dash-spv; only the load replay and the wallet's own re-dispatch are covered here.validate_replay_amounts); the shared replay serves record-returning persisters.TODO(android-core-history-restore)).How Has This Been Tested?
Validation after merging
fix/pr-5126atfa110a12cda7350ab3b0da36f67a84a50beb881f:platform-wallet,platform-wallet-ffiandplatform-wallet-storage: 165 passed, including history replay, capabilities, transaction records, snapshot loading and completed-drain funding protection.--all-targets --all-features --locked -- --no-deps -D warnings: clean. Formatting and diff checks passed.should_reserve_absent_inputs_of_unsafe_stored_record_until_sweptfailed before the fix and covers absent funding, redelivery protection and release after confirmed conflict.What the main tests cover:
should_sweep_conflicting_spend_for_either_sibling_replay_order(block, chain-lock and persisted-chain-lock winners),should_drop_attributable_descendant_of_swept_conflict_for_any_replay_order,should_remove_cross_account_descendant_of_conflicting_spend, andshould_plan_the_same_removals_as_whole_history_sweeps, which compares the planner with whole-history sweeps on 500 generated histories.should_rebuild_spent_mark_for_height_only_funding_from_owned_inputs,should_guard_unreplayed_funding_against_irreversible_spends_only,should_credit_funding_again_after_a_fork_drops_the_restored_spend,should_keep_unsafe_amounts_from_the_checker_and_reserve_the_coins_they_spend,should_hold_shared_coin_while_a_record_that_may_confirm_spends_it,should_release_coin_shared_by_spends_of_impossible_recorded_amounts.should_settle_stored_conflicts_by_what_a_tracked_lock_provesasserts the rule as a table: lock evidence (Instant proof, chain-final status) against competitor (unconfirmed, plain block, chain-locked), checking the funding's change, the competitor's change and whether the funding's other input stays guarded; two more rows cover a history lock that names another transaction, and a funding with two credit outputs at mixed statuses whose record the host attached. Around it:should_not_credit_inputs_of_a_final_asset_lock_again,should_not_claim_inputs_of_a_tracked_funding_the_replay_defeated,should_keep_the_claim_of_a_final_lock_whose_funding_was_only_left_out,should_remember_stored_spender_of_pending_asset_lock_input_on_load.should_settle_an_attached_unconfirmed_record_the_history_does_not_holdpins what the replay does with an attached unconfirmed record, which is why the load detaches it;should_detach_unconfirmed_records_of_withheld_fundings_and_put_them_backcovers the detaching.Also covered: normal and overlapping FFI restore, malformed and trailing transaction bytes, proof-record and metadata retention, funding redelivery, and re-dispatch of retired sends. Each regression test was seen failing before its fix.
Not run:
swiftnorxcodebuild. The Swift changes were read, not run.Breaking Changes
WalletRestoreEntryFFIgains two trailing fields (recorded_transactions,recorded_transactions_count), plus the new element structRecordedTransactionRestoreFFI. The entry has no size field, so the host and the Rust library must be built together (same contract asunconfirmed_outgoing_tx_records).ClientWalletStartStategains the public fieldrecorded_history. Struct-literal constructors must set it;Default::default()gives the previous behaviour.CORE_HISTORY_RESTORE(bit 14; bit 13 belongs to asset-lock operation IDs) is additive.manager::history_replay(additive):replay_recorded_history,HistoryReplay,unsafe_replay_amounts,UnsafeAmountsandReplayOperands.Checklist:
structure.rs, regeneratedgrovedb-structure.json, and checked the structure viewer link posted on this pull requestFor repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent