Skip to content

feat(platform-wallet): reconstruct sent DashPay payments from tx history - #4300

Open
romchornyi wants to merge 8 commits into
v4.2-devfrom
fix/dashpay-sent-payment-reconstruction
Open

feat(platform-wallet): reconstruct sent DashPay payments from tx history#4300
romchornyi wants to merge 8 commits into
v4.2-devfrom
fix/dashpay-sent-payment-reconstruction

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 5, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

Received DashPay payments already recover after a restore-from-seed: reconcile_incoming_payments walks dashpay_receival_accounts, which persist along with their UTXOs. The sending direction had no equivalent.

The result is that a restored wallet shows "No payments with this contact yet" for every contact it had paid. The transactions are on chain and in local history — they simply are not attributed to the contact. The counterparty's wallet, meanwhile, still lists them all, so the data is provably recoverable.

The asymmetry is called out in this file's own comment (payments.rs:207-208).

What was done?

reconcile_sent_payments_from_tx_history walks the wallet's persisted core transactions, matches outputs against the addresses derived from each contact's DashpayExternalAccount, and records one Sent entry per (owner, contact, txid). It runs as a step of dashpay_sync(), after reconcile_incoming_payments so the entry with real ground truth wins. Local-only, no network round-trips, idempotent — an existing entry for a txid is never overwritten.

Three details worth a reviewer's attention:

Matching reads record.transaction.output, not record.output_details. Records handed back by get_core_tx_record are rebuilt from the host's raw transaction bytes, so only transaction, txid and context carry real data — the details vec is always empty (rs-platform-wallet-ffi/src/persistence.rs). A version of this that read output_details passed every unit test and matched nothing on device. Comparing script pubkeys rather than rendered addresses also sidesteps address-encoding differences.

The skip-guard is direction-aware. A contact is skipped once it has a Sent entry, or once swept this launch. Testing "any payment with this contact" would hide the outgoing history of every contact we had also received from, because the incoming reconcile runs first and fills the map.

The per-launch marker is in-memory only and is not set when a persister read or write failed, so a transient error cannot permanently strand a contact. A relaunch retries once for still-empty contacts, which is far cheaper than re-scanning persisted history every 15s forever.

Enumerating the wallet's transactions needs a new persistence hook, list_wallet_core_txids, defaulting to an empty list so existing persisters keep compiling. The Swift implementation packs only well-formed 32-byte txids and reports how many it packed — the earlier shape could hand Rust an uninitialized slot as a txid.

How Has This Been Tested?

cargo test -p platform-wallet — 519 passed, 0 failed.

Two of those are regression tests for the defects above, and both were verified to fail without their fix:

  • reconcile_sent_payments_from_tx_history_matches_without_output_details — builds the record shape the FFI actually returns (output_details cleared).
  • reconcile_sent_payments_from_tx_history_reconstructs_for_contact_with_received_history — a contact with prior incoming history still gets its sends reconstructed.

End to end on an iOS device (testnet), wallet restored from seed with two established contacts:

reconcile_sent_payments_from_tx_history: candidate set built  eligible_contacts=2 candidate_addresses=40
reconcile_sent_payments_from_tx_history: scan complete  txids=49 records_read=49 outputs_scanned=95 matched_txids=6
Recording reconstructed sent DashPay payment  contact=AHez… ×4
Recording reconstructed sent DashPay payment  contact=BfTA… ×2

Six Sent entries recorded, amounts and dates matching the counterparty wallet, and they survive relaunch. Steady-state passes afterwards report no eligible contacts, i.e. no repeated full scans. The receiving side was checked in the same run and is unaffected.

Needs the companion app change (dashwallet-ios fix/bug-28-dashpay-payment-history) to drain the deferred contact-crypto queue — without it a restored wallet has no external accounts for this pass to match against.

Breaking Changes

None. list_wallet_core_txids is a defaulted trait method.

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

Summary by CodeRabbit

  • New Features

    • Added automatic recovery of missing DashPay sent-payment history from persisted wallet transactions.
    • Recovered payments include matching contacts, transaction details, and Pending or Confirmed status.
    • Wallet synchronization can identify wallet-funded transactions for historical reconciliation and extend address discovery when needed.
  • Bug Fixes

    • Prevented duplicate sent-payment entries and repeated reconciliation scans.
    • Improved handling of mempool transactions, InstantSend locks, incomplete history, and rescan backfills.
    • Synchronization continues safely when individual transaction reads or writes fail.
    • Preserved recovery progress across partial writes and temporary data-access failures.

Received DashPay payments already recover after a restore-from-seed:
`reconcile_incoming_payments` walks `dashpay_receival_accounts`, which
persist with their UTXOs. The sending direction had no equivalent, so a
restored wallet showed "No payments with this contact yet" for every
contact it had paid — the transactions were on chain and in local
history, just not attributed.

`reconcile_sent_payments_from_tx_history` closes that gap. It walks the
wallet's persisted core transactions, matches outputs against the
addresses derived from each contact's `DashpayExternalAccount`, and
records one `Sent` entry per (owner, contact, txid). Local-only, no
network round-trips, idempotent — an existing entry for a txid is never
overwritten, so the live send path and the incoming reconcile both keep
priority. It runs as a step of `dashpay_sync()` after
`reconcile_incoming_payments`.

Matching reads `record.transaction.output` and compares script pubkeys.
It deliberately does not read `record.output_details`: records handed
back by `get_core_tx_record` are rebuilt from the host's raw transaction
bytes, so only `transaction`, `txid` and `context` carry real data and
the details vec is always empty. Comparing scripts rather than rendered
addresses also sidesteps address-encoding differences.

Contacts are skipped once they have a `Sent` entry, or once swept this
launch. The direction matters: the incoming reconcile runs first, so a
"has any payment with this contact" test would have hidden the outgoing
history of every contact we had also received from. The per-launch
marker is in-memory only and is not set when a persister read or write
failed, so a transient error cannot permanently strand a contact.

Enumerating the wallet's transactions needs a new persistence hook,
`list_wallet_core_txids`, defaulting to an empty list so existing
persisters keep compiling.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e0058fd-c3f2-475c-af6b-5c2862081cd7

📥 Commits

Reviewing files that changed from the base of the PR and between 54758c2 and 2d352b9.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/rs-platform-wallet-ffi/src/persistence.rs

📝 Walkthrough

Walkthrough

Changes

Sent-payment history recovery

Layer / File(s) Summary
Wallet transaction enumeration
packages/rs-platform-wallet/src/changeset/..., packages/rs-platform-wallet/src/wallet/persister.rs, packages/rs-platform-wallet-ffi/src/persistence.rs, packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift, packages/rs-unified-sdk-jni/src/persistence.rs
The persistence API enumerates wallet-scoped Core transaction IDs with funding flags. Rust validates, decodes, and frees returned buffers.
Sent-payment reconstruction
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs, packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/*
DashPay reconstructs missing sent payments from persisted transaction outputs, assigns Confirmed or Pending status, retries incomplete work, and tracks rescan backfill state.
Sync integration and validation
packages/rs-platform-wallet/src/manager/dashpay_sync.rs, packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
DashPay sync runs reconciliation and logs failures without stopping later steps. Tests cover matching, idempotency, deduplication, retries, address-gap extension, rescan gating, and funding filters.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DashPaySync
  participant DashPayView
  participant WalletPersister
  participant SwiftPersistence
  participant PaymentStore
  DashPaySync->>DashPayView: reconcile_sent_payments_from_tx_history()
  DashPayView->>WalletPersister: list_wallet_core_txids()
  WalletPersister->>SwiftPersistence: enumerate wallet transaction IDs
  SwiftPersistence-->>WalletPersister: transaction IDs and funding flags
  WalletPersister-->>DashPayView: decoded transaction records
  DashPayView->>PaymentStore: write missing Sent entries
  DashPayView-->>DashPaySync: result or logged failure
Loading

Possibly related PRs

  • dashpay/platform#4268: Both PRs modify PersistenceCallbacks and persistence.rs; this PR adds transaction-enumeration callbacks, while that PR adds callback-context ownership and release_fn lifecycle handling.

Suggested reviewers: lklimek, quantumexplorer, shumkov

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: reconstructing sent DashPay payments from persisted transaction history.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/dashpay-sent-payment-reconstruction

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
packages/rs-platform-wallet/src/wallet/identity/network/payments.rs (3)

3274-3331: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the read-error and write-failure retry paths.

The tests cover the success and idempotency branches. Two branches decide whether recovery retries after a failure, and both are currently untested:

  • had_read_error set by a get_core_tx_record error at lines 304-312, which must leave the guard unstamped so the next sweep retries.
  • write_failed_for populated by a record_dashpay_payment failure at lines 372-379, which must leave that contact's guard unstamped.

A regression in either branch silently converts a transient failure into permanently missing sent history for the launch, with no assertion to catch it. RecordStorePersister needs a failure toggle, in the shape of the existing ToggleFailPersister.

Do you want me to write these two tests?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs` around
lines 3274 - 3331, Add tests for read-error and write-failure retry behavior
alongside reconcile_sent_payments_from_tx_history_skips_repeat_empty_sweeps.
Extend RecordStorePersister with a failure toggle matching ToggleFailPersister,
then verify get_core_tx_record errors leave the reconciliation guard unstamped
and cause the next sweep to retry, while record_dashpay_payment failures leave
only the affected contact guard unstamped and retry that contact on the next
sweep.

600-611: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse this helper in reconcile_sent_payments.

Lines 465-466 inline the same finality test: record.is_confirmed() || matches!(record.context, TransactionContext::InstantSend(_)). Two copies of the definition of "final" can diverge. Call the helper from both sites.

♻️ Proposed refactor at lines 465-466
-            let is_final = record.is_confirmed()
-                || matches!(record.context, TransactionContext::InstantSend(_));
-            if !is_final {
+            if sent_payment_status_for_record(&record) != PaymentStatus::Confirmed {
                 continue;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs` around
lines 600 - 611, Update reconcile_sent_payments to call
sent_payment_status_for_record for the finality decision instead of duplicating
the record.is_confirmed() || InstantSend check, while preserving the existing
confirmed and pending behavior. Remove any now-unneeded TransactionContext usage
from that call site.

290-333: 🚀 Performance & Scalability | 🔵 Trivial

Consider a batched record read if wallet transaction counts grow.

The scan issues one get_core_tx_record FFI call per wallet transaction. Each call crosses the C ABI and runs a SwiftData fetch on the host's serial queue. The per-launch guard bounds how often the full scan runs, so the current cost is one pass per launch. On a wallet with thousands of transactions that pass becomes a single long stall on the sync task, and the host queue is blocked for its duration.

If transaction counts grow, a batched enumeration that returns records (not just txids) would collapse the round trips. No change is needed for the current volumes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs` around
lines 290 - 333, The scan in reconcile_sent_payments_from_tx_history currently
does one get_core_tx_record lookup per txid, which can stall the sync task when
wallet histories grow. Update the tx-history reconciliation path to prefer a
batched record enumeration from the persister that returns records directly, and
reuse that in place of the per-txid loop while keeping the existing txid-only
fallback behavior for current volumes. Preserve the current totals,
records_read, outputs_scanned, and had_read_error accounting around the new
batch path so the reconciliation result stays unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 2816-2821: In the txid-buffer handling around get_core_tx_record,
replace the ineffective raw.len comparison with a pre-slice overflow check that
rejects count when count multiplied by 32 cannot be represented safely,
including the isize::MAX bound required by from_raw_parts. Perform this
validation before constructing the slice, and add a SAFETY comment documenting
the pointer, length, and allocation assumptions consistent with the existing
get_core_tx_record pattern.
- Around line 630-657: Move on_list_wallet_core_txids_fn and
on_list_wallet_core_txids_free_fn to positions after
PersistenceCallbacks.release_fn, preserving the existing order and offsets of
all prior fields for older host bindings. Keep their signatures and callback
behavior unchanged.

In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- Around line 383-395: Update the recovery guard in
payments::reconcile_sent_payments_from_tx_history so the stamp-and-return path
does not run on an empty txid enumeration from list_wallet_core_txids. Treat a
successful but zero-length txid result as inconclusive, like a read error, and
keep retrying instead of marking every eligible contact in eligible_contacts as
attempted. Preserve the existing write_failed_for filtering and
managed_identity_mut update flow for real recovery runs, and adjust
reconcile_sent_payments_from_tx_history_skips_repeat_empty_sweeps to reflect the
new empty-enumeration retry behavior.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 7512-7517: The txid enumeration callback must distinguish failures
from an empty transaction list. In PlatformWalletPersistenceHandler.swift at
lines 7512-7517, return a non-zero status when walletIdPtr, outTxids, or
outCount is nil, and when the fetch handler reports failure. At lines 5781-5791,
replace try? with do/catch, log fetch errors, and propagate an errored flag to
the shim instead of converting failures to an empty result.
- Around line 7512-7517: Update the guard handling required arguments in the
transaction enumeration method to return a non-zero failure code when context,
walletIdPtr, outTxids, or outCount is nil. Preserve the successful zero-result
behavior for valid arguments so Rust distinguishes host wiring failures and
retries reconciliation.
- Around line 34-36: Guard the faulted wallet relationship access inside
PlatformWalletPersistenceHandler’s involvedAccounts predicate so
`wallet.walletId` is only read after safely confirming the relationship is
available, matching the existing defensive pattern used later in the same file.
Update the `walletCoreTxids` filtering path around the
`transaction.involvedAccounts.contains` check to avoid crashing when a persisted
`PersistentAccount` row has an inconsistent or unloaded `wallet` relationship,
and preserve the current true/false matching behavior for valid rows.

---

Nitpick comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- Around line 3274-3331: Add tests for read-error and write-failure retry
behavior alongside
reconcile_sent_payments_from_tx_history_skips_repeat_empty_sweeps. Extend
RecordStorePersister with a failure toggle matching ToggleFailPersister, then
verify get_core_tx_record errors leave the reconciliation guard unstamped and
cause the next sweep to retry, while record_dashpay_payment failures leave only
the affected contact guard unstamped and retry that contact on the next sweep.
- Around line 600-611: Update reconcile_sent_payments to call
sent_payment_status_for_record for the finality decision instead of duplicating
the record.is_confirmed() || InstantSend check, while preserving the existing
confirmed and pending behavior. Remove any now-unneeded TransactionContext usage
from that call site.
- Around line 290-333: The scan in reconcile_sent_payments_from_tx_history
currently does one get_core_tx_record lookup per txid, which can stall the sync
task when wallet histories grow. Update the tx-history reconciliation path to
prefer a batched record enumeration from the persister that returns records
directly, and reuse that in place of the per-txid loop while keeping the
existing txid-only fallback behavior for current volumes. Preserve the current
totals, records_read, outputs_scanned, and had_read_error accounting around the
new batch path so the reconciliation result stays unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e27ff56-9d16-4913-bcf8-cdb07e4b2efc

📥 Commits

Reviewing files that changed from the base of the PR and between 092d1d6 and 0d132c2.

📒 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/manager/dashpay_sync.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs
  • packages/rs-platform-wallet/src/wallet/persister.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-platform-wallet/src/wallet/identity/network/payments.rs Outdated
CI:
- `cargo fmt` on the long test signature.
- Android's JNI vtable builds `PersistenceCallbacks` by literal, so the two
  new slots have to be named there. Left `None`, matching the existing
  `on_persist_invitations_fn` precedent: Android keeps today's behaviour
  rather than reporting a reconstruction it cannot perform.

Review:
- Append the txid callbacks after `release_fn`. The struct is a C-bound
  vtable whose trailing slots are documented as end-safe; inserting before
  them would shift the layout for hosts built against the previous header.
- Replace the tautological buffer-length check with the check that matters:
  `count * 32` must not overflow and must fit in `isize::MAX` before
  `from_raw_parts` sees it. Adds the missing SAFETY note.
- Do not stamp the per-launch guard when the enumeration came back empty.
  After a restore the recurring sweep can fire before the host has
  repopulated its transaction table, and a zero-txid answer is
  indistinguishable from "nothing to reconstruct" — stamping there ended
  recovery for the rest of the process, the exact symptom this pass exists
  to fix. `..._skips_repeat_empty_sweeps` pinned that behaviour and is
  replaced by two tests: one that an empty enumeration is retried, one that
  a conclusive scan is not repeated.
- Guard the fault-loaded `account.wallet` access in `walletOwnsTransaction`
  the way `loadWalletList` already does; this predicate runs over every
  persisted transaction row, so the exposure is wider.
- Report failures from the txid callback. A nil argument or a failed fetch
  returned 0 with an empty list, which Rust could not tell from an empty
  wallet.
@romchornyi

Copy link
Copy Markdown
Author

Pushed 188b486 addressing both CI failures and all five review findings.

CI

  • cargo fmt on the long test signature.
  • The Kotlin build broke because rs-unified-sdk-jni builds PersistenceCallbacks by literal, so the two new slots must be named there. Left None, following the existing on_persist_invitations_fn precedent — list_wallet_core_txids then returns an empty list and Android keeps today's behaviour rather than reporting a reconstruction it cannot perform. Wiring the Android side is a separate change.

Review

  • End-appended slot rule — good catch, the convention is documented twice in this very struct and I broke it. Both callbacks now sit after release_fn.
  • Tautological length check — replaced with the check that matters: count * 32 must not overflow and must fit in isize::MAX, validated before from_raw_parts sees it. Added the missing SAFETY note.
  • Empty enumeration stamping the guard — agreed, and this is the more serious of the two data-integrity findings. Fixed: the guard is stamped only when the enumeration was non-empty. ..._skips_repeat_empty_sweeps pinned the wrong behaviour and is replaced by two tests — ..._retries_after_empty_enumeration (empty is inconclusive, enumerate again) and ..._skips_repeat_sweeps_after_a_real_scan (a conclusive scan is not repeated, and the second pass does no per-record reads).
  • Faulted account.wallet — guarded via the Optional cast, same as loadWalletList.
  • Callback reporting success on failure — a nil argument or a failed fetch now returns non-zero; walletCoreTxids returns an errored flag instead of collapsing a database fault to []. This one compounded the guard bug: a fetch failure would silently have marked every contact swept.

cargo test -p platform-wallet: 520 passed, 0 failed. cargo fmt --check --all clean, cargo check clean for platform-wallet-ffi and rs-unified-sdk-jni, and the Swift SDK builds for both device and simulator slices.

…payment-reconstruction

# Conflicts:
#	packages/rs-unified-sdk-jni/src/persistence.rs
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.61%. Comparing base (13c3a23) to head (2d352b9).

Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4300      +/-   ##
============================================
- Coverage     87.61%   87.61%   -0.01%     
============================================
  Files          2704     2704              
  Lines        345206   345182      -24     
============================================
- Hits         302446   302417      -29     
- Misses        42760    42765       +5     
Components Coverage Δ
dpp 88.83% <ø> (ø)
drive 86.25% <ø> (ø)
drive-abci 89.66% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.88% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 48.02% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`clippy::type_complexity` is denied workspace-wide and the inline tuple
annotation tripped it. Extracting `OwnerContact` and `ContactScriptIndex`
also gives the script-pubkey keying an obvious place to be explained.
@thepastaclaw

thepastaclaw commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Opus deferred (commit 2d352b9)
Canonical validated blockers: 1

`ffi_capability_projection_has_stable_v1_layout_values` pins the callback
vtable's size and asserts the last-appended field is terminal. Both move
when a slot is added, exactly as they did for invitations and then for
`release_fn`.

The two txid callbacks sit after `release_fn`, so no previously-defined
slot changes offset — which is the property that actually matters for
hosts built against an older header, and the reason growth is only ever
safe at the end.

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

Preliminary review — Codex only

The implementation adds useful sent-payment recovery coverage, but verification confirms five in-scope blockers. The new FFI slots break the existing vtable ABI, while the reconstruction can fabricate sent payments or permanently omit records after partial reads, partial writes, and restores involving more than 20 payment addresses.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Opus: not run (deferred by blocker gate)

🔴 5 blocking

🤖 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.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:683-714: Appending callbacks does not preserve the FFI vtable ABI
  Appending fields preserves existing offsets but not binary compatibility for a struct passed through `*const PersistenceCallbacks`. `platform_wallet_manager_create_impl` copies the value with `std::ptr::read(persistence)`, so the new library reads the full 24-slot or 40-slot struct. A host compiled against the previous 22-slot or 38-slot definition allocated only the old extent, making manager creation read beyond that object before it can determine that the new callbacks are unset. Adjacent memory can then be interpreted as callback pointers and invoked. Preserve the old struct size and expose these hooks through a size/version-negotiated v2 structure, a separate extension structure, or a new creation entry point.

In `packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:258-268: One successful write prevents retrying another failed payment
  This contact-level guard treats one existing `Sent` entry as proof that the contact's entire outgoing history is complete. If transaction A is persisted successfully and transaction B fails, `write_failed_for` correctly avoids setting the completion marker, but the next sweep skips the contact because A now exists. A restart also restores A and continues skipping B, so the failed entry is permanently stranded despite the retry log. Eligibility must use a genuine completion marker or retry state; the per-txid `contains_key` check later in the loop already provides idempotence for successfully recorded entries.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:306-309: Unavailable listed transactions are treated as a completed scan
  A txid returned by `list_wallet_core_txids` followed by `Ok(None)` is not a conclusive absence. The FFI implementation maps callback failures, missing or empty transaction bytes, decode failures, unknown contexts, and every InstantSend record to `Ok(None)`; Swift can also enumerate placeholder rows whose bytes are populated later. This arm leaves `had_read_error` false, so a nonempty enumeration stamps every eligible contact as completed and prevents later sweeps from reconsidering the unavailable record. This also exposes the broader contract mismatch: `get_core_tx_record` documents that `transaction` may be a placeholder, but this reconstruction requires a complete decoded output list. Treat unavailable listed records as an incomplete scan or introduce a dedicated history API that guarantees a decoded transaction.
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:270-277: Freshly rebuilt accounts only search the first 20 payment addresses
  The candidate index contains only scripts currently materialized in the external account pool. `ManagedCoreFundsAccount::from_account` initializes a `DashpayExternalAccount` with 20 addresses, and live sends derive index 20 and above only after prior addresses have been marked used. After a seed restore that rebuilds the contact account without its historical pool state, persisted or rescanned transaction history can contain payments to later indices while the candidate set contains only indices 0 through 19. The sweep then records the early matches and marks the contact complete, permanently omitting later payments. Derive a sufficient historical range from the contact xpub or restore/reconstruct the pool's used range before setting the completion marker.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:5805-5807: Incoming and third-party transactions can be recorded as sent payments
  `walletOwnsTransaction` includes transactions through wallet outputs and `involvedAccounts`, not only transactions spending wallet-owned inputs. A `DashpayExternalAccount` is watch-only but is matched whenever an output pays one of the contact's derived addresses, so an unrelated third-party transaction paying that contact is persisted as wallet-involved. An incoming transaction that pays both this wallet and the contact is also included. The Rust sweep then assumes every matching contact output was funded by this wallet and records it as `Sent`, producing false payment history. Enumeration or the returned record must carry reliable per-wallet outgoing ownership, and reconstruction must reject transactions without a wallet-owned input.

Comment on lines +683 to +714
/// Enumerate the persisted Core txids that belong to `wallet_id`.
///
/// Appended at the END so the struct layout stays stable — a host
/// built against the previous vtable keeps working, it simply never
/// sets these two slots.
///
/// Used by DashPay sent-payment reconstruction to walk the local
/// transaction history without requiring the optional in-memory
/// `transactions()` map to retain finalized records.
///
/// Output contract:
/// - Set `*out_txids` to a contiguous buffer of `32 * *out_count`
/// bytes, one raw-wire txid per 32-byte chunk, and `*out_count`
/// to the number of txids returned.
/// - Set `*out_txids = null` and `*out_count = 0` when no rows
/// exist for the wallet.
/// - Return `0` on success; non-zero values are treated as backend
/// failures by the Rust side.
pub on_list_wallet_core_txids_fn: Option<
unsafe extern "C" fn(
context: *mut c_void,
wallet_id: *const u8,
out_txids: *mut *const u8,
out_count: *mut usize,
) -> i32,
>,
/// Paired free callback for the txid buffer returned by
/// [`Self::on_list_wallet_core_txids_fn`]. Rust invokes this with
/// the same pointer and txid count, exactly once per successful
/// hit.
pub on_list_wallet_core_txids_free_fn:
Option<unsafe extern "C" fn(context: *mut c_void, txids: *const u8, count: usize)>,

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: Appending callbacks does not preserve the FFI vtable ABI

Appending fields preserves existing offsets but not binary compatibility for a struct passed through *const PersistenceCallbacks. platform_wallet_manager_create_impl copies the value with std::ptr::read(persistence), so the new library reads the full 24-slot or 40-slot struct. A host compiled against the previous 22-slot or 38-slot definition allocated only the old extent, making manager creation read beyond that object before it can determine that the new callbacks are unset. Adjacent memory can then be interpreted as callback pointers and invoked. Preserve the old struct size and expose these hooks through a size/version-negotiated v2 structure, a separate extension structure, or a new creation entry point.

source: ['codex']

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.

Resolved in e3b193cAppending callbacks does not preserve the FFI vtable ABI no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +258 to +268
// Only a `Sent` entry proves this contact's outgoing history is
// already present. `reconcile_incoming_payments` runs first and
// records `Received` entries, so testing for "any payment with
// this contact" hid every send to a contact we had also
// received from — the incoming pass filled the map, this guard
// read it as done, and the sends were never reconstructed.
if managed.dashpay().payments.values().any(|payment| {
payment.counterparty_id == contact
&& payment.direction == PaymentDirection::Sent
}) {
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: One successful write prevents retrying another failed payment

This contact-level guard treats one existing Sent entry as proof that the contact's entire outgoing history is complete. If transaction A is persisted successfully and transaction B fails, write_failed_for correctly avoids setting the completion marker, but the next sweep skips the contact because A now exists. A restart also restores A and continues skipping B, so the failed entry is permanently stranded despite the retry log. Eligibility must use a genuine completion marker or retry state; the per-txid contains_key check later in the loop already provides idempotence for successfully recorded entries.

source: ['codex']

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.

Resolved in e3b193cOne successful write prevents retrying another failed payment no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +306 to +309
let record = match self.persister.get_core_tx_record(&txid) {
Ok(Some(record)) => record,
Ok(None) => continue,
Err(e) => {

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: Unavailable listed transactions are treated as a completed scan

A txid returned by list_wallet_core_txids followed by Ok(None) is not a conclusive absence. The FFI implementation maps callback failures, missing or empty transaction bytes, decode failures, unknown contexts, and every InstantSend record to Ok(None); Swift can also enumerate placeholder rows whose bytes are populated later. This arm leaves had_read_error false, so a nonempty enumeration stamps every eligible contact as completed and prevents later sweeps from reconsidering the unavailable record. This also exposes the broader contract mismatch: get_core_tx_record documents that transaction may be a placeholder, but this reconstruction requires a complete decoded output list. Treat unavailable listed records as an incomplete scan or introduce a dedicated history API that guarantees a decoded transaction.

Suggested change
let record = match self.persister.get_core_tx_record(&txid) {
Ok(Some(record)) => record,
Ok(None) => continue,
Err(e) => {
Ok(None) => {
had_read_error = true;
tracing::warn!(
%txid,
"reconcile_sent_payments_from_tx_history: listed tx record is unavailable; will retry next sweep"
);
continue;
},

source: ['codex']

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.

Resolved in e3b193cUnavailable listed transactions are treated as a completed scan no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +5805 to +5807
let txids = rows.compactMap { tx in
Self.walletOwnsTransaction(walletId: walletId, transaction: tx) ? tx.txid : nil
}

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: Incoming and third-party transactions can be recorded as sent payments

walletOwnsTransaction includes transactions through wallet outputs and involvedAccounts, not only transactions spending wallet-owned inputs. A DashpayExternalAccount is watch-only but is matched whenever an output pays one of the contact's derived addresses, so an unrelated third-party transaction paying that contact is persisted as wallet-involved. An incoming transaction that pays both this wallet and the contact is also included. The Rust sweep then assumes every matching contact output was funded by this wallet and records it as Sent, producing false payment history. Enumeration or the returned record must carry reliable per-wallet outgoing ownership, and reconstruction must reject transactions without a wallet-owned input.

source: ['codex']

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.

Resolved in e3b193cIncoming and third-party transactions can be recorded as sent payments no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +270 to +277
let pools = account.managed_account_type().address_pools();
let Some(pool) = pools.first() else {
continue;
};
eligible.push((owner, contact));
for address_info in pool.addresses.values() {
out.entry(address_info.script_pubkey.clone())
.or_insert((owner, contact));

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: Freshly rebuilt accounts only search the first 20 payment addresses

The candidate index contains only scripts currently materialized in the external account pool. ManagedCoreFundsAccount::from_account initializes a DashpayExternalAccount with 20 addresses, and live sends derive index 20 and above only after prior addresses have been marked used. After a seed restore that rebuilds the contact account without its historical pool state, persisted or rescanned transaction history can contain payments to later indices while the candidate set contains only indices 0 through 19. The sweep then records the early matches and marks the contact complete, permanently omitting later payments. Derive a sufficient historical range from the contact xpub or restore/reconstruct the pool's used range before setting the completion marker.

source: ['codex']

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.

Resolved in e3b193cFreshly rebuilt accounts only search the first 20 payment addresses no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

@bfoss765

bfoss765 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Notes from the Android side — we hit the same class of problem, so two things that may be useful here. No action needed from us: the None wiring in rs-unified-sdk-jni keeps this inert on Android, which is correct for now (we don't run dashpay_sync() yet, and we derive contact attribution from transaction history on every read rather than from PaymentEntry).

1. Blocker: walletOwnsTransaction can record incoming/third-party payments as Sent

We ran into exactly this. Matching a transaction because it pays a contact's watch-only derived address is true for any payer, so an incoming payment (or a third party paying that same contact) matches too.

What worked for us was to stop asking "does this tx touch the contact's address" and instead compute a signed net per party, explicitly excluding the contact's watch-only external account from both sides of the sum. On Android that's the DashPay external account (type 13): its TXOs are deliberately excluded from both the sent and received sums, with an address-level fallback for rows where the account id is NULL. Direction then falls out of the sign of the net rather than out of address membership, so an incoming payment can't be misfiled as Sent.

Reference, if it helps: SdkTxContactResolver.signedNetsFor in the Android wallet (dash-wallet), which carries the on-device evidence for the NULL-account fallback case.

2. ABI caveat on the vtable extension

PersistenceCallbacks grows from 22 → 24 slots here (38 → 40 with shielded), and platform_wallet_manager_create_impl does a std::ptr::read(persistence), so it reads the full new extent. A host compiled against the previous header would be over-read.

Android is immune — build_vtable constructs the struct in Rust inside the same crate graph, so the definitions can't disagree. iOS is exposed if the XCFramework header and the Swift app are ever built/versioned separately. Might be worth a version guard or a size field on the struct if that's a real possibility in your release flow.

One design note, offered only as a data point: because Android recomputes attribution from the transaction store on every read (checking both the sending and receiving friend key chains), it has no persisted-cache asymmetry to lose on restore — the cache is derived, not a source of truth. That's a different trade-off from the PaymentEntry model (more work per read, nothing to rebuild), not necessarily a better one, but it's why the restore symptom doesn't reproduce for us.

@romchornyi

Copy link
Copy Markdown
Author

On the vtable ABI

@thepastaclaw @bfoss765 — you both flagged this and the facts aren't in dispute. platform_wallet_manager_create_impl does std::ptr::read(persistence), so it reads the full struct extent. This PR grows PersistenceCallbacks from 22 to 24 slots (38 → 40 with shielded), and a host compiled against the previous header would be read past the end of the object it allocated — with those bytes interpreted as callback pointers. Appending after release_fn preserves every existing offset, but it does not fix that.

Two things worth putting on the record.

This is a property of the struct, not of this change. The same growth happened twice already — on_persist_invitations_fn, then release_fn, each documented in place as "appended at the END so the struct layout stays stable", with the size test re-pinned each time. If the pattern is unsound it is unsound retroactively, and the fix belongs to the struct rather than to this PR.

Who is exposed today. Android builds build_vtable in Rust inside the same crate graph, so the two definitions cannot disagree. iOS is exposed only if the XCFramework header and the consuming app are ever built or versioned separately; in the current flow build_ios.sh regenerates the header and the package together. That makes this a latent hazard rather than a live one — though "latent" is exactly what bites when a release flow changes.

Recommendation: accept the growth here, consistent with the two prior appends, and track versioned negotiation separately so it covers every future slot at once rather than just these two. A size/version-negotiated struct or a new creation entry point changes the contract for every host, which is its own review, not a rider on a DashPay payment-history fix.

If you'd rather hold this PR until that structure exists, that's a fine call too — but then the enumeration callback should be designed together with the per-transaction ownership data the sweep needs (the walletOwnsTransaction thread below). Both live on the same surface, and reworking that FFI twice would be wasted effort.

Either way I'd like it to be a decision rather than an omission, so I'm leaving the call to maintainers.

Meanwhile the two "concluded more than the evidence supports" findings are fixed in 82dd151, and I'm working the two correctness blockers that don't depend on this: false Sent entries from third-party or incoming transactions, and the 20-address derivation window.

@thepastaclaw

Copy link
Copy Markdown
Collaborator

Thanks for making the tradeoff explicit. I agree the underlying extensibility flaw predates this PR, but this change still creates a new incompatible size boundary: a host built against the current header can be over-read when paired with the new library. The prior appends are precedent for the pattern, not evidence that another append is safe. From my review perspective this remains blocking unless a Platform maintainer explicitly confirms that lockstep XCFramework/header/app builds are the supported ABI contract and accepts no cross-version compatibility, or the callback surface gains size/version negotiation (ideally together with the ownership data now being reworked). The PR head is still 4dfc454; I’ll re-review the pushed correctness fixes when a new head lands.

…ial scans

Addresses the four reconstruction blockers from review:

- Contact eligibility no longer treats one existing Sent entry as
  proof of completion. A write that fails after a sibling write
  succeeded is retried on the next sweep; per-txid dedup already
  makes re-sweeping recorded entries a no-op.

- A listed txid whose record resolves to Ok(None) now marks the scan
  incomplete. The FFI collapses backend failures, missing/undecodable
  tx bytes and pending InstantSend rows into a miss, so a miss on a
  txid the host itself enumerated means "not available yet" — the
  completion guard stays unstamped and the sweep retries.

- Candidate scripts are derived from the contact xpub over the
  historical range (matched index + gap limit, iterated to a fixed
  point) on a pool clone, instead of matching only the addresses the
  restored pool materialized. Payments past the initial gap window
  are now found after restore-from-seed.

- The txid enumeration carries a per-txid spends-wallet-input flag
  computed by the host from persisted TXO rows (inputs tracked under
  a watch-only DashPay external account do not count). Transactions
  the wallet did not fund — a third party paying the watched contact
  address, or incoming payments — are skipped without a record read
  and can no longer fabricate Sent history.

The get_core_tx_record field contract now requires the real decoded
transaction (or None); reconcile_sent_payments reuses the shared
finality helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer

Copy link
Copy Markdown
Member

Pushed e3b193c addressing the review blockers.

Partial writes stranded a sibling payment (payments.rs:258-268) — Fixed. Eligibility no longer consults the payment map at all; it is gated only by the per-launch sent_payment_reconcile_attempted guard, and the guard is stamped only after a complete, fully-persisted pass. A write that fails after a sibling succeeded is retried on the next sweep (per-txid dedup keeps the recorded sibling a no-op). Regression test: …retries_failed_write_after_sibling_success.

Ok(None) treated as a completed scan (payments.rs:306-309) — Fixed. A listed txid that resolves to Ok(None) now marks the scan incomplete, so the guard stays unstamped and the sweep retries until every wallet-funded record was actually read (pending-InstantSend rows resolve when the tx mines). The get_core_tx_record trait contract was also tightened: transaction must be the real consensus-decoded transaction or the backend must return None — placeholder bodies are no longer permitted. Regression test: …retries_when_listed_record_is_unavailable.

Only the first 20 addresses searched after restore (payments.rs:270-277) — Fixed. The sweep now clones the contact pool, and extends it from the contact xpub (KeySource::Public) using standard BIP44 recovery semantics: whenever an observed output matches a derived script, generation continues through matched index + gap_limit, iterated to a fixed point. Historical usage always chains within the gap limit (live sends only derive index N after earlier indices were used), so this covers the full recoverable range. Runs on a clone outside the wallet lock; resident state is untouched. Regression test: …finds_payments_past_initial_gap_window.

Incoming/third-party transactions recorded as sent (PlatformWalletPersistenceHandler.swift:5805-5807) — Fixed. The (new-in-this-PR) enumeration callback now returns a per-txid flags buffer alongside the txids; bit 0x01 = "spends at least one input funded by this wallet's own spendable accounts". Swift computes it from persisted PersistentTxo input rows — a TXO tracked under the watch-only DashPay external account (tag 13) does not count, since those are the contact's coins. The Rust sweep skips unfunded transactions without a record read, so a third party paying the watched contact address (or an incoming tx paying both wallet and contact) can no longer fabricate Sent history. Regression test: …skips_transactions_wallet_did_not_fund.

Appending callbacks breaks the FFI vtable ABI (persistence.rs:683-714) — Not changed, deliberately. There is no supported deployment where a host compiled against the old 22/38-slot struct loads this library: every consumer of PersistenceCallbacks ships from this monorepo (the Swift package statically links the xcframework built from the same commit whose generated header defines the struct; rs-unified-sdk-jni compiles in-workspace), and pre-release the FFI makes no cross-version ABI guarantee. Append-at-end is the repo's established mechanism for growing this vtable — the invitations slots and the release_fn destructor landed the same way — and the size-pin test exists precisely to make each append a deliberate, reviewed act (updated here for 24/40). Since the enumeration slots are new in this PR, their signature change to carry the flags buffer is not itself a break. If out-of-tree hosts with independent update cadences ever become a supported target, size/version negotiation should be introduced for the struct as a whole rather than piecemeal in this PR.

Also applied CodeRabbit's suggestions: the read-error/write-failure retry paths are now covered by the two tests above, and reconcile_sent_payments reuses sent_payment_status_for_record so "final" has one definition.

Verified: cargo test -p platform-wallet --lib (524 passed), cargo test -p platform-wallet-ffi with and without shielded (vtable pin + terminal-field assertions), cargo clippy clean vs baseline, swift build of SwiftDashSDK against the regenerated FFI headers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/rs-platform-wallet-ffi/src/persistence.rs (1)

2829-2841: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Define buffer ownership when the callback returns non-zero.

_txid_guard is constructed before the rc check, so Rust invokes on_list_wallet_core_txids_free_fn even when the callback reported failure. The sibling contracts differ: on_load_wallet_list_fn documents "on failure Rust does not call the free callback", and load builds its LoadGuard only after the rc check (lines 2099-2110). The new doc at lines 719-722 says "exactly once per successful hit", which contradicts the code.

A host that writes both buffers and then returns non-zero, and frees them itself, would double-free. The in-repo Swift shim leaves outTxids nil on every failure path, so it is unaffected today.

Pick one semantic and make the code and the doc agree. Either move the guard construction after the rc check, or state in the callback doc that Rust frees whatever pointers the host wrote, including on failure.

Also note the narrower gap in the guard itself: it keys on self.txids.is_null(), so a host that sets only flags_ptr leaks the flags buffer.

🛡️ Proposed fix: construct the guard after the status check
-        let _txid_guard = TxidBytesGuard {
-            txids: txids_ptr,
-            flags: flags_ptr,
-            count,
-            free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
-            ctx: self.callbacks.context,
-        };
-
         if rc != 0 {
             return Err(PersistenceError::backend(format!(
                 "on_list_wallet_core_txids_fn returned non-zero status {rc}"
             )));
         }
+        let _txid_guard = TxidBytesGuard {
+            txids: txids_ptr,
+            flags: flags_ptr,
+            count,
+            free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
+            ctx: self.callbacks.context,
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-ffi/src/persistence.rs` around lines 2829 - 2841,
Move the TxidBytesGuard construction in the callback handling flow to after the
rc != 0 error check, matching the ownership contract that Rust does not invoke
the free callback on failure. Update the associated callback documentation to
state that buffers are freed exactly once only for successful results, and
adjust TxidBytesGuard cleanup to release flags_ptr independently when txids_ptr
is null so a flags-only allocation cannot leak.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet/src/wallet/identity/network/payments.rs`:
- Around line 413-442: Set incomplete_scan before the range-walk loop for each
window whose key_source cannot derive, including empty pools and pools with no
observed matches. Preserve the existing warning and once-per-window behavior,
then remove the now-redundant !window.key_source.can_derive() branch inside the
loop while leaving derivable pools’ range-walk behavior unchanged.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 5805-5813: Update walletFundedTransaction to resolve each TXO’s
wallet ID through its linked account wallet when txo.walletId is empty, matching
the fallback used by loadWalletList. Compare the resolved ID with walletId while
preserving the existing external-account exclusion and current behavior for
populated walletId values.

---

Outside diff comments:
In `@packages/rs-platform-wallet-ffi/src/persistence.rs`:
- Around line 2829-2841: Move the TxidBytesGuard construction in the callback
handling flow to after the rc != 0 error check, matching the ownership contract
that Rust does not invoke the free callback on failure. Update the associated
callback documentation to state that buffers are freed exactly once only for
successful results, and adjust TxidBytesGuard cleanup to release flags_ptr
independently when txids_ptr is null so a flags-only allocation cannot leak.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bb94c2a-f618-457b-bfaa-99fb7866fbe4

📥 Commits

Reviewing files that changed from the base of the PR and between ef9b0df and e3b193c.

📒 Files selected for processing (6)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/changeset/mod.rs
  • packages/rs-platform-wallet/src/changeset/traits.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-platform-wallet/src/wallet/persister.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift

Comment thread packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
@QuantumExplorer

Copy link
Copy Markdown
Member

Note for reviewers: the "Kotlin SDK build + tests" failure on this run is pre-existing v4.2-dev breakage, not this PR — #4015 renamed a Swift test without updating the SDK parity manifest, so the check fails on every PR against the current base. Fix is up in #4304 (CI green); once it merges, re-running the Kotlin job here will clear it. Everything else on this PR's latest commit is green.

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

Preliminary review — Codex only

Carried-forward prior findings: none remain actionable—four are fixed, and the FFI ABI concern is intentionally deferred under the maintainer-confirmed lockstep deployment contract. Latest delta: two blocking recovery gaps remain around partially populated transaction history and legacy TXO wallet attribution, plus one FFI ownership suggestion; the missing-xpub report is refuted by the supported account-pairing invariant, and all 11 focused Rust reconstruction tests pass but do not cover the retained edge cases.
Source: reviewers gpt-5.6-sol (general, rust-quality, ffi-engineer); verifier gpt-5.6-sol; openclaw-agent coordinator is orchestration-only.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Opus: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 1 suggestion(s)

🤖 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/wallet/identity/network/payments.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/payments.rs:531-553: A partially populated transaction table is treated as complete
  The completion condition distinguishes an empty enumeration from a completed scan, but any nonempty prefix is still treated as conclusive. The code and its regression test explicitly acknowledge that `dashpay_sync()` can run while the host is repopulating transaction history after restore. If one historical row exists when this snapshot is fetched but later rows have not arrived yet, every listed record can be read successfully and `txid_count > 0` causes the contact to be marked attempted. Rows added after that snapshot are then ignored for the rest of the process because the in-memory guard suppresses future sweeps. Completion needs an explicit host/core-history completeness signal, a transaction-history generation or high-water mark, or another mechanism that cannot certify a snapshot while restoration is still adding rows.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- [BLOCKING] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:5805-5812: Legacy TXOs are classified as unfunded after restore
  `PersistentTxo.walletId` defaults to empty for rows migrated from before the denormalized field existed, and `loadWalletList` already resolves those rows through `txo.account.wallet.walletId`. `walletFundedTransaction` instead compares only the raw denormalized field, so a real spend of an untouched legacy TXO is reported as not wallet-funded. Rust skips that transaction and can still stamp the contact as swept, leaving its sent payment absent for the process lifetime. The earlier `walletOwnsTransaction` helper has the same raw-field-only checks for outputs, inputs, and pending inputs, so a transaction composed entirely of legacy TXOs can be filtered out before the funding classifier runs. Use one shared wallet-resolution helper in both ownership selection and funding classification: prefer a populated `txo.walletId`, otherwise resolve through the linked account wallet, while continuing to exclude DashPay external account type 13 from wallet-funded inputs.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/persistence.rs:2822-2841: Align txid-buffer cleanup with callback failure ownership
  The callback documentation says Rust invokes the paired free callback exactly once for a successful result, but `TxidBytesGuard` is installed before `rc` is checked, so a non-null buffer returned with a failure status is also freed. That conflicts with a host following the documented success-only ownership transfer and can double-free if the host retains responsibility for failure-path allocations. The guard also runs only when `txids` is non-null, leaking a flags-only allocation from a malformed successful callback. The in-repository Swift callback returns errors before allocating, so this is not a current supported-host crash, but the new FFI contract should be internally consistent: check `rc` before installing the guard and invoke the free callback when either successful output pointer is non-null.

Comment on lines +531 to +553
// An enumeration that came back empty proves nothing: after a restore
// the recurring `dashpay_sync()` can fire before the host has finished
// repopulating its transaction table, and a zero-txid sweep is
// indistinguishable from a wallet that genuinely has nothing to
// reconstruct. Stamping the guard there would end recovery for the
// rest of the process — the exact symptom this pass exists to fix.
// Retrying costs one enumeration per sweep, without the per-record
// reads, until the wallet actually has transactions. The same logic
// gates on `incomplete_scan`: a pass that could not read every
// wallet-funded record (or could not derive a contact's historical
// address range) has not proven anything about the records it missed.
if !incomplete_scan && txid_count > 0 {
for window in &windows {
if write_failed_for.contains(&(window.owner, window.contact)) {
continue;
}
let Some(managed) = info.identity_manager.managed_identity_mut(&window.owner)
else {
continue;
};
managed
.dashpay_sent_payment_reconcile_attempted_mut()
.insert(window.contact);

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: A partially populated transaction table is treated as complete

The completion condition distinguishes an empty enumeration from a completed scan, but any nonempty prefix is still treated as conclusive. The code and its regression test explicitly acknowledge that dashpay_sync() can run while the host is repopulating transaction history after restore. If one historical row exists when this snapshot is fetched but later rows have not arrived yet, every listed record can be read successfully and txid_count > 0 causes the contact to be marked attempted. Rows added after that snapshot are then ignored for the rest of the process because the in-memory guard suppresses future sweeps. Completion needs an explicit host/core-history completeness signal, a transaction-history generation or high-water mark, or another mechanism that cannot certify a snapshot while restoration is still adding rows.

source: ['codex']

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.

Resolved in 54758c2A partially populated transaction table is treated as complete no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +5805 to +5812
static func walletFundedTransaction(
walletId: Data,
transaction: PersistentTransaction
) -> Bool {
transaction.inputs.contains { txo in
txo.walletId == walletId
&& txo.account.map { $0.accountType != dashpayExternalAccountTypeTag } ?? true
}

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: Legacy TXOs are classified as unfunded after restore

PersistentTxo.walletId defaults to empty for rows migrated from before the denormalized field existed, and loadWalletList already resolves those rows through txo.account.wallet.walletId. walletFundedTransaction instead compares only the raw denormalized field, so a real spend of an untouched legacy TXO is reported as not wallet-funded. Rust skips that transaction and can still stamp the contact as swept, leaving its sent payment absent for the process lifetime. The earlier walletOwnsTransaction helper has the same raw-field-only checks for outputs, inputs, and pending inputs, so a transaction composed entirely of legacy TXOs can be filtered out before the funding classifier runs. Use one shared wallet-resolution helper in both ownership selection and funding classification: prefer a populated txo.walletId, otherwise resolve through the linked account wallet, while continuing to exclude DashPay external account type 13 from wallet-funded inputs.

source: ['codex', 'coderabbit']

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.

Resolved in 54758c2Legacy TXOs are classified as unfunded after restore no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment on lines +2822 to +2841
impl Drop for TxidBytesGuard {
fn drop(&mut self) {
if let (Some(free), false) = (self.free_fn, self.txids.is_null()) {
unsafe { free(self.ctx, self.txids, self.flags, self.count) };
}
}
}
let _txid_guard = TxidBytesGuard {
txids: txids_ptr,
flags: flags_ptr,
count,
free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
ctx: self.callbacks.context,
};

if rc != 0 {
return Err(PersistenceError::backend(format!(
"on_list_wallet_core_txids_fn returned non-zero status {rc}"
)));
}

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: Align txid-buffer cleanup with callback failure ownership

The callback documentation says Rust invokes the paired free callback exactly once for a successful result, but TxidBytesGuard is installed before rc is checked, so a non-null buffer returned with a failure status is also freed. That conflicts with a host following the documented success-only ownership transfer and can double-free if the host retains responsibility for failure-path allocations. The guard also runs only when txids is non-null, leaking a flags-only allocation from a malformed successful callback. The in-repository Swift callback returns errors before allocating, so this is not a current supported-host crash, but the new FFI contract should be internally consistent: check rc before installing the guard and invoke the free callback when either successful output pointer is non-null.

Suggested change
impl Drop for TxidBytesGuard {
fn drop(&mut self) {
if let (Some(free), false) = (self.free_fn, self.txids.is_null()) {
unsafe { free(self.ctx, self.txids, self.flags, self.count) };
}
}
}
let _txid_guard = TxidBytesGuard {
txids: txids_ptr,
flags: flags_ptr,
count,
free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
ctx: self.callbacks.context,
};
if rc != 0 {
return Err(PersistenceError::backend(format!(
"on_list_wallet_core_txids_fn returned non-zero status {rc}"
)));
}
impl Drop for TxidBytesGuard {
fn drop(&mut self) {
if let (Some(free), true) = (
self.free_fn,
!self.txids.is_null() || !self.flags.is_null(),
) {
unsafe { free(self.ctx, self.txids, self.flags, self.count) };
}
}
}
if rc != 0 {
return Err(PersistenceError::backend(format!(
"on_list_wallet_core_txids_fn returned non-zero status {rc}"
)));
}
let _txid_guard = TxidBytesGuard {
txids: txids_ptr,
flags: flags_ptr,
count,
free_fn: self.callbacks.on_list_wallet_core_txids_free_fn,
ctx: self.callbacks.context,
};

source: ['codex']

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.

Resolved in 54758c2Align txid-buffer cleanup with callback failure ownership no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Three findings from the review of e3b193c.

**A partially populated transaction table was treated as complete.**
Distinguishing an empty enumeration from a completed one was not enough:
any non-empty prefix still certified the snapshot. The sweep runs on a
timer, so it can read the table while a DashPay rescan is still
delivering rows into it — one early row makes `txid_count > 0`, the
per-launch guard gets stamped, and every row arriving afterwards is
ignored for the rest of the process.

`reconcile_dashpay_rescan` now records the tip it rewound from, and the
sweep refuses to certify a contact while `synced_height` is still below
that mark. In-memory like `rescan_triggered`, and self-healing for the
same reason: a relaunch restores `synced_height` at its high-water and
re-arms the mark with the re-triggered backfill.

**Legacy TXOs were classified as unfunded.** `PersistentTxo.walletId`
is empty on rows written before the denormalized field existed, and
`loadWalletList` already resolves those through the owning account's
wallet. Both `walletOwnsTransaction` and `walletFundedTransaction`
compared the raw field, so a real spend of an untouched legacy TXO read
as "not ours" — the transaction was skipped and the contact could still
be stamped. Both now share one resolver that prefers the populated field
and falls back to the account's wallet, while still excluding the
watch-only DashPay external account from funded inputs.

**Txid-buffer ownership contradicted its own contract.** The free
callback is documented as transferring ownership on success only, but
the guard was installed before the status check, so a buffer returned
alongside a failure was freed too — a double free for any host that
cleans up its own failed allocation. The guard now goes up after the
check and fires when either output pointer is non-null, so a flags-only
allocation from a malformed success can't leak.
@romchornyi

Copy link
Copy Markdown
Author

Pushed 54758c2 addressing the three findings on e3b193c.

A partially populated transaction table was treated as complete — fixed. Distinguishing empty from non-empty enumeration wasn't enough: any non-empty prefix still certified the snapshot, and the sweep runs on a timer, so one early row is sufficient to stamp the guard while the backfill is still delivering the rest.

reconcile_dashpay_rescan now records the tip it rewound from (DashPayState::rescan_backfill_target), and the sweep refuses to certify a contact while synced_height is below that mark. In-memory with the same self-healing contract as rescan_triggered: a relaunch restores synced_height at its monotonic high-water and re-arms the mark alongside the re-triggered backfill. Regression test …waits_for_the_rescan_backfill, verified to fail without the gate.

Legacy TXOs classified as unfunded — fixed. Added one shared resolvedWalletId(of:) that prefers the populated denormalized field and falls back to the owning account's wallet, exactly as loadWalletList already does. Used by both walletOwnsTransaction (outputs and inputs) and walletFundedTransaction, so a transaction composed entirely of legacy TXOs is neither filtered out of scope nor misclassified as unfunded. Type-13 exclusion on funded inputs is unchanged. PersistentPendingInput carries no account relationship, so it still compares its own walletId — noted inline; it is a newer row type written only by the current send path.

Txid-buffer cleanup vs failure ownership — fixed, and thanks for catching it: that guard placement was mine. It now goes up after the rc check, so a buffer returned alongside a failure stays the host's, and it fires when either output pointer is non-null so a flags-only allocation can't leak. The callback doc now states the success-only transfer explicitly rather than leaving it implied.

Missing-xpub branch — agreed with your refutation; left as is.

Verified: cargo test -p platform-wallet -p platform-wallet-ffi --all-features (671 + 230 + 26 + 6 + 9, 0 failed), cargo clippy --all-features -D warnings clean, cargo fmt --check clean, FFI builds for device and simulator slices, and the consuming iOS app builds against the regenerated headers.

Note the Kotlin job here is still the pre-existing v4.2-dev breakage from #4015 — unrelated to this branch, clears once #4304 merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift (1)

30-74: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the repository indentation width.

These changed Swift lines use 4-space indentation. The repository EditorConfig requires 2-space indentation. Reindent the changed lines.

As per coding guidelines, **/* requires 2-space indentation.

Also applies to: 1308-1309, 5832-5837

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 30 - 74, Reindent the changed Swift code to use the repository’s
required 2-space indentation instead of 4 spaces. Apply this consistently to the
shown resolvedWalletId and walletOwnsTransaction code and the additional changed
sections around lines 1308-1309 and 5832-5837, without altering behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`:
- Around line 30-74: Reindent the changed Swift code to use the repository’s
required 2-space indentation instead of 4 spaces. Apply this consistently to the
shown resolvedWalletId and walletOwnsTransaction code and the additional changed
sections around lines 1308-1309 and 5832-5837, without altering behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 13d7dc8b-cc3c-40e4-a0bd-76f82827e921

📥 Commits

Reviewing files that changed from the base of the PR and between e3b193c and 54758c2.

📒 Files selected for processing (5)
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs
  • packages/rs-platform-wallet-ffi/src/persistence.rs
  • packages/rs-platform-wallet/src/wallet/identity/network/payments.rs

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

Preliminary review — Codex only

One carried-forward blocking issue remains: sent-payment reconstruction can certify a partial transaction snapshot before the rescan coordinator arms its completeness target. The latest delta correctly fixes legacy TXO attribution and FFI buffer ownership, and no separate latest-delta defect survives verification. The FFI ownership test request is useful hardening but does not identify a current production defect.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Opus: not run (deferred by blocker gate)

🔴 1 blocking

1 additional finding(s) omitted (not in diff).

🤖 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/dashpay_sync.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/dashpay_sync.rs:467-489: A partially populated transaction table is treated as complete
  `sync_wallet_dashpay` invokes `reconcile_sent_payments_from_tx_history()` before `reconcile_dashpay_rescan()`, which is the only production path that writes `rescan_backfill_target`. On the first pass after contact discovery or restoration, the target is therefore still `None`; any non-empty current transaction prefix can pass the completion condition at `payments.rs:562-580` and stamp `sent_payment_reconcile_attempted`. The subsequent rescan rewinds the wallet and arms the target too late, because later passes exclude that contact at `payments.rs:284-290` before consulting the target. The `synced_height == 0` early return at `payments.rs:111-115` leaves the same gap during an initial genesis scan: once the first transaction appears, a partial scan can be certified without any target. The regression test at `payments.rs:3555-3646` manually preloads `Some(1000)` and therefore does not exercise the production coordinator ordering. Establish a conclusive settled-history signal before reconstruction, including for an initial scan, and add a test that runs the actual coordinator sequence.

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.

5 participants