Skip to content

fix(platform-wallet)!: replay recorded transaction history on load for every persister - #5220

Open
lklimek wants to merge 8 commits into
fix/pr-5126from
fix/wallet-load-history-replay
Open

lklimek wants to merge 8 commits into
fix/pr-5126from
fix/wallet-load-history-replay

Conversation

@lklimek

@lklimek lklimek commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

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?

  • Shared replay. The history replay moved from platform-wallet-storage (sqlite/rehydrate.rs) into rs-platform-wallet (manager/history_replay.rs). load_from_persistor runs it for every persister before the balance is mirrored into the wallet. Persisters only supply stored records through the new ClientWalletStartState::recorded_history (RecordedHistory { transactions, instant_locks }, types in changeset/recorded_history.rs).
  • SQLite hands its stored records and InstantSend locks to the shared replay instead of replaying them itself. Behaviour is unchanged.
  • FFI.
    • New #[repr(C)] RecordedTransactionRestoreFFI, appended to WalletRestoreEntryFFI as recorded_transactions / recorded_transactions_count.
    • Decoding fails closed per row: bytes that don't decode completely, don't hash to the row's txid, or carry an unknown context are dropped, and the drops are counted in a warning. Both recorded-history and unconfirmed-outgoing decoders reject trailing bytes.
  • Swift. loadWalletList fetches the stored history once and hands each restorable wallet its own records. The history fetch prefetches the relationships used to determine wallet ownership. A fetch error rejects the snapshot rather than claiming the wallet has no history.
  • Capability. New CORE_HISTORY_RESTORE (bit 13). SQLite and Swift declare it; the FFI attests it only when the wallet-list load callbacks are wired. It gates nothing: load_from_persistor logs a warning when a persister restores wallets without it.
  • Replay and retention. A send listed in both the unconfirmed outgoing list and the history is accounted once and still rebroadcast. Raw-restored asset-lock and provider records are temporarily detached so their presence cannot suppress the checker’s spend reconstruction. Replay preserves labels, available fees, and records needed for proof lookup, while final conflicting spends remove losing records.
  • Async replay. The replay now runs inside the async load_from_persistor and awaits the checker, so the first-poll shim (poll_ready) and its rollback were removed.
  • The FFI restore entry already carries the stored net_amount / direction. They are unused here, reserved for the follow-up that makes Rust the single source of transaction accounting (replacing the Swift re-derivation).
  • Ported fix(platform-wallet)!: restore Core spending state and repair persisted accounting #5150 replay fixes. Merging fix/pr-5126 brought in its two restart-replay fixes. Both are ported into the shared history_replay:
    • Owned inputs recorded on stored transactions (StoredTransaction::owned_inputs) are staged before replay. A spend whose funding survives only as a height row therefore rebuilds its spent mark.
    • InstantSend conflict sweeps re-run after the full replay, so the result does not depend on sibling order.
    • Staging exposed a gap in the raw-record fallback, so held unconfirmed descendants of swept transactions are now dropped transitively.

Known limitations:

  • InstantSend rows replay as mempool on the FFI path, because the host stores no IS-lock bytes. The spend is still reserved; the upgrade to InstantSend happens once the lock is seen again.
  • TODO(android-core-history-restore): the Kotlin host does not supply history yet. Its JNI fields are null/0, and Android does not attest the capability.
  • TODO(bound-load-history-replay) and TODO(expire-unconfirmed-spend-reservations) moved with the replay. The latter now also covers stored incoming mempool records on the FFI path.
  • TODO(ffi-recorded-input-details): the FFI path supplies no owned inputs yet. On SwiftData, the spent-mark guard for height-only funding is rebuilt only once the host provides per-input ownership.

How Has This Been Tested?

  • After the fix/pr-5126 merge, nextest for -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi passed 2660 tests, and Clippy --all-targets --all-features -D warnings is clean for those crates. The ported fixes are covered by should_sweep_conflicting_spend_for_either_sibling_replay_order, should_rebuild_spent_mark_for_height_only_funding_from_owned_inputs, should_drop_orphaned_raw_descendant_of_swept_conflict_in_either_order, and the two height-only-funding storage tests. Each fails with its fix disabled.
  • Earlier: 127 targeted tests passed: 89 wallet history/restore tests, 25 FFI tests, 2 manager-load tests, and all 11 SQLite spent-rehydration integration tests.
  • Regression tests reproduce the raw-record overlap and trailing-byte bugs before their fixes, then pass afterward.
  • Coverage includes confirmed and mempool spend guards, normal and overlapping FFI restore, proof-record and metadata retention, final-conflict removal including descendants, malformed transaction bytes, and funding redelivery.
  • Scoped Rust formatting passed. Clippy passed for platform-wallet and platform-wallet-ffi with --all-targets --all-features --locked -- --no-deps -D warnings.
  • Not run: native Swift build and tests; this environment has neither swift nor xcodebuild.

Breaking Changes

  • WalletRestoreEntryFFI gains two trailing fields (recorded_transactions, recorded_transactions_count), plus the new element struct RecordedTransactionRestoreFFI. The entry has no size field, so the host and the Rust library must be built together (same contract as unconfirmed_outgoing_tx_records).
  • ClientWalletStartState gains the public field recorded_history. Struct-literal constructors must set it; Default::default() gives the previous behaviour.
  • SqlitePersister::load() no longer returns a replayed projection. Callers that bypass PlatformWalletManager::load_from_persistor must call platform_wallet::manager::history_replay::replay_recorded_history themselves.
  • CORE_HISTORY_RESTORE (bit 13) is additive.

This PR conflicts with #5210 in sqlite/rehydrate.rs (the replay region moves out) and slightly in persister.rs::load_one_wallet. Resolve by keeping the move and porting #5210's replay changes into manager/history_replay.rs.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 5 commits September 30, 2026 11:33
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: dashpay/platform/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 58630595-3512-4561-b7bf-3a77fdb4c331

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

❤️ Share

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

@thepastaclaw

thepastaclaw commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

⛔ Final review complete — 3 blocking finding(s) (commit 04ed0f6) · triage: critical

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — architecture-layering (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — ffi-engineer (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — security-auditor (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-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.

Comment thread packages/rs-platform-wallet/src/manager/history_replay.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/history_replay.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
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 <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-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.

Comment on lines +168 to +170
// Finalize replayed records before a sync checkpoint can prune their spend guards.
if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() {
wallet_info.apply_chain_lock(chain_lock);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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.

source: gpt-6.1-sol (phase2-reviewer: general)

Comment on lines +162 to +166
if restored_fallback {
// Unattributable raw records were absent from the checker's conflict sweeps.
for (transaction, context) in final_transactions {
wallet_info.sweep_conflicts(&transaction, &context);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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.

source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality, security-auditor)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LN7YPS4eaKdQv5XFUBPLH

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-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.

Comment on lines +157 to +163
drop_swept_descendants(&mut swept, &held, &instant_locks);
let mut restored_fallback = false;
for mut account in wallet_info.accounts.all_accounts_mut() {
let owner = account.managed_account_type().to_account_type();
for original in held.remove(&owner).into_iter().flatten() {
if swept.contains(&original.txid) {
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: 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.

source: gpt-6.1-sol (phase2-reviewer: general)

Comment on lines +202 to +226
let followable: Vec<&TransactionRecord> = held
.values()
.flatten()
.filter(|record| {
matches!(record.context, TransactionContext::Mempool)
&& !instant_locks.contains_key(&record.txid)
})
.collect();
loop {
let before = swept.len();
for record in &followable {
if !swept.contains(&record.txid)
&& record
.transaction
.input
.iter()
.any(|input| swept.contains(&input.previous_output.txid))
{
swept.insert(record.txid);
}
}
if swept.len() == before {
return;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: 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.

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants