Skip to content

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

Open
bfoss765 wants to merge 1 commit into
v4.2-devfrom
fix/persist-classifier-external-926
Open

fix(platform-wallet): stop a contact's watch-only chain from defining the persisted transaction row#4363
bfoss765 wants to merge 1 commit into
v4.2-devfrom
fix/persist-classifier-external-926

Conversation

@bfoss765

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
    • Fixed contact watch-only transactions being incorrectly saved as transaction records or newly derived UTXOs.
    • Prevented block confirmations from reintroducing excluded transaction data.
    • Preserved address usage tracking, derived addresses, and cleanup of spent or stale UTXOs.
    • Improved handling for genuine incoming payments, internal transfers, and receival accounts.

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

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f48325e4-c84d-4f4d-baf4-6a5f75c9bb17

📥 Commits

Reviewing files that changed from the base of the PR and between 86f3878 and 98f171e.

📒 Files selected for processing (1)
  • packages/rs-platform-wallet/src/changeset/core_bridge.rs

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

@thepastaclaw

thepastaclaw commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 98f171e)

@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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants