Skip to content

fix(platform-wallet): fall back to persister for chainlocked asset-lock tx records#3619

Merged
QuantumExplorer merged 6 commits intov3.1-devfrom
claude/asset-lock-proof-persister-fallback
May 7, 2026
Merged

fix(platform-wallet): fall back to persister for chainlocked asset-lock tx records#3619
QuantumExplorer merged 6 commits intov3.1-devfrom
claude/asset-lock-proof-persister-fallback

Conversation

@QuantumExplorer
Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Follow-up to #3617. CodeRabbit's review on the rust-dashcore bump flagged a real architectural concern that I scoped out of that PR (thread): five asset-lock proof lookup sites in wallet/asset_lock/sync/{proof,recovery}.rs resolve a transaction record from the in-memory transactions() map keyed by (account_index, txid). With upstream's keep-finalized-transactions Cargo feature OFF (the default), chainlocked records are evicted from that map and only their txids retained in finalized_txids for dedup. The chain-lock height / block hash that an asset-lock proof needs is no longer reachable through the wallet-info API.

The narrow failure mode is "first lookup happens after the chainlock-eviction window." wait_for_proof polls and normally observes InstantSend / InBlock state while the record is still present, but a tx that's already chain-locked at the moment the proof flow starts (e.g. crash recovery resuming an asset lock that chain-locked while we were down) hits the missing-record path.

What was done?

Added a persister fallback. The persister received the full record on the chainlock-transition store call before eviction, so it can answer the lookup.

Trait surface

  • New PlatformWalletPersistence::get_core_tx_record(wallet_id, txid) -> Result<Option<TransactionRecord>, PersistenceError> in traits.rs with a default Ok(None) impl. Backward-compatible: persisters that don't index records by txid (the in-tree NoPlatformPersistence, no-op test stubs in the wider tree) need no change. Persisters whose backing store keys records by txid (SqliteWalletPersister in evo-tool, the SwiftData iOS persister) should override; until then those callers see the same behavior as before this PR (None response, caller's existing not-found path applies).

  • New WalletPersister::get_core_tx_record(txid) pub(crate) per-wallet passthrough in persister.rs.

Lookup helper

  • New record_or_persister(in_memory, persister, txid) -> Option<TransactionRecord> pub(super) helper in proof.rs. Returns the in-memory record if present, otherwise queries the persister. Persister errors are logged at debug and surfaced as None so a transient backend failure doesn't turn into a hard error in the proof flow.

Five sites switched to the helper

Site Before After
validate_or_upgrade_proof Errored with "tx not found in account" if evicted Falls back to persister; error message broadened to "in-memory or persister"
upgrade_to_chain_lock_proof Errored on missing Falls back to persister; if neither has it, falls through to wait_for_chain_lock (SPV-driven)
wait_for_chain_lock Poll loop checked in-memory only Poll loop also checks persister so a chainlock arriving between polls and being evicted is still observed
wait_for_proof Same Same fix
resolve_status_from_info Associated fn, no persister access Converted to method (&self) to use the persister

How Has This Been Tested?

5 new unit tests in proof.rs::tests covering the helper:

  • record_or_persister_prefers_in_memory_when_present — in-memory hit short-circuits; persister never consulted
  • record_or_persister_falls_back_to_persister_on_miss — chain-lock-eviction recovery path
  • record_or_persister_returns_none_when_neither_has_it — both miss → None
  • record_or_persister_default_persister_returns_none — confirms the trait default impl works for backends that don't override
  • record_or_persister_swallows_backend_errors_as_none — transient backend failures don't escalate

Fake PlatformWalletPersistence implementations (FakeRecordStore keyed by txid, ErroringStore always-fails) added inline.

cargo test -p platform-wallet --lib
test result: ok. 120 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

cargo check --workspace --all-targets and cargo clippy -p platform-wallet --all-targets both clean.

Breaking Changes

None. The new trait method has a default Ok(None) impl. The five call sites have a slightly more permissive error path (persister fallback before erroring), but the success contract is unchanged.

Follow-up not in this PR

Downstream persisters (the SwiftData iOS persister, the evo-tool SqliteWalletPersister) need to override get_core_tx_record to actually benefit from this fallback. Until they do, the trait's default Ok(None) keeps the previous behavior (caller's existing not-found path applies). Filing those overrides will close the loop on the original CodeRabbit finding for production builds.

…ck tx records

Five asset-lock proof lookup sites in
`wallet/asset_lock/sync/{proof,recovery}.rs` resolve a transaction
record from the in-memory `transactions()` map keyed by
`(account_index, txid)`. With upstream's `keep-finalized-transactions`
Cargo feature OFF (the default), chainlocked records are evicted from
that map and only their txids retained in `finalized_txids` for dedup.
The chain-lock height / block hash that an asset-lock proof needs is
no longer reachable through the wallet-info API.

The narrow failure mode is "first lookup happens after the
chainlock-eviction window" — `wait_for_proof` polls and normally
observes `InstantSend` / `InBlock` state while the record is still
present, but a tx that's already chain-locked at the moment the proof
flow starts (e.g. crash recovery resuming an asset lock that
chain-locked while we were down) hits the missing-record path.

This adds a persister fallback. The persister received the full record
on the chainlock-transition `store` call before eviction, so it can
answer the lookup.

Changes:

* `PlatformWalletPersistence::get_core_tx_record(wallet_id, txid) ->
  Result<Option<TransactionRecord>, PersistenceError>` — new trait
  method with a default `Ok(None)` impl. Backward-compatible:
  persisters that don't index records by txid (the in-tree
  `NoPlatformPersistence`, no-op test stubs in the wider tree) need
  no change. Persisters whose backing store keys records by txid
  (`SqliteWalletPersister` in evo-tool, the SwiftData iOS persister)
  should override; until then those callers see the same behavior as
  before this PR (None response, caller's existing not-found path
  applies).

* `WalletPersister::get_core_tx_record(txid)` — `pub(crate)` per-wallet
  passthrough.

* `record_or_persister(in_memory, persister, txid) -> Option<TransactionRecord>`
  — `pub(super)` helper in `proof.rs` that returns the in-memory
  record if present, otherwise queries the persister. Persister
  errors are logged at `debug` and surfaced as `None` so a transient
  backend failure doesn't turn into a hard error in the proof flow.

* Five lookup sites switched to the helper:
  - `validate_or_upgrade_proof` (proof.rs): error message broadened to
    "in-memory or persister".
  - `upgrade_to_chain_lock_proof` (proof.rs): "tx not found" no longer
    errors; falls through to `wait_for_chain_lock` so SPV events can
    still complete the proof.
  - `wait_for_chain_lock` (proof.rs): poll loop now also checks
    persister so a chainlock that arrives between polls and is
    evicted is still observed on the next iteration.
  - `wait_for_proof` (proof.rs): same treatment.
  - `resolve_status_from_info` (recovery.rs): converted from
    associated `fn` → method (`&self`) to access the persister.

* Tests: 5 new unit tests in `proof.rs::tests` covering the helper —
  in-memory hit short-circuits, persister-only hit returns the
  record, both-miss returns `None`, default trait impl returns `None`,
  backend errors swallowed as `None`. Fake `PlatformWalletPersistence`
  implementations (`FakeRecordStore`, `ErroringStore`) added inline.
  Total: 120/120 platform-wallet lib tests pass.

Backlinks #3617 (CodeRabbit's "Major" finding on the
proof.rs eviction window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Warning

Rate limit exceeded

@QuantumExplorer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 11 minutes and 45 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f46b576e-a6b2-4bf4-9866-aa0c046bd52d

📥 Commits

Reviewing files that changed from the base of the PR and between 8156ecc and 74f6b3f.

📒 Files selected for processing (8)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/traits.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs
  • packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs
  • packages/rs-platform-wallet/src/wallet/persister.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/asset-lock-proof-persister-fallback

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 and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone May 7, 2026
@thepastaclaw
Copy link
Copy Markdown
Collaborator

thepastaclaw commented May 7, 2026

Review Gate

Commit: 74f6b3f5

  • Debounce: 14m ago (need 30m)

  • CI checks: builds passed, 0/0 tests passed

  • CodeRabbit review: comment found

  • Off-peak hours: off-peak (11:07 AM PT Thursday)

  • Run review now (check to override)

…llback

Hooks the persister-fallback path added in the previous commit through
to the iOS persister so it actually fires in production builds.

The trait method `PlatformWalletPersistence::get_core_tx_record` is the
seam; this commit adds the FFI plumbing + the Swift implementation that
backs it via the existing `PersistentTransaction` SwiftData rows.

## platform-wallet (trait doc)

Tightened the field contract on `get_core_tx_record`. Implementations
are only required to populate `txid` + `context` (and the `BlockInfo`
inside `InChainLockedBlock` / `InBlock` carrying real height + hash +
timestamp). Other fields MAY be best-effort placeholders; callers
(currently only the asset-lock proof flow) MUST NOT read them. This
makes the FFI-backed implementation feasible without serializing the
full `Transaction` body across the C ABI.

## platform-wallet-ffi

* `PersistenceCallbacks.on_get_chainlocked_tx_record_fn` — new C
  callback. Takes a wallet_id + txid; on hit, populates
  `out_block_height`, `out_block_hash` (32 bytes), `out_block_timestamp`
  and sets `out_found = true`. Returns 0 on success regardless of hit /
  miss; non-zero is treated as a transient backend failure (surfaced
  as `None` per the helper contract). Scope is intentionally narrow:
  chainlocked records only — IS-locked / mempool / InBlock records
  aren't evicted from the in-memory map and so don't need a fallback.

* `FFIPersister::get_core_tx_record` — overrides the trait default to
  call the new callback and synthesize a minimal `TransactionRecord`
  with `context = InChainLockedBlock(BlockInfo::new(height, hash,
  timestamp))`. Placeholder transaction body / account_type /
  direction / etc. — proof flow callers never read those, per the
  trait contract.

## swift-sdk

* `PlatformWalletPersistenceHandler.chainlockedTxRecord(walletId:txid:)`
  — internal lookup. Walks `PersistentTransaction` rows by `txid`
  (raw 32 wire bytes — same orientation Rust hands across the FFI)
  filtered to `context == 3` (InChainLockedBlock). Returns nil on
  miss or when the row's `blockHash` is missing / wrong-length
  (treated as miss rather than fabricating a zero hash that would
  round-trip to Rust as a real block id).

* `getChainlockedTxRecordCallback` — top-level C shim mirroring the
  pattern of the other persistence callbacks. Handles the
  Rust→Swift→Rust conversion + populates the output pointers.

* `makeCallbacks()` registers the new shim. Existing
  `PlatformWalletManager()` construction picks it up automatically;
  the example app needed no changes.

## Verification

- `cargo check --workspace --all-targets`: clean
- `cargo test -p platform-wallet --lib`: 120/120 (the 5 new
  helper tests from the prior commit still pass since the helper
  itself is unchanged)
- `cargo clippy -p platform-wallet-ffi --all-targets`: clean (the 2
  warnings are pre-existing in unrelated test code)
- `bash packages/swift-sdk/build_ios.sh --target sim --profile dev`:
  BUILD SUCCEEDED end-to-end including SwiftExampleApp link

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer requested a review from shumkov as a code owner May 7, 2026 17:04
Rest of `makeCallbacks()` is one-line-per-registration; the new line
follows the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "35b1ecf7baf930357d5c45653ca12438079f24f802e7686d752c183af634d520"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

Code Review

The persister fallback is a good narrow fix for chainlocked asset-lock records that have been evicted from the in-memory wallet map, and the helper itself is covered by focused unit tests. I did not find a consensus or safety blocker. The remaining review points are follow-through/behavioral: the in-tree FFI/Swift persistence surface still cannot answer the new lookup, the missing-record path in upgrade_to_chain_lock_proof now waits for timeout instead of failing fast, and one recovery callsite hides backend errors / does persister I/O while holding the wallet-manager write lock.

Reviewed commit: 757f035

🟡 3 suggestion(s) | 💬 1 nitpick(s)

GitHub rejected the inline review payload for this run (HTTP 422), so I’m posting the verified findings as a top-level review rather than dropping the dispatcher-required review.

🟡 suggestion: FFI / Swift persister does not yet override `get_core_tx_record` — iOS recovery path remains inert

packages/rs-platform-wallet-ffi/src/persistence.rs:63-312

The new PlatformWalletPersistence::get_core_tx_record lookup is the entire point of this PR's chainlock-eviction recovery, but FFIPersister (the only in-tree persister used by Swift/iOS, at packages/rs-platform-wallet-ffi/src/persistence.rs:312) does not override it and the C PersistenceCallbacks vtable exposes no lookup hook. Result: every FFI/Swift-backed wallet inherits the trait's default Ok(None), so once the in-memory transactions() map evicts a chainlocked record, all five updated asset-lock call sites still resolve to None and the chainlock recovery path the PR adds is unreachable on iOS.

