Skip to content

fix(platform-wallet): stop a contact's watch-only chain from defining the persisted transaction row - #4363

Merged
QuantumExplorer merged 2 commits into
v4.2-devfrom
fix/persist-classifier-external-926
Aug 11, 2026
Merged

fix(platform-wallet): stop a contact's watch-only chain from defining the persisted transaction row#4363
QuantumExplorer merged 2 commits into
v4.2-devfrom
fix/persist-classifier-external-926

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #4353 — same change, recreated on a dashpay/platform branch per repo policy (no more personal-fork PRs). Commits and authorship unchanged; full review history on #4353.

Context: this completes dashpay/rust-dashcore#926

dashpay/rust-dashcore#926 (@romchornyi, merged 2026-08-07) established the policy:

A DashPay external account is watch-only by construction ... Its addresses derive from a contact's xpub, so this wallet can observe those outputs but can never sign for them. They are the contact's coins; this wallet only ever pays into them.

and applied it by dropping dashpay_external_accounts from ManagedAccountCollection::all_funding_accounts / _mut, which covers balance, account_balances, utxos and get_spendable_utxos in one place. dashpay_receival_accounts were deliberately kept — those derive from our xpub, so a contact paying into them really is money arriving.

That fixed the balance layer. The persist-time projection is the same rule's second home, and it was missed. This PR applies the identical policy there. The key-wallet pin on v4.2-dev (b056d07c) already contains #926, so the two layers currently disagree with each other: the in-memory balance excludes the contact's coins, the persisted store still counts them.

The defect

Upstream check_core_transaction emits one TransactionRecord per matched account (key_wallet::transaction_checking::wallet_checker). A payment to a contact matches two accounts, producing two records that share one txid:

record's account direction net_amount
funding (BIP44/BIP32/CoinJoin) Outgoing change - spent
DashpayExternalAccount Incoming +paid

The external account's record is not wrong about its own account — that chain did receive an output. It is wrong as a description of the wallet.

build_core_changeset projected both records into CoreChangeSet.records, and derive_new_utxos turned the contact's output into a wallet UTXO. The persisted transactions row is keyed by txid alone — there is no per-account dimension to disambiguate it, because transaction_account_involvements is only written for provider-key accounts (see follow-ups below). So whichever record is stored last defines the row, and the watch-only one is emitted last, since all_accounts visits the DashPay accounts after the standard ones.

Field-observed on a testnet device store (2026-08-09): every payment to a contact is persisted with direction=incoming and a positive net_amount — a 0.69998912 DASH payment away stored as +69998912 where the wallet's true net is -70000000. The paid output sits in txos with isSpent=0 indefinitely (only the contact's own spend could ever flip it), so any SQL-sum consumer reads a phantom balance. A tester's mainnet wallet shows the same signature ("+3.83 change instead of −0.1 payment").

What changed

packages/rs-platform-wallet/src/changeset/core_bridge.rs — one predicate plus the two projection sites that consume it:

  • is_contact_watch_only(record) — matches AccountType::DashpayExternalAccount { .. }, carrying the rationale and the feat(dashmate): replace js-drive-abci with rs-drive-abci  #926 link so the line does not get "fixed" back.
  • derive_new_utxos returns nothing for such a record. Direct counterpart of feat(dashmate): replace js-drive-abci with rs-drive-abci  #926 dropping those accounts from utxos() / get_spendable_utxos().
  • build_core_changeset omits them from CoreChangeSet.records on both paths: the per-record TransactionDetected first sighting, and the inserted/updated/matured lists of BlockProcessed. The confirmation path matters on its own — re-emitting the watch-only record when the block lands would re-clobber the row exactly as the first sighting did.

The funding account's record — already Outgoing with net = -(spent - change), exactly what balance() semantics imply — becomes the row that lands. No FFI or storage-schema change; CoreChangeSet and every persistence signature are untouched.

What deliberately did not change

Everything from the same event that is genuinely ours to remember is preserved, so the event is never dropped wholesale:

  • addresses_marked_used and account_highest_used — the contact's address pool must keep advancing or the wallet would pay the same contact address twice. is_empty_no_records() counts these, so a watch-only-only event still round-trips to the persister.
  • addresses_derived — gap-limit extensions on the contact's chain still persist.
  • derive_spent_utxos stays unfiltered — so a contact spending an output that a pre-fix build already persisted still clears that stale row. Covered by a test.
  • Detection, monitoring and filter membership are untouched, exactly as in feat(dashmate): replace js-drive-abci with rs-drive-abci  #926: the address set is built from all_accounts.

One intended behavioural consequence: a third party paying our contact (which we see, because we monitor that chain) no longer produces a wallet transactions row or TXO. Under #926's policy that is correct — those were never our coins — and it removes a second, quieter source of the same phantom balance.

Required for correct Android behavior

The Android wallet currently compensates for these records at read time. That correction is a workaround for wrong data on disk, not a fix: every other consumer of the same store reads the rows raw — iOS parity, and any future feature that trusts direction / net_amount / txos — and each would have to reinvent the same compensation. Fixing it at the point of persistence lets the Android read-time correction be retired.

Testing

cargo test -p platform-wallet --lib618 passed, 0 failed. No existing test encoded the old projection.
cargo test -p platform-wallet-ffi --lib — 261 passed, 0 failed.
cargo clippy -p platform-wallet --all-targets -- -D warnings and cargo fmt --check clean.

Eight new tests in contact_watch_only_projection_tests, built from the record pair a real contact payment produces:

  1. contact_directed_payment_persists_as_outgoing_and_negative — both records in one BlockProcessed; asserts exactly one record reaches the persister, Outgoing, net == -70_000_000, and that only the change output becomes a TXO.
  2. contact_watch_only_detection_persists_no_transaction_row — the standalone TransactionDetected path, where there is no sibling record in the batch to fall back on.
  3. funding_account_detection_of_the_same_payment_still_persists — the filter is scoped to the account, not the transaction.
  4. genuine_receive_still_persists_incoming_and_positive — unchanged: incoming, positive, TXO created.
  5. dashpay_receival_account_receive_is_unaffected — the boundary feat(dashmate): replace js-drive-abci with rs-drive-abci  #926 drew; receival accounts derive from our xpub and must keep their incoming/positive row.
  6. internal_transfer_is_unaffected — all outputs owned, none type-13: direction, net, both TXOs and the spent input all unchanged.
  7. confirmation_re_emit_does_not_reintroduce_the_watch_only_row — the updated list.
  8. contact_spend_still_clears_a_stale_pre_fix_txo — no transaction row, but the spent-TXO removal still fires.

The four fix-dependent tests (1, 2, 7, 8) were verified to fail without the change — reverting is_contact_watch_only to false reproduces the exact defect (records.len() is 2 where 1 is allowed). Tests 3–6 are guards and pass either way by design.

Notes / not in scope

Three adjacent items surfaced while tracing this, deliberately left for follow-up:

  1. Historical rows stay wrong. Rows persisted before this fix keep their bad direction / net_amount, and their phantom txos rows persist until the contact spends. Android's read-time correction covers its own surface; a one-time store-rewrite pass would be the general fix and is intentionally out of scope here.
  2. transaction_account_involvements is effectively unpopulated. It is written only when the account is a provider-key account and the transaction is a provider special tx (PlatformWalletPersistenceHandler), so ordinary payments never get a row — which is precisely why the txid-keyed transactions row has no per-account dimension and this collision was possible at all. Populating it generally would give per-account history a real join (the INNER JOIN transaction_account_involvements query in TransactionDao returns nothing for normal accounts today).
  3. txos.isInstantLocked has no consumer. It is written from the TXO's first sighting and restored on load, but nothing reads it to make a decision — and because TransactionInstantLocked carries no UTXO re-emit, a TXO first seen in mempool never has the flag flipped when its IS lock later arrives. Worth either wiring up or removing.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of contact watch-only transactions so they no longer appear as regular persisted transactions or newly derived spendable outputs.
    • Continued tracking address usage, derived addresses, and cleanup of spent outputs.
    • Corrected processing across outgoing contact payments, confirmations, genuine incoming payments, internal transfers, and stale output removal.
  • Maintenance
    • Updated the underlying wallet engine revision for improved compatibility and reliability.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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: 753cbc01-6328-4e71-bb64-208aa20f3ec8

📥 Commits

Reviewing files that changed from the base of the PR and between 08edcfd and 6b802c9.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • Cargo.toml
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs

📝 Walkthrough

Walkthrough

The wallet bridge now excludes contact watch-only records from persisted transactions and newly derived UTXOs. It retains usage, address, spend, and cleanup updates. Regression tests cover transaction and block-processing scenarios.

Changes

Contact watch-only filtering

Layer / File(s) Summary
Workspace dependency revision
Cargo.toml
Workspace rust-dashcore dependencies now reference the updated revision.
Transaction and UTXO filtering
packages/rs-platform-wallet/src/changeset/core_bridge.rs
Transaction detection, block processing, and new-UTXO derivation exclude contact watch-only records. Spend cleanup, usage deltas, and derived addresses remain active.
Regression coverage
packages/rs-platform-wallet/src/changeset/core_bridge.rs
Fixtures and tests cover outgoing payments, genuine receives, internal transfers, confirmations, and stale contact UTXO removal.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: quantumexplorer, lklimek

🚥 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 main change: preventing contact watch-only chains from defining persisted transaction rows.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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/persist-classifier-external-926

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

@thepastaclaw

thepastaclaw commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — next in queue (commit 6b802c9)
Queue position: 1/3
ETA: start ~10:50 UTC · complete ~11:01 UTC (median 11m across 30 recent reviews; 2 slots)
Queued 10m ago · Last checked: 2026-08-11 10:50 UTC

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Codex/Sol only (Phase 2 disabled)

The watch-only filtering fixes the transaction-row and new-UTXO classification, but the retained spent-UTXO cleanup is lost when the changeset crosses the FFI persistence boundary, so existing phantom TXOs do not self-heal on FFI-backed hosts. The tests also do not exercise the explicitly preserved contact address-pool deltas.
Source: codex-general reviewer backend gpt-5.6-sol; codex-rust-quality reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 2 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/changeset/core_bridge.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/core_bridge.rs:686-692: Preserve filtered contact spends through the FFI projection
  Filtering the watch-only record leaves its spent outpoints in `CoreChangeSet.spent_utxos`, which lets the native SQLite persister mark a stale pre-fix TXO spent. However, `WalletChangeSetFFI::from_changeset` explicitly ignores `CoreChangeSet.spent_utxos` and derives each account's `utxos_spent` only from the records retained in `cs.records` (`packages/rs-platform-wallet-ffi/src/core_wallet_types.rs:249-255, 360-368`). After this filter, a contact-only spend has no retained record, so FFI-backed hosts receive no spent outpoint and leave the historical phantom TXO unspent even after the contact spends it. The standalone `TransactionDetected` filter at lines 626-629 has the same behavior. Add an account-routed spent delta that the FFI conversion consumes independently of persisted transaction records, and cover the complete FFI conversion path with the stale-TXO regression fixture.
- [SUGGESTION] packages/rs-platform-wallet/src/changeset/core_bridge.rs:1396-1407: Exercise preserved contact address-pool deltas
  This test uses an empty `WalletManager`, and its event carries no `addresses_derived`. Therefore `collect_usage_deltas` takes the unknown-wallet return and the resulting changeset contains none of the `addresses_marked_used`, `account_highest_used`, or `addresses_derived` state that the PR explicitly promises to preserve. The assertions only verify record and UTXO suppression; they do not establish that a real `DashpayExternalAccount` advances its contact address pool or that a watch-only-only event remains persistable after filtering. Build the test around a manager containing a real external account and monitored contact address, then assert the usage and derivation deltas survive while `records` and `new_utxos` remain empty.

Comment on lines +686 to +692
cs.records.extend(
inserted
.iter()
.chain(updated.iter())
.chain(matured.iter())
.filter(|r| !is_contact_watch_only(r))
.cloned(),

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: Preserve filtered contact spends through the FFI projection

Filtering the watch-only record leaves its spent outpoints in CoreChangeSet.spent_utxos, which lets the native SQLite persister mark a stale pre-fix TXO spent. However, WalletChangeSetFFI::from_changeset explicitly ignores CoreChangeSet.spent_utxos and derives each account's utxos_spent only from the records retained in cs.records (packages/rs-platform-wallet-ffi/src/core_wallet_types.rs:249-255, 360-368). After this filter, a contact-only spend has no retained record, so FFI-backed hosts receive no spent outpoint and leave the historical phantom TXO unspent even after the contact spends it. The standalone TransactionDetected filter at lines 626-629 has the same behavior. Add an account-routed spent delta that the FFI conversion consumes independently of persisted transaction records, and cover the complete FFI conversion path with the stale-TXO regression fixture.

source: ['codex']

Comment on lines +1396 to +1407
async fn contact_watch_only_detection_persists_no_transaction_row() {
let (_, _, watch_only) = contact_payment_records();
let cs = build_core_changeset(&test_manager(), &transaction_detected(watch_only)).await;

assert!(
cs.records.is_empty(),
"a contact's watch-only chain must not define a wallet transaction row"
);
assert!(
cs.new_utxos.is_empty(),
"the contact's output must not become a wallet UTXO"
);

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: Exercise preserved contact address-pool deltas

This test uses an empty WalletManager, and its event carries no addresses_derived. Therefore collect_usage_deltas takes the unknown-wallet return and the resulting changeset contains none of the addresses_marked_used, account_highest_used, or addresses_derived state that the PR explicitly promises to preserve. The assertions only verify record and UTXO suppression; they do not establish that a real DashpayExternalAccount advances its contact address pool or that a watch-only-only event remains persistable after filtering. Build the test around a manager containing a real external account and monitored contact address, then assert the usage and derivation deltas survive while records and new_utxos remain empty.

source: ['codex']

QuantumExplorer added a commit to dashpay/rust-dashcore that referenced this pull request Aug 11, 2026
…es (#952)

A DashpayExternalAccount derives its addresses from the contact's xpub,
so its coins are the contact's, never this wallet's. That policy now has
two enforcement sites — balance/UTXO aggregation dropped the accounts
from all_funding_accounts (#926), and dashpay/platform#4363 filters the
same records out of its persistence projection — but each site hardcodes
its own account-type list, which can silently drift when a new
contact-owned account type is added.

Give the policy one canonical home: AccountType::is_contact_owned(),
with an exhaustive match so a new account type cannot compile without
deciding whether its coins are the wallet's or a contact's, plus a
delegating ManagedAccountType::is_contact_owned(). The #926 funding-
scope test now asserts all_funding_accounts agrees with the predicate.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
bfoss765 and others added 2 commits August 11, 2026 17:30
… the persisted transaction row

dashpay/rust-dashcore#926 established that a `DashpayExternalAccount` is
watch-only by construction — its addresses derive from a contact's xpub,
so they are the contact's coins and this wallet only ever pays into them
— and removed those accounts from `all_funding_accounts` so they stop
counting toward balance and UTXO aggregation.

The persistence seam is the same rule's second home, and it was missed.

Upstream `check_core_transaction` emits one `TransactionRecord` per
matched account, so a payment to a contact produces two records sharing
one txid: the funding account's (`Outgoing`, `net = change - spent`) and
the external account's (`Incoming`, `net = +paid`). `build_core_changeset`
projected both into `CoreChangeSet.records`, and `derive_new_utxos` turned
the contact's output into a wallet UTXO. Because the persisted
`transactions` row is keyed by txid alone — the
`transaction_account_involvements` table is only written for provider-key
accounts, so there is no per-account dimension to disambiguate — the
watch-only record defined the stored row. Field capture on testnet: a
0.69998912 DASH payment away was persisted as `direction=incoming`,
`netAmount=+69998912` instead of `-70000000`, and the paid output sat in
`txos` with `isSpent=0` indefinitely, inflating any SQL-sum balance.

Records owned by an external account are now excluded from the
persist-time projection: no transaction row, no new TXO. The funding
account's record — already correct — becomes the row that lands.

Everything genuinely ours from the same event is preserved: address-used
flips and highest-used watermarks (so contact address rotation keeps
working), derived-address rows, and `derive_spent_utxos`, which stays
unfiltered so a contact spending an output persisted by a pre-fix build
still clears the stale row.

Eight regression tests cover the record pair a real contact payment
produces, the standalone first-sighting path, the confirmation re-emit,
a genuine receive, a DashPay *receival* account receive (the boundary
#926 drew, which must stay incoming/positive), and an internal transfer.
The four fix-dependent ones were verified to fail without the change.
…y-wallet

rust-dashcore#952 (merged as 9c0e8742) gave the #926 policy a canonical
home: AccountType::is_contact_owned(), with an exhaustive match so any
future account type must declare whether its coins are the wallet's or
a contact's. Bump the workspace pin to the dev tip (37b1a361, which
also brings dash-spv sync-reliability fixes #941/#943/#949/#953) and
make is_contact_watch_only delegate to the upstream predicate instead
of matching DashpayExternalAccount locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the fix/persist-classifier-external-926 branch from 98f171e to 6b802c9 Compare August 11, 2026 10:38
@QuantumExplorer

Copy link
Copy Markdown
Member

Updated now that dashpay/rust-dashcore#952 merged:

Verified: cargo test -p platform-wallet --lib (660 passed, including the 8 projection regression tests), cargo check --workspace clean on the new pin, clippy --all-targets and --all-features --tests clean apart from a pre-existing unused-import warning in platform_addresses/mod.rs (unrelated to this PR).

@QuantumExplorer
QuantumExplorer merged commit 8b6b8fb into v4.2-dev Aug 11, 2026
18 checks passed
@QuantumExplorer
QuantumExplorer deleted the fix/persist-classifier-external-926 branch August 11, 2026 10:48
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.63%. Comparing base (08edcfd) to head (6b802c9).
⚠️ Report is 2 commits behind head on v4.2-dev.

Additional details and impacted files
@@            Coverage Diff            @@
##           v4.2-dev    #4363   +/-   ##
=========================================
  Coverage     87.63%   87.63%           
=========================================
  Files          2670     2670           
  Lines        339447   339447           
=========================================
+ Hits         297464   297465    +1     
+ Misses        41983    41982    -1     
Components Coverage Δ
dpp 88.86% <ø> (ø)
drive 86.25% <ø> (ø)
drive-abci 89.66% <ø> (+<0.01%) ⬆️
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.

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.

3 participants