The PR description acknowledges this and points to follow-up PRs for SqliteWalletPersister and the SwiftData persister, so this is intentional groundwork rather than a defect — flagging because the in-tree FFI surface needs a paired callback (and FFIPersister impl) before this PR's intent applies to the iOS build, and the Ok(None) default gives no compile-time signal driving that follow-through. Until then, the only behavior change visible to FFI consumers is the fast-fail → full-timeout regression noted separately on upgrade_to_chain_lock_proof.

🟡 suggestion: `upgrade_to_chain_lock_proof`: missing record now waits the full timeout instead of erroring fast

packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:185-204

Previously this function returned AssetLockProofWait("Transaction X not found in account Y") immediately when the in-memory lookup missed. After the change, a miss yields in_memory = None; if the persister also returns None (which is the case for any backend that hasn't overridden get_core_tx_record — i.e. all in-tree backends today), record_or_persister returns None, the match falls to None => None, height becomes None, and the code dispatches to wait_for_chain_lock(...) which polls until height_timeout elapses and errors with FinalityTimeout.

Because the function already validated tracked_asset_locks.get(out_point) above, the asset lock is known-tracked, so silently waiting is mostly consistent with this function's documented "otherwise wait" semantics. But it does change the fast-fail behavior for genuinely-unknown txids (e.g. wallet-state mismatch, post-wipe race) and — combined with the FFI gap above — means production iOS callers always wait the full chainlock timeout instead of recovering. Either preserve a fast-fail when both lookups miss, or document the timeout-on-miss behavior on the function so callers size timeouts accordingly.

🟡 suggestion: Recovery path silently downgrades status on transient persister error

packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:100-115

record_or_persister logs persister errors at debug and surfaces them as None, which is the right call inside the wait_for_chain_lock / wait_for_proof poll loops (the next tick retries). But resolve_status_from_info is a one-shot recovery call: a transient backend error here makes the lock silently classify as AssetLockStatus::Broadcast even when the underlying tx is actually chain-locked. The follow-up resume_asset_lock then takes the wasteful Broadcast → wait_for_proof path instead of constructing a ChainAssetLockProof directly. The user eventually recovers, but the failure is invisible to operators. Either log at warn/error from this call site, or distinguish "persister returned None" from "persister failed" in resolve_status_from_info so a real backend error is visible.

💬 nitpick: `recover_asset_lock_blocking` now holds the wallet manager write lock across persister I/O

packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:47-89

recover_asset_lock_blocking acquires self.wallet_manager.blocking_write() and then calls self.resolve_status_from_info(&info.core_wallet, ...). After this PR, resolve_status_from_info can fall through to record_or_persister, which performs a synchronous self.persister.get_core_tx_record(...) lookup on miss — disk I/O, FFI callbacks, or arbitrary backend logic, now all serialized behind the wallet-manager write lock. Consider snapshotting the in-memory result, dropping wm, then doing the persister fallback (and, if needed, re-acquiring) so the lock-hold time stays bounded to the in-memory lookup as before.

🤖 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.

- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:63: FFI / Swift persister does not yet override `get_core_tx_record` — iOS recovery path remains inert
  The new `PlatformWalletPersistence::get_core_tx_record` lookup is the entire point of this PR's chainlock-eviction recovery, but `FFIPersister` (the only in-tree persister used by Swift/iOS, at packages/rs-platform-wallet-ffi/src/persistence.rs:312) does not override it and the C `PersistenceCallbacks` vtable exposes no lookup hook. Result: every FFI/Swift-backed wallet inherits the trait's default `Ok(None)`, so once the in-memory `transactions()` map evicts a chainlocked record, all five updated asset-lock call sites still resolve to `None` and the chainlock recovery path the PR adds is unreachable on iOS. The PR description acknowledges this and points to follow-up PRs for `SqliteWalletPersister` and the SwiftData persister, so this is intentional groundwork rather than a defect — flagging because the in-tree FFI surface needs a paired callback (and `FFIPersister` impl) before this PR's intent applies to the iOS build, and the `Ok(None)` default gives no compile-time signal driving that follow-through. Until then, the only behavior change visible to FFI consumers is the fast-fail → full-timeout regression noted separately on `upgrade_to_chain_lock_proof`.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/sync/proof.rs:185: `upgrade_to_chain_lock_proof`: missing record now waits the full timeout instead of erroring fast
  Previously this function returned `AssetLockProofWait("Transaction X not found in account Y")` immediately when the in-memory lookup missed. After the change, a miss yields `in_memory = None`; if the persister also returns `None` (which is the case for any backend that hasn't overridden `get_core_tx_record` — i.e. all in-tree backends today), `record_or_persister` returns `None`, the match falls to `None => None`, `height` becomes `None`, and the code dispatches to `wait_for_chain_lock(...)` which polls until `height_timeout` elapses and errors with `FinalityTimeout`. Because the function already validated `tracked_asset_locks.get(out_point)` above, the asset lock is known-tracked, so silently waiting is mostly consistent with this function's documented "otherwise wait" semantics. But it does change the fast-fail behavior for genuinely-unknown txids (e.g. wallet-state mismatch, post-wipe race) and — combined with the FFI gap above — means production iOS callers always wait the full chainlock timeout instead of recovering. Either preserve a fast-fail when both lookups miss, or document the timeout-on-miss behavior on the function so callers size timeouts accordingly.
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs:100: Recovery path silently downgrades status on transient persister error
  `record_or_persister` logs persister errors at `debug` and surfaces them as `None`, which is the right call inside the `wait_for_chain_lock` / `wait_for_proof` poll loops (the next tick retries). But `resolve_status_from_info` is a one-shot recovery call: a transient backend error here makes the lock silently classify as `AssetLockStatus::Broadcast` even when the underlying tx is actually chain-locked. The follow-up `resume_asset_lock` then takes the wasteful `Broadcast → wait_for_proof` path instead of constructing a `ChainAssetLockProof` directly. The user eventually recovers, but the failure is invisible to operators. Either log at `warn`/`error` from this call site, or distinguish "persister returned None" from "persister failed" in `resolve_status_from_info` so a real backend error is visible.

QuantumExplorer and others added 2 commits May 8, 2026 00:29
…ody in persister fallback

Two fixes on top of the previous commit:

1. **FFI now sends the actual context kind, Rust faithfully
   reconstructs.** Previously the FFI was scoped to "chainlocked
   only" and the Rust side wrapped every hit as
   `InChainLockedBlock(...)` regardless. The Swift side compensated
   by filtering rows to `context == 3` in the predicate. That was
   correct in steady state (the eviction invariant means
   persister-only-not-memory implies chainlocked) but wrong at
   startup: an in-memory map starts empty, so any persister hit on
   a non-chainlocked row would round-trip to Rust as a false-
   positive chainlock — `resolve_status_from_info` would emit
   `(ChainLocked, ChainAssetLockProof)` for a tx that isn't
   chainlocked yet.

   Now the FFI carries `out_context_kind` (0=Mempool, 1=IS,
   2=InBlock, 3=InChainLockedBlock). Rust constructs the matching
   `TransactionContext` variant, the chainlock filter on Swift
   goes away, and false-positive chainlocks are impossible. IS
   hits surface as `None` to the proof flow (the persister
   doesn't store the IS-lock blob; SPV-event wait completes the
   proof from the live stream — same outcome as if the fallback
   weren't wired at all).

2. **Persister returns the real transaction bytes; Rust decodes a
   real `Transaction`.** Previously the synthesized
   `TransactionRecord` carried a placeholder `Transaction { version:
   1, lock_time: 0, input: [], output: [], … }` because the FFI
   surface didn't carry the tx body. The proof flow doesn't read
   `record.transaction` today, so the placeholder was technically
   safe — but it was a lie that would silently corrupt any future
   code reading those fields.

   `PersistentTransaction.transactionData` is what gets stored on
   the FFI write path (consensus-encoded — `dashcore::consensus::
   encode::serialize` upstream, NOT bincode). The new callback
   hands those bytes back over FFI; Rust decodes via
   `Transaction::consensus_decode`. Buffer ownership: Swift
   allocates with `UnsafeMutablePointer<UInt8>.allocate`, Rust
   takes the pointer, an RAII guard on the Rust side calls the
   paired `on_get_core_tx_record_free_fn` exactly once on every
   exit path (success, decode failure, IS-skip, unknown context
   kind).

   `PersistentTransaction.transactionData` is now non-optional —
   the FFI write path always populates it in practice (the `Data?`
   was a vestige of a defensive guard that never fires). Init
   gains a required `transactionData` arg; the upsert path lifts
   the bytes extraction to a single binding shared between the
   init and the unconditional post-init assignment. The one stub
   creation site (UTXO upsert path that pre-creates the parent
   transaction row before the real upsert arrives) passes empty
   `Data()` — the real upsert overwrites every field including
   `transactionData`; an orphaned stub correctly reads back as
   miss because the empty buffer won't decode.

Touched the example app's `StorageRecordDetailViews` (one line) to
drop the `?.count` optional chain on the de-optionalized field.

Verification:
- `cargo check --workspace --all-targets`: clean
- `cargo test -p platform-wallet --lib`: 120/120
- `cargo clippy -p platform-wallet-ffi --all-targets`: clean
- `bash packages/swift-sdk/build_ios.sh --target sim --profile dev`:
  BUILD SUCCEEDED end-to-end including SwiftExampleApp link

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… drop lock before persister I/O

Addresses three findings from the @thepastaclaw review on commit
757f035 (the FFI/Swift wiring + tx-bytes plumbing in d8de2c2 and
8e9b75d already addressed the fourth — "FFI not wired" — and
already reach the iOS build through the new
`on_get_core_tx_record_fn` callback).

## #2 — fast-fail regression in `upgrade_to_chain_lock_proof`

Pre-PR, a missing in-memory record errored fast with
`AssetLockProofWait("Transaction X not found in account Y")`.
After the helper landed, the missing case fell through to
`wait_for_chain_lock(...)` and burned the full chainlock timeout
before returning `FinalityTimeout` — a regression for genuine
"tx never tracked" cases (wallet-state mismatch, post-wipe race).

Restored the fast-fail: when both the in-memory map and the
persister miss, the function returns the same
`AssetLockProofWait` error pre-PR callers expected. The
"persister returned a non-chainlocked record" case still falls
through to `wait_for_chain_lock` (correct — the tx exists, just
hasn't chainlocked yet).

## #3 — silent error downgrade on transient persister failure

`record_or_persister` previously logged backend errors at
`debug` and surfaced them as `None`. That's correct for the
poll loops (`wait_for_chain_lock`, `wait_for_proof`) where the
next tick retries — but wrong for `resolve_status_from_info`,
which is a one-shot recovery call: a transient backend error
silently classified the lock as `AssetLockStatus::Broadcast`
even when the underlying tx was actually chain-locked,
forcing `resume_asset_lock` down the wasteful
`Broadcast → wait_for_proof` path with no operator-visible
signal.

Split the helper:

* `record_or_persister` now returns
  `Result<Option<TransactionRecord>, PersistenceError>`. Call
  sites choose their own policy.
* `record_or_persister_or_log` is the new poll-loop variant —
  same `Option` return, but logs persister errors at `warn` (up
  from `debug`) before downgrading to `None`. A persistent
  failure is now visible in operator logs.
* The two poll-loop sites (`wait_for_chain_lock`,
  `wait_for_proof`) switched to `_or_log`.
* The two fast-fail sites (`validate_or_upgrade_proof`,
  `upgrade_to_chain_lock_proof`) propagate `Err` as
  `AssetLockProofWait("Persister lookup for tx X failed: …")`,
  distinct from the "neither lookup found it" message.
* The one-shot recovery site (renamed
  `resolve_status_with_in_memory`, see #4 below) logs at
  `error` and degrades to `None`, preserving recovery's "always
  make some progress" semantics while making the failure
  visible.

Tests updated for the new signature. Added a new test
`record_or_persister_or_log_swallows_backend_errors_as_none`
covering the poll-loop variant; renamed the old "swallows" test
to `propagates_backend_errors` and asserts `Err`. 121/121 tests
pass.

## #4 — wallet-manager lock held across persister I/O

`recover_asset_lock_blocking` was acquiring
`self.wallet_manager.blocking_write()` and then dispatching to
`resolve_status_from_info`, which after the helper's persister
fallback could perform synchronous SQLite/SwiftData/FFI I/O
behind the write lock — serializing other writers on a single
disk lookup.

Restructured into three phases:

1. **Lock held**: claim the `tracked_asset_locks` slot,
   snapshot the in-memory record (only when no proof was
   provided), drop the lock.
2. **No lock held**: resolve status, including the persister
   fallback. The renamed
   `resolve_status_with_in_memory(snapshot, ...)` takes the
   pre-snapshotted record so this phase no longer touches the
   wallet manager.
3. **Lock held**: re-acquire to commit the
   `TrackedAssetLock` entry. A re-check of
   `tracked_asset_locks.contains_key` handles the case where
   another caller raced in during phase 2 (first writer wins).

Net effect: the persister I/O now runs without serializing
other wallet-manager writers. The lock-hold time drops from
"in-memory lookup + persister round-trip" to two short
in-memory operations.

Verification:
- `cargo fmt --all`: clean
- `cargo test -p platform-wallet --lib`: 121/121 (was 120,
  +1 from the new `_or_log` test)
- `bash packages/swift-sdk/build_ios.sh --target sim --profile dev`:
  BUILD SUCCEEDED end-to-end including SwiftExampleApp link

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer
Copy link
Copy Markdown
Member Author

@thepastaclaw thanks for the review. Three of the four are addressed in 372697d, the fourth was a staleness artifact:

#1 — FFI not wired (stale). The review pulled commit 757f035c, which was indeed groundwork-only. The follow-on commits on this same PR (d8de2c2 + 8e9b75d) added on_get_core_tx_record_fn + on_get_core_tx_record_free_fn to PersistenceCallbacks, the matching FFIPersister override, the Swift coreTxRecord lookup that walks PersistentTransaction by txid, and plumbing for the consensus-encoded transaction bytes (so the synthesized TransactionRecord carries a real Transaction body, not a placeholder). The recovery path now reaches the iOS build.

#2 — fast-fail regression. Restored. upgrade_to_chain_lock_proof returns the same AssetLockProofWait("Transaction X not found in account Y") pre-PR callers expected when both the in-memory map and the persister miss. The "persister returned a non-chainlocked record" case still falls through to wait_for_chain_lock (correct — the tx exists, just hasn't chainlocked yet).

#3 — silent error downgrade. Split the helper:

  • record_or_persister now returns Result<Option<TransactionRecord>, PersistenceError>.
  • record_or_persister_or_log is the new poll-loop variant — same Option return, logs persister errors at warn (was debug) before downgrading to None.
  • The two poll-loop sites (wait_for_chain_lock, wait_for_proof) use _or_log.
  • The two fast-fail sites propagate Err as a distinct "Persister lookup for tx X failed" message.
  • resolve_status_with_in_memory (the renamed one-shot recovery site) logs at error and degrades — preserves recovery's "always make some progress" semantics while making the failure visible.

#4 — lock held across persister I/O. Restructured recover_asset_lock_blocking into three phases: lock-held slot claim + in-memory snapshot, lock-released status resolution (including persister fallback), lock-held commit with a tracked_asset_locks.contains_key re-check for the race. Persister I/O no longer serializes other wallet-manager writers.

Tests updated: record_or_persister_propagates_backend_errors replaces the old "swallows" test; new record_or_persister_or_log_swallows_backend_errors_as_none covers the poll-loop variant. 121/121 pass. iOS xcframework builds end-to-end.

Copy link
Copy Markdown
Member Author

@QuantumExplorer QuantumExplorer left a comment

Choose a reason for hiding this comment

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

Self Reviewed

@thepastaclaw
Copy link
Copy Markdown
Collaborator

Thanks — I re-checked the current head (372697d0) against the four review points.

I also ran cargo test -p platform-wallet --lib (121/121 pass) and cargo clippy -p platform-wallet --all-targets (passes). Tiny cleanup note: both commands still warn about an unused ManagedWalletInfo import in recovery.rs, but that’s not related to the original review points.

…ery.rs

The import was referenced only in a doc comment after the
`resolve_status_from_info` → `resolve_status_with_in_memory`
rename dropped the `&ManagedWalletInfo` parameter.

Spotted by @thepastaclaw on PR #3619 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@QuantumExplorer QuantumExplorer merged commit 105546c into v3.1-dev May 7, 2026
16 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/asset-lock-proof-persister-fallback branch May 7, 2026 18:16
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