Skip to content

fix(kotlin-sdk): reconcile the TXO store against the engine and repair restored address pools - #4439

Draft
HashEngineering wants to merge 3 commits into
dashpay:v4.3-devfrom
HashEngineering:fix/kotlin-sdk-txo-reconcile-v42dev
Draft

HashEngineering wants to merge 3 commits into
dashpay:v4.3-devfrom
HashEngineering:fix/kotlin-sdk-txo-reconcile-v42dev

Conversation

@HashEngineering

Copy link
Copy Markdown
Collaborator

Issue being fixed

The Room store (txos / transactions / core_addresses) diverged from the engine under rescan, interruption, and delivery loss — and because the engine is rebuilt FROM the store at launch (buildUtxoRestoreData), divergence graduated into visible fund loss on relaunch (testnet field case: 106.43 → 86.33 after restart; root causes fixed engine-side in dashpay/rust-dashcore#979).

Stacked on #4406 (this branch's base): the reconcile's shared TXO-insert path absorbs #4406's swept-tombstone semantics, so both writers honor the same rules. Depends on dashpay/rust-dashcore#979: the first commit pins rust-dashcore to that PR's head so CI builds; re-pin to the dev merge commit once it lands.

What was done

  • reconcileTxoStore — post-sync audit of the store against the engine's full inventory (new walletManagerAllUtxosJson JNI export): heals missing TXOs (insert-only, 100-conf gate), repairs the netAmounts those holes falsified, logs every action. Runs on the SPV SYNCED transition and every 30 min. Nonzero heals after fix: same block core chain lock height #979 = regression tripwire.
  • Restore-time address-pool repairrestore_core_address_pools resolves each pool's key source from the signing wallet and re-derives indices missing from the persisted rows (holes observed in the field made funds rescan-proof invisible; this also closes the rescan-vs-fresh-restore divergence).
  • Widened reconcile (review feedback): the engine inventory now carries spent outpoints (platform_wallet_account_spent_outpoints). Store rows the engine proves spent are flipped; rows the engine has never seen are LOGGED, never deleted; spent rows the engine disputes are LOGGED, never un-marked (a live spend racing the engine is indistinguishable from lost-release residue, and un-marking could double-spend). Watch-only DIP-15 contact rows are excluded from classification.
  • Bridge note: derive_new_utxos over updated records is included for consistency, with a reviewer note that from_changeset re-derives from records and ignores cs.new_utxos — that field may be vestigial and worth a follow-up decision.

How this was tested

  • Kotlin: 115 handler tests green on this base — fix(platform-wallet): act on swept transactions at the persistence seam #4406's five tombstone tests and eight reconcile tests (heal, idempotence, immature gate, spent-flip, never-remove, never-unmark, young-coin consistency, contact-row exclusion) pass together.
  • Rust: platform-wallet 688 + FFI 277 green against the fix: same block core chain lock height #979 pin.
  • Device (testnet, CoinJoin-heavy wallet, ~3,270 txs): the reconcile healed real field damage once (4 dropped change outputs, 21.08 DASH), then stayed permanently silent ("mirror consistent") across fresh restore, from-genesis rescan, an interrupted-and-resumed rescan, and kill+relaunch. Final app-published balance identical to an SDK-free dashj 22.4 wallet of the same seed: 106.43173749.
  • Fault injection: a genuinely-spent coin flipped back to spendable in the store was corrected by the next rescan cycle; the never-delete and never-unmark rules are pinned by tests.

Merge order: rust-dashcore#979 → re-pin here → #4406 → this.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

❤️ Share

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

@thepastaclaw

thepastaclaw commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

🕓 Review not started yet because this PR is a draft.

  • Request normal review — click when the PR is ready for review.
  • Request priority review — click to move this review to the front of the queue.

Commit 80d61e8. Normal review starts when eligible; priority review starts as soon as a slot is available.

@HashEngineering HashEngineering changed the title fix(kotlin-sdk): TXO-store reconcile + address-pool repair — the mirror survives what the engine survives fix(kotlin-sdk): reconcile the TXO store against the engine and repair restored address pools Aug 21, 2026
@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-txo-reconcile-v42dev branch 2 times, most recently from 590ee97 to ac7c9b2 Compare August 21, 2026 00:16

@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 reconciliation and restore repair target real persistence divergence, but four correctness defects can durably mark unconfirmed coins spent, repeatedly report or credit an insertion that never occurred, corrupt an already-correct transaction amount, and leave DashPay receiving pools sparse. Three additional in-scope issues weaken contact-row classification, make the periodic inventory scan quadratic in account count, and bypass the canonical outpoint conversion.
Source: Codex reviewer lanes codex-general, codex-rust-quality, and codex-ffi-engineer (exact backend model IDs were not supplied in the evidence); final verifier backend grok-4.5; orchestration-only openclaw-agent/cliproxy/gpt-5.6-sol is not reviewer evidence.

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
  • Sonnet: not run (deferred by blocker gate)

🔴 4 blocking | 🟡 3 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1345-1352: Do not persist unconfirmed inputs as spent
  The exact pinned key-wallet revision inserts every input of a recorded transaction into `spent_outpoints`, regardless of whether its context is mempool or in-block. Treating membership in that set as a confirmed spend contradicts this handler's established rule at lines 928-934 and 3073-3080: mempool-linked inputs remain unspent in Room so they can be restored and reclassified after restart. A reconciliation while a payment is unconfirmed therefore makes the coin durably spent; if the transaction is later abandoned and its release update is lost or interrupted, the deliberate never-unmark policy prevents every later reconciliation from recovering it. Export spender context/finality with each outpoint, or only flip rows for spends proven confirmed by another authoritative source.
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1288-1301: Only account for TXOs that were actually inserted
  `upsertUtxoRow` returns early when the parent transaction is still marked `isGloballySwept`, but it returns `Unit`, so this caller cannot distinguish that refusal from a successful insert. The reconciler then increments `inserted`, adds to `insertedDuffs`, and may update `netAmount` even though the TXO remains absent. A missed reinstatement record can leave exactly this stale tombstone while the engine authoritatively holds the output; every periodic pass then repeats the false heal and can repeatedly add the same amount. Make the helper report whether it materialized the row and perform all counters and amount repair only after a successful insert, or explicitly reconcile the stale swept state first.
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1298-1301: Do not infer netAmount from TXO presence
  A missing `txos` projection does not prove that the independently persisted transaction record omitted the output from `netAmount`. For example, a corrective transaction callback can already store the engine's recomputed net amount while delivery of the corresponding UTXO projection is omitted, or a TXO can disappear later without changing its parent transaction. In either case this unconditional delta overstates the transaction, and the newly inserted row makes the corruption permanent because later passes become no-ops. The inventory does not include an authoritative expected net amount, so repair must compare against one or recompute the amount from authoritative ownership data rather than infer it from row absence.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1321-1340: Resolve contact ownership through coreAddressId
  The exclusion only checks `row.accountId`, but production changeset writes leave that field null and route TXO ownership through `coreAddressId -> core_addresses.accountId`, as documented by `buildUtxoRestoreData` at lines 3065-3069. The regression test manually fills `accountId`, so it does not represent production contact rows. Resolve the effective account through `coreAddressId` when the direct FK is null in both reverse-pass loops; otherwise normal DIP-15 contact outputs are repeatedly reported as engine-unknown and defeat the intended regression-tripwire signal.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:4085-4113: Repair DashPay pools with their concrete account xpub
  Both DashPay variants reach this resolver, but the exact pinned implementation of `Wallet::key_source_for_account_type` explicitly returns `NoKeySource` for `DashpayReceivingFunds` and `DashpayExternalAccount`. The guard at line 4131 therefore always skips their hole repair. This is especially harmful for `DashpayReceivingFunds`: it is wallet-owned and funds-bearing, and its concrete account already carries the xpub needed to reconstruct missing addresses. Resolve the full `AccountType` through `wallet.accounts.account_of_type(account_type)` and use that account's xpub as `KeySource::Public`, retaining the existing helper as the fallback for special account types such as the BLS provider account. Add a restore test with a sparse DashPay receiving pool and a real signing wallet; the current test passes `None` and cannot exercise this path.

In `packages/rs-unified-sdk-jni/src/wallet_manager.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3205-3207: Snapshot all account inventories in one pass
  The JNI method enumerates all N accounts and then invokes separate per-account UTXO and spent-outpoint FFI accessors. Each accessor reacquires `wallet_manager.blocking_read()`, allocates `all_accounts()`, and linearly searches that N-element collection, making one full reconciliation O(N²) with 2N+1 lock acquisitions. DashPay creates accounts per contact, so this periodic scan scales with wallet history and runs both at sync completion and every 30 minutes. Add a manager-level snapshot that gathers account identity, UTXOs, and spent outpoints under one read lock, then expose it through one FFI operation.

In `packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs:655-661: Use the canonical OutPointFFI conversion
  `OutPointFFI` already implements `From<&dashcore::OutPoint>` and documents that implementation as the single authority for preserving the persistence join key and txid byte order. The new export manually duplicates the conversion. It is equivalent today but can silently diverge if the canonical representation changes, so route this export through the existing conversion boundary.

Comment on lines +1345 to +1352
key in engineSpentKeys -> {
// Lost spend update (#4425): the engine knows this
// coin was spent; the row missed the flip. Flip in
// place — spendingTxid stays as-is (usually null;
// the spender's row, if it ever arrives, relinks
// via the deferred-input drain).
database.txoDao().upsert(row.copy(isSpent = true))
flippedSpent++

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: Do not persist unconfirmed inputs as spent

The exact pinned key-wallet revision inserts every input of a recorded transaction into spent_outpoints, regardless of whether its context is mempool or in-block. Treating membership in that set as a confirmed spend contradicts this handler's established rule at lines 928-934 and 3073-3080: mempool-linked inputs remain unspent in Room so they can be restored and reclassified after restart. A reconciliation while a payment is unconfirmed therefore makes the coin durably spent; if the transaction is later abandoned and its release update is lost or interrupted, the deliberate never-unmark policy prevents every later reconciliation from recovering it. Export spender context/finality with each outpoint, or only flip rows for spends proven confirmed by another authoritative source.

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 2195b18Do not persist unconfirmed inputs as spent 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved in 2195b18 and still true after the rebase: the reverse pass only classifies and logs, nothing is flipped.

Comment on lines +1288 to +1301
upsertUtxoRow(
database, walletId, txid, vout, amount, address, scriptPubKey,
height,
isCoinbase = false,
isConfirmed = true,
isInstantLocked = false,
isLocked = isLocked,
)
inserted++
insertedDuffs += amount
if (priorTx != null && priorTx.transactionData.isNotEmpty()) {
if (database.transactionDao().addToNetAmount(txid, amount) > 0) {
netAmountRepairs++
}

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: Only account for TXOs that were actually inserted

upsertUtxoRow returns early when the parent transaction is still marked isGloballySwept, but it returns Unit, so this caller cannot distinguish that refusal from a successful insert. The reconciler then increments inserted, adds to insertedDuffs, and may update netAmount even though the TXO remains absent. A missed reinstatement record can leave exactly this stale tombstone while the engine authoritatively holds the output; every periodic pass then repeats the false heal and can repeatedly add the same amount. Make the helper report whether it materialized the row and perform all counters and amount repair only after a successful insert, or explicitly reconcile the stale swept state first.

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 2195b18Only account for TXOs that were actually inserted 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The refusal path no longer exists on v4.2-dev (no isGloballySwept flag). upsertUtxoRow is v4.2-dev's own insert discipline extracted into one helper and always writes, so the counter and the skippedSwept report field are gone.

Comment on lines +1298 to +1301
if (priorTx != null && priorTx.transactionData.isNotEmpty()) {
if (database.transactionDao().addToNetAmount(txid, amount) > 0) {
netAmountRepairs++
}

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: Do not infer netAmount from TXO presence

A missing txos projection does not prove that the independently persisted transaction record omitted the output from netAmount. For example, a corrective transaction callback can already store the engine's recomputed net amount while delivery of the corresponding UTXO projection is omitted, or a TXO can disappear later without changing its parent transaction. In either case this unconditional delta overstates the transaction, and the newly inserted row makes the corruption permanent because later passes become no-ops. The inventory does not include an authoritative expected net amount, so repair must compare against one or recompute the amount from authoritative ownership data rather than infer it from row absence.

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 2195b18Do not infer netAmount from TXO presence 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 2195b18: netAmount is never mutated; a healed TXO with a pre-existing record is only reported as a netAmountSuspect.

Comment on lines +4085 to +4113
let key_source = signing_wallet
.and_then(|wallet| {
key_wallet::transaction_checking::transaction_router::AccountTypeToCheck::try_from(
&*managed_type,
)
.ok()
.map(|check_type| {
let account_index = match &account_type {
AccountType::Standard {
index, ..
}
| AccountType::CoinJoin {
index,
}
| AccountType::DashpayReceivingFunds {
index, ..
}
| AccountType::DashpayExternalAccount {
index, ..
} => Some(*index),
AccountType::IdentityTopUp {
registration_index,
} => Some(*registration_index),
_ => None,
};
wallet.key_source_for_account_type(&check_type, account_index)
})
})
.unwrap_or(key_wallet::KeySource::NoKeySource);

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: Repair DashPay pools with their concrete account xpub

Both DashPay variants reach this resolver, but the exact pinned implementation of Wallet::key_source_for_account_type explicitly returns NoKeySource for DashpayReceivingFunds and DashpayExternalAccount. The guard at line 4131 therefore always skips their hole repair. This is especially harmful for DashpayReceivingFunds: it is wallet-owned and funds-bearing, and its concrete account already carries the xpub needed to reconstruct missing addresses. Resolve the full AccountType through wallet.accounts.account_of_type(account_type) and use that account's xpub as KeySource::Public, retaining the existing helper as the fallback for special account types such as the BLS provider account. Add a restore test with a sparse DashPay receiving pool and a real signing wallet; the current test passes None and cannot exercise this path.

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 2195b18Repair DashPay pools with their concrete account xpub 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 2195b18 (key source resolved from the signing wallet). DashPay contact pools are re-derived by contact sync at runtime; that skip is now logged at debug.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 80d61e8 as you describe: the resolver consults accounts.account_of_type(account_type) first and uses that account's xpub as KeySource::Public, keeping key_source_for_account_type as the fallback for the types account_of_type does not return (the BLS provider operator and Ed25519 platform-node accounts). New test dashpay_receiving_pool_holes_are_repaired_from_the_account_xpub restores a sparse DashPay receiving pool with a real signing wallet; without the fix it fails with 30 holes left at indices 20–49, with it every index up to the watermark is derivable. Leaving the thread for you to resolve.

Comment on lines +1321 to +1340
for (row in storeRows) {
if (!row.isSpent) continue
if (row.accountId != null && row.accountId in foreignAccountIds) continue
val key = "${row.txid?.toHex() ?: continue}:${row.vout}"
if (key in engineUnspentKeys) {
stuckSpent++
stuckSpentDuffs += row.amount
Log.w(
TAG,
"txos reconcile: store row spent but engine lists it " +
"unspent outpoint=$key amount=${row.amount} — LOG-ONLY " +
"(lost release, or a live spend racing the engine)",
)
}
}
val storeUnspent = storeRows.filter { !it.isSpent }
for (row in storeUnspent) {
if (row.accountId != null && row.accountId in foreignAccountIds) {
skippedForeign++
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.

🟡 Suggestion: Resolve contact ownership through coreAddressId

The exclusion only checks row.accountId, but production changeset writes leave that field null and route TXO ownership through coreAddressId -> core_addresses.accountId, as documented by buildUtxoRestoreData at lines 3065-3069. The regression test manually fills accountId, so it does not represent production contact rows. Resolve the effective account through coreAddressId when the direct FK is null in both reverse-pass loops; otherwise normal DIP-15 contact outputs are repeatedly reported as engine-unknown and defeat the intended regression-tripwire signal.

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 2195b18Resolve contact ownership through coreAddressId 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 2195b18: rowIsForeign resolves both accountId and coreAddressId → core_addresses.accountId, and the insert pass uses the inventory's account tag first.

Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs Outdated
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

@bfoss765 — this is the store-reconcile series from the job-flower investigation, retargeted off the integration branch per review: it now stacks on #4406 (whose swept-tombstone semantics the shared upsertUtxoRow absorbs — both test families pass together, 115 handler tests) and pins dashpay/rust-dashcore#979 for the engine-side halves. Merge order: rust-dashcore#979 → re-pin here → #4406 → this. The widened reconcile follows your Claude's review note: spent-flips are the only mutation; engine-unknown rows and disputed spent rows are log-only by design (rationale in the PR body). The fold role fix rides separately in #4438.

🤖 Generated with Claude Code

@romchornyi

Copy link
Copy Markdown
Contributor

Heads-up on the base, since this is stacked on chore/bump-rust-dashcore-dev-961 — GitHub already re-pointed it at 16e88914c4 and it still reports clean, so nothing is broken right now.

Two changes are landing on that branch that touch files you also touch, so it is worth knowing before you build further on them.

Already landed (16e88914c4): the sweep's payment-flip coupling was extracted out of #4406 into its own PR to shrink the review surface. That removed ~2,465 lines from core_bridge.rs, payments.rs, payment_handler.rs, persistence_capabilities.rs and rs-platform-wallet-ffi/src/persistence.rs. Your diff does not overlap that block, which is why the rebase was clean.

Coming next, and this one does overlap you: the swept-tombstone lifetime rule is being reworked. The current version stamps a tombstone with the height at which the sweep was observed and collects it after a fixed margin; that is unsound for an InstantSend-locked winner that stays unmined, so it is being replaced with the winner's actual mined height, carried on the event by dashpay/rust-dashcore#975. Concretely, on this branch that will change:

  • PlatformWalletPersistenceHandler.kt — no pending-input tombstone is created for a mempool-context sweep at all, and block-context tombstones are keyed on the winner's height rather than heldSinceHeight
  • PlatformWalletPersistenceHandlerTest.kt — the tombstone-collection tests change shape with it
  • rs-platform-wallet-ffi/src/persistence.rsSweepBatchFFI gains the winner height, and the numeric chainlock height starts crossing the FFI instead of only opaque bytes
  • core_bridge.rs — the projection arm for the new field

It also needs a repin once #975 merges. Room schema v13 (heldSinceHeight on pending_inputs) will likely be reworked in the same pass, so if you are adding migrations, expect to renumber.

No action needed from you — the base moves under you and your PR keeps rebasing — but if you are about to write anything in the tombstone or pending_inputs area of the Kotlin handler, it is worth syncing first so we do not both edit the same lines. Happy to sequence it either way: land yours first and I rebase the rework onto it, or the reverse, whichever is less disruptive for you.

HashEngineering added a commit to HashEngineering/platform that referenced this pull request Aug 21, 2026
…ot prove

thepastaclaw review round on dashpay#4439, all four blockers:

- The spent-flip is demoted to LOG-ONLY (wouldFlipSpent): the engine's
  spent set records every input of every recorded transaction including
  MEMPOOL spends, with no context — persisting the flip would settle an
  unconfirmed spend, contradicting this handler's own in-block gating.
  Re-arm as a mutation only when the engine exports spends with context.
- upsertUtxoRow reports whether it wrote: a globally-swept-parent refusal
  is now visible to the reconcile (skippedSwept), which no longer counts
  phantom heals nor flags netAmounts for rows that were never inserted.
- The netAmount repair is demoted to LOG-ONLY (netAmountSuspects): a
  corrective record callback can land while its TXO delivery races this
  sweep, and blind addition double-credits. The event pipeline owns net
  correctness; the reconcile reports the suspicion.
- The restore-time pool repair announces every pool it cannot repair
  (DashPay contact pools have no public key source by design and re-derive
  through DashPay sync; hardened pools cannot be publicly derived).

Plus the review suggestions: contact-row exclusion now resolves ownership
through coreAddressId -> core_addresses.accountId (production rows leave
txos.accountId null, so the accountId-only check was ineffective), the
neither-inventory log names the finalized-drop ambiguity, and the JNI
spent-outpoint export uses the canonical OutPointFFI conversion.

The reconcile is now fully observe-and-heal-forward: its only mutation is
inserting provably-owned engine UTXOs.

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

Copy link
Copy Markdown
Collaborator Author

Review round addressed in 2195b18 — all four blockers plus the suggestions:

  1. Unconfirmed spends: the spent-flip is demoted to LOG-ONLY (wouldFlipSpent). Correct call — the pinned engine's spent set includes mempool spends with no context, and persisting the flip would settle an unconfirmed spend against this handler's own in-block gating. It returns as a mutation only if/when the engine exports spends with their confirmation context. The reconcile's only remaining mutation is inserting provably-owned engine UTXOs.
  2. Refusals counted as heals: upsertUtxoRow now reports whether it wrote; a globally-swept-parent refusal surfaces as skippedSwept and neither increments inserted nor triggers any netAmount handling. Pinned by reconcileDoesNotCountSweptRefusalsAsHeals.
  3. netAmount inference: demoted to LOG-ONLY (netAmountSuspects) for exactly the race described — a corrective record callback landing while its TXO delivery races the sweep would be double-credited by blind addition. The event pipeline (rust-dashcore#979's corrections) owns net correctness; the reconcile reports the suspicion with the stored value in the log line.
  4. DashPay pool repair: the loader now announces every pool it cannot repair instead of silently claiming coverage. Left as a skip rather than implemented: both DashPay variants return no public key source by design (contact keys derive from identity material, not an account xpub), and contact pools re-derive through DashPay contact sync at runtime — observed converging on-device within seconds of identity sync. Deriving them at restore time from identity keys is a real follow-up, not a loader patch.

Suggestions: contact-row exclusion now resolves ownership through coreAddressId → core_addresses.accountId (confirmed against production rows — txos.accountId is null there, so the previous check was ineffective; new test pins the routed path), the neither-inventory log names the finalized-drop ambiguity, and the JNI export uses the canonical OutPointFFI conversion. The one-pass inventory snapshot is deferred — the reconcile runs twice an hour on a sub-second call, and the pinned accessors are shared surface; happy to take it if reviewers want it now.

117 handler tests green (all reconcile semantics re-pinned to log-only behavior), FFI 277 green.

@romchornyi thanks for the base heads-up — will take the tombstone-lifetime rework and the #975 repin as a merge round here when it lands, and expect the v13 schema renumber.

🤖 Generated with Claude Code

@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Device validation of this branch exactly as pushed (f0f632e + the rust-dashcore#979 pin): full uninstall → seed restore → rescan → kill+relaunch on the CoinJoin-heavy testnet wallet. Reconcile reported mirror consistent with every counter zero at each SYNCED; 76 born-spent attributions fired live during discovery (first device run of #979's wallet-scope sweep); published balance 106.43173749 — equal to an SDK-free dashj wallet of the same seed — stable across all steps.

One composition note for reviewers: transaction-history net sums only converge with #4438 landed too. This branch heals coins and balances on its own, but multi-account history rows persist one account's slice until the record fold (#4438) is in — observed directly: the same wallet's history sum converges on a base that includes the fold and doesn't here. The two PRs are independent to merge but complementary in effect.

Also observed, expected but worth documenting: contact-payment coins land only after a DashPay backfill pass that needs an unlocked wallet session (deferred contact-crypto builds gate it) — a freshly restored wallet that is never unlocked shows a balance short by its incoming contact payments until first unlock.

🤖 Generated with Claude Code

@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 latest review round correctly demotes ambiguous spend and net-amount changes to logging, counts only successful inserts, resolves contact ownership through core addresses, and uses the canonical outpoint conversion. Three blockers remain: restored DashPay pools still skip repair despite carrying usable account xpubs, healed TXOs can lose their exported account ownership and disappear again on restart, and the periodic JNI inventory fully materializes an unbounded chain-inflatable snapshot in a mobile process. Source: Codex general, security-auditor, and FFI-engineer reviewer lanes (exact backend model IDs were not supplied); final verifier: Claude Agent SDK (exact backend model ID was not supplied); openclaw-agent/cliproxy/gpt-5.6-sol is orchestration-only and not reviewer evidence.

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 — security-auditor (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 3 blocking | 🟡 1 suggestion(s)

2 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`:
- [BLOCKING] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1299-1306: Preserve exported account ownership on healed TXOs
  The native inventory identifies the account that owns each UTXO, but this insertion ignores those tags. `upsertUtxoRow` preserves an existing `accountId` and sets `coreAddressId` only when the corresponding `core_addresses` row already exists. If persistence lost both the TXO and its address row—the two divergence classes this PR is intended to repair—the reconciled row is inserted with neither ownership link. On the next launch, `buildUtxoRestoreData` can resolve ownership only through `txo.accountId` or the address relationship, so it skips the healed output and recreates the visible fund loss. Emit the complete account tuple already present in `AccountBalanceEntryFFI` (`typeTag`, `standardTag`, index, registration index, key class, and DashPay identity IDs), resolve the Room account during reconciliation, and persist its ID on the healed TXO even when the address projection is absent.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1327-1330: Interpolate values in the healed-TXO diagnostic
  Each dollar sign is escaped with `${'$'}`, so the diagnostic emits literal placeholders such as `${txid.toHex()}:$vout`, `$amount`, and `${priorTx.netAmount}`. That removes the outpoint and values needed to investigate the nonzero reconciliation heal this warning is intended to identify.

In `packages/rs-unified-sdk-jni/src/wallet_manager.rs`:
- [BLOCKING] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3202-3348: Do not materialize an unbounded chain-controlled inventory
  This periodic operation stores one formatted `String` per UTXO and spent outpoint, duplicates them through `join`, builds another complete JSON string with `format!`, copies that value across JNI, and then Kotlin parses it into a full JSON DOM and constructs additional full-inventory hash sets. Inventory cardinality is unbounded and remotely inflatable because anyone who knows a watched address can repeatedly send dust outputs to it. On a memory-constrained mobile process, an attacker-inflated wallet can therefore make every SYNCED transition and 30-minute reconciliation allocate several simultaneous copies of the inventory, causing repeated allocation failure or process termination. Expose a bounded, cursor-based snapshot or stream/iterate records through a native callback or compact binary representation so neither Rust nor Kotlin must hold the complete serialized inventory at once.
- [SUGGESTION] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3205-3207: Snapshot all account inventories in one pass
  (existing thread: https://github.com/dashpay/platform/pull/4439#discussion_r3826530759)
  The JNI method first enumerates all N accounts and then invokes separate per-account UTXO and spent-outpoint accessors. Each accessor reacquires `wallet_manager.blocking_read()`, rebuilds `all_accounts()`, and linearly searches that N-element collection. One reconciliation therefore performs O(N²) account traversal and 2N+1 lock acquisitions. DashPay adds accounts per contact, and this path runs at every SYNCED transition and every 30 minutes. Add a manager-level inventory operation that gathers each account's identity, UTXOs, and spent outpoints under one read lock before exposing the snapshot through FFI.

In `packages/rs-platform-wallet-ffi/src/persistence.rs`:
- [BLOCKING] packages/rs-platform-wallet-ffi/src/persistence.rs:4085-4113: Repair DashPay pools with their concrete account xpub
  (existing thread: https://github.com/dashpay/platform/pull/4439#discussion_r3826530747)
  The resolver still routes both DashPay variants through `Wallet::key_source_for_account_type`, which the pinned key-wallet revision explicitly maps to `NoKeySource`. The subsequent `repairable` guard therefore skips every DashPay pool. This is not a cryptographic limitation: `build_wallet_start_state` reconstructs these ECDSA accounts with their persisted `account_xpub`, `AccountCollection::account_of_type` supports both full DashPay account variants, and the normal DashPay registration paths construct the same `Absent` address pools from `KeySource::Public(account.account_xpub)`. A sparse `DashpayReceivingFunds` pool consequently remains unable to recognize payments to omitted indices after cold restore until an unlocked contact-sync happens, so the restore-time repair promised by this PR is incomplete. Resolve the concrete full `AccountType` through `wallet.accounts.account_of_type(account_type)` and use its xpub as the public key source, retaining the existing helper as a fallback for special key accounts. Add a sparse DashPay receiving-pool restore test using a real wallet; the current test passes `None` and cannot exercise derivation.

Comment on lines 1299 to 1306
val wrote = upsertUtxoRow(
database, walletId, txid, vout, amount, address, scriptPubKey,
height,
isCoinbase = false,
isConfirmed = true,
isInstantLocked = false,
isLocked = isLocked,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Preserve exported account ownership on healed TXOs

The native inventory identifies the account that owns each UTXO, but this insertion ignores those tags. upsertUtxoRow preserves an existing accountId and sets coreAddressId only when the corresponding core_addresses row already exists. If persistence lost both the TXO and its address row—the two divergence classes this PR is intended to repair—the reconciled row is inserted with neither ownership link. On the next launch, buildUtxoRestoreData can resolve ownership only through txo.accountId or the address relationship, so it skips the healed output and recreates the visible fund loss. Emit the complete account tuple already present in AccountBalanceEntryFFI (typeTag, standardTag, index, registration index, key class, and DashPay identity IDs), resolve the Room account during reconciliation, and persist its ID on the healed TXO even when the address projection is absent.

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 4c6c6ec and kept: every row carries the account tuple, the reconcile resolves the Room account and stamps resolvedAccountId on the healed row (healedUnowned counts the unresolvable ones).

Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs Outdated
Comment on lines +1327 to +1330
"txos reconcile: healed TXO ${'$'}{txid.toHex()}:${'$'}vout " +
"(${'$'}amount duffs) has a pre-existing record whose " +
"netAmount may be short by that amount — LOG-ONLY, " +
"storedNet=${'$'}{priorTx.netAmount}",

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: Interpolate values in the healed-TXO diagnostic

Each dollar sign is escaped with ${'$'}, so the diagnostic emits literal placeholders such as ${txid.toHex()}:$vout, $amount, and ${priorTx.netAmount}. That removes the outpoint and values needed to investigate the nonzero reconciliation heal this warning is intended to identify.

Suggested change
"txos reconcile: healed TXO ${'$'}{txid.toHex()}:${'$'}vout " +
"(${'$'}amount duffs) has a pre-existing record whose " +
"netAmount may be short by that amount — LOG-ONLY, " +
"storedNet=${'$'}{priorTx.netAmount}",
"txos reconcile: healed TXO ${txid.toHex()}:$vout " +
"($amount duffs) has a pre-existing record whose " +
"netAmount may be short by that amount — LOG-ONLY, " +
"storedNet=${priorTx.netAmount}",

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed: the diagnostic now interpolates txid:vout, amount and storedNet.

@PastaPastaPasta
PastaPastaPasta marked this pull request as draft August 22, 2026 15:42
@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-txo-reconcile-v42dev branch from f0f632e to 7c1abcb Compare August 25, 2026 23:22
HashEngineering added a commit to HashEngineering/platform that referenced this pull request Aug 25, 2026
…ot prove

thepastaclaw review round on dashpay#4439, all four blockers:

- The spent-flip is demoted to LOG-ONLY (wouldFlipSpent): the engine's
  spent set records every input of every recorded transaction including
  MEMPOOL spends, with no context — persisting the flip would settle an
  unconfirmed spend, contradicting this handler's own in-block gating.
  Re-arm as a mutation only when the engine exports spends with context.
- upsertUtxoRow reports whether it wrote: a globally-swept-parent refusal
  is now visible to the reconcile (skippedSwept), which no longer counts
  phantom heals nor flags netAmounts for rows that were never inserted.
- The netAmount repair is demoted to LOG-ONLY (netAmountSuspects): a
  corrective record callback can land while its TXO delivery races this
  sweep, and blind addition double-credits. The event pipeline owns net
  correctness; the reconcile reports the suspicion.
- The restore-time pool repair announces every pool it cannot repair
  (DashPay contact pools have no public key source by design and re-derive
  through DashPay sync; hardened pools cannot be publicly derived).

Plus the review suggestions: contact-row exclusion now resolves ownership
through coreAddressId -> core_addresses.accountId (production rows leave
txos.accountId null, so the accountId-only check was ineffective), the
neither-inventory log names the finalized-drop ambiguity, and the JNI
spent-outpoint export uses the canonical OutPointFFI conversion.

The reconcile is now fully observe-and-heal-forward: its only mutation is
inserting provably-owned engine UTXOs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/rs-platform-wallet/src/manager/accessors.rs Outdated
Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs Outdated
minConfirmations = minConfirmations,
pageSize = pageSize,
engineUtxoPage = { cursor, limit ->
if (cursor == null) firstPage else page(cursor, limit)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The first-page prefetch is correct only under an invariant nothing enforces.

firstPage is fetched eagerly and smuggled back through a stateful closure keyed on cursor == null, purely to preserve the "return null on dead transport" signature. The comment states the load-bearing assumption — "The handler asks for the null cursor exactly once" — but reconcileTxos is a public method on another class with no such contract in its own docs.

Any future change there (a retry after a transport failure, a second sweep, restarting the insert pass) would silently replay a stale page and re-walk rows already applied, with no test covering it.

Cheaper and self-enforcing: pass ::page straight through and have reconcileTxos return null itself when the first page comes back null — the null-cursor call is already distinguishable inside the loop.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Gone (c5cbc44): reconcileTxoStore passes the page lambda straight through, and reconcileTxos returns null itself when the first page is null (test reconcileReturnsNoReportWhenTheFirstPageIsUnavailable).

* Must NOT be called from the handler's own [dispatcher] (it takes
* [callbackExclusion] and runs Room transactions).
*/
suspend fun reconcileTxos(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

reconcileTxos is ~400 lines holding two unrelated passes and 17 mutable counters in one scope.

The insert pass and the classification pass share exactly one piece of state — foreignAccountIds — yet every counter is live across both: skippedImmature/healedUnowned/netAmountSuspects/skippedSwept belong only to the first, wouldFlipSpent/stuckSpent/wouldRemove only to the second. The insert loop nests five deep (whilewithLockwithTransactionfor → the skip chain), and both passes declare local suspend funs inside it.

Any edit to either half currently requires reading the whole body to know which counters are in play. Two private functions — healMissingTxos(...) and classifyStoreRows(...), each returning its own half of the report — would leave reconcileTxos as: resolve foreignAccountIds, call both, merge, log.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Split (c5cbc44): healMissingTxos (+ healEngineRow per row) and classifyStoreRows each return their half (HealPass / ClassifyPass); reconcileTxos resolves the foreign account set, calls both, merges and logs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in c5cbc44 and carried forward: reconcileTxos is now ~80 lines that resolve the shared exclusion set, call healMissingTxos and classifyStoreRows, merge the two halves and log. Leaving the thread for you to resolve.

Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs Outdated
Comment thread packages/rs-platform-wallet/src/changeset/core_bridge.rs Outdated
mapNativeErrors {
WalletManagerNative.walletManagerUtxosPageJson(
managerHandle, walletId, network.ffiValue, cursor, limit,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[correctness — confirmed mechanism] The null-transport contract is unreachable: mapNativeErrors rethrows, it never returns null.

mapNativeErrors (DashSdkError.kt:610) catches DashSDKException and throws DashSdkError.fromNative(e) — and the JNI side (guard + take_pwffi_error) throws an SDK exception whenever the native call fails. So page(...) propagates an exception on transport failure rather than returning null:

  • val firstPage = page(null, pageSize) ?: return null — the documented "returns null when the first page fails" path never fires; callers get an exception instead.
  • In reconcileTxos, if (pageJson == null) { transportFailures++; break } (and the classify equivalent) never runs for real JNI failures — a mid-sweep transport error aborts the whole pass by exception, discarding the report the truncate-and-report design promises. maybeReconcileTxoStores swallows it via runCatching, so the partial-sweep telemetry is silently lost.

Either wrap the two lambdas in runCatching { ... }.getOrNull() so the handler's null-handling actually engages, or update the contract docs to exception semantics.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (c5cbc44): both transport lambdas wrap mapNativeErrors in runCatching { … }.getOrNull() and log the fault, so a mid-sweep native failure yields the partial report the truncate-and-report design promises.

if (!synced) return
val now = System.currentTimeMillis()
if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return
val tipHeight = (progress.filters?.currentHeight ?: 0L).toInt()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[correctness — plausible] tipHeight comes solely from progress.filters?.currentHeight; when the filters sub-phase is absent (has_filters == false maps to filters = null in SpvSyncProgressData.fromNative) or reports 0, maybeReconcileTxoStores returns before scheduling anything — silently, on every tick.

If dash-spv ever reports SYNCED without a filters sub-progress (filter sync disabled, or the phase dropped after completion), the entire heal this PR exists to deliver never runs, with no log line to say so. Consider falling back to headers?.currentHeight (or the wallet's synced height accessor) and logging when the sweep is skipped for want of a tip height.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (c5cbc44): falls back to headers.currentHeight and logs when neither height is available instead of returning silently.

Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/accessors.rs Outdated
/// page — the reconciler must still see every account that DID read, so
/// one faulted account cannot mask the others' repair.
#[no_mangle]
pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[conventions — packages/kotlin-sdk/CLAUDE.md] walletManagerUtxosPageJson violates the stated rule: "No JNI functions that stitch together existing Rust calls — add the composite to Rust instead."

This export stitches platform_wallet_manager_get_account_balances + per-account platform_wallet_account_utxos_page, and layers on its own cross-account ordering (account_sort_key), an ad-hoc <accountKeyHex>:<txidHex>:<vout> cursor format, hand-rolled hex and JSON encoders, and dashcore address encoding — all in the JNI shim, where the account-key layout can drift from AccountBalanceEntryFFI with no compile error. A wallet-wide paged accessor beside account_utxos_page_blocking in rs-platform-wallet would own the invariant once, and the Swift host would get it for free.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (ca3c739): the composite lives in rs-platform-wallet (wallet_utxos_page_blocking) and the FFI (platform_wallet_wallet_utxos_page); the JNI is a serializer over one call, and the Swift host gets the same walk.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The composite is gone. The export is a serde shim over one call, upstream's platform_wallet_wallet_utxos_page from #4638; account ordering, cursor semantics, the page bound and the address rendering all live in platform-wallet now, and the dashcore dependency is out of the JNI crate. Leaving the thread for you to resolve. #4439 (comment)

Comment thread packages/rs-platform-wallet-ffi/src/persistence.rs Outdated
HashEngineering added a commit to HashEngineering/platform that referenced this pull request Sep 2, 2026
…pay#4474)

The restore scan's ownership set did not include the CoinJoin
derivation chains unless the host's account-creation options (or the
host itself, pre-scan) supplied them. Change from a CoinJoin-funded
send then went to an unwatched CoinJoin-chain address and was
attributed foreign: the record was born with net = -(sum of inputs)
instead of -(payment + fee), and unspent CoinJoin change was missing
from the balance.

register_wallet now mirrors every BIP44 account index into a CoinJoin
account — while the wallet is still seed-bearing (before the
external-signable downgrade) and before ManagedWalletInfo::from_wallet,
so the chains ride the initial birth-floor scan checkpoint, the
address-pool snapshot, and the persisted account registrations. A
deliberate no-op for WalletAccountCreationOptions::None (no funds
accounts, nothing to misattribute).

Born-wrong records in stores restored before this fix heal through the
rescan a CoinJoin account registration triggers: the corrected records
arrive via the normal correction callbacks (rust-dashcore#979) and
compose with the Android host walker's durable class-level
corrections — no store-side net arithmetic (demoted to log-only in the
external-signable at load, so the SDK cannot derive the account itself
for such stores; load_from_persistor now logs the gap so a host that
neither creates nor registers the chain is visible.

Acceptance item 2 (unspent CoinJoin change in balance on the live
processing path) was already covered by dashpay#4439 + rust-dashcore#979; the
new nets-to-payment-plus-fee test pins it here for the restore shape.

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

Copy link
Copy Markdown
Collaborator Author

The latest testing of restoring large coinjoin wallets (testnet) are showing that this PR may not be fixing any problems that once were though to be problems.

More testing will be done to decide if this should be closed or kept open.

@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-txo-reconcile-v42dev branch from bc0d859 to c5cbc44 Compare September 8, 2026 23:04
@HashEngineering
HashEngineering changed the base branch from chore/bump-rust-dashcore-dev-961 to v4.2-dev September 8, 2026 23:04
@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 8, 2026
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Rebased and retargeted to v4.2-dev (no longer stacked on #4406 — its persistence seam landed on v4.2-dev as 5f58417). Old head kept at HashEngineering/platform:backup/kotlin-sdk-txo-reconcile-v42dev-pre-rebase.

Series now: one squashed rebase commit (upsertUtxoRow is v4.2-dev's own insert body extracted into the shared helper; the swept-refusal counter went with the tombstone flag it counted; the core_bridge updated.iter() UTXO re-emission is dropped), a temporary pin commit, then three review-fix commits:

  • ca3c739580 platform-wallet/ffi — the composite the JNI used to stitch is one Rust accessor (wallet_utxos_page_blocking, (AccountType, OutPoint) order under one read lock) exported as platform_wallet_wallet_utxos_page with the account tuple on every row; the never-consumed account_spent_outpoints surface and the per-account page export are deleted; loader log fixed.
  • 15e56599d0 unified-sdk-jni — serde over that one call (UtxoPage/UtxoPageRow/UtxoPageCursor); no hand-rolled JSON/hex/cursor; a foreign cursor throws.
  • c5cbc448ae kotlin-sdk — typed EngineUtxoPage/EngineUtxoRow with the strict default Json, reconcileTxos split into healMissingTxos / classifyStoreRows, null returned by the handler when the first page is unavailable (no prefetch closure), runCatching around both native lambdas, header-tip fallback, addToNetAmount deleted.

Every thread has an individual reply. Pin: temporarily HashEngineering/rust-dashcore@e5cbb13b = v4.2-dev's own pin 93260bf + dashpay/rust-dashcore#979, until #979 lands (this branch needs its spent_outpoints accessor).

Tests: platform-wallet / platform-wallet-ffi / rs-unified-sdk-jni green except shield_input_selection_tests::regression_reports_max_from_usable_suffix_not_total_account_balance, which fails identically on clean v4.2-dev (reserve-constant precondition, unrelated). kotlin-sdk: 441 unit tests green.

@HashEngineering

Copy link
Copy Markdown
Collaborator Author

we are updating this PR and will retest it.

HashEngineering and others added 3 commits September 18, 2026 13:30
A persisted address pool has been observed arriving sparse (a 2026-08-19
field wallet was missing BIP44 change rows 875..=890 between surviving
ones). Ingesting the sparse list as-is makes every output paying a missing
address permanently unrecognizable — no rescan repairs it, because the
row-derived `highest_generated` suppresses the gap-limit re-derivation
that otherwise would. Derivation is pure key arithmetic, so re-derive
every missing index up to the persisted watermark at load.

The key source comes from the pool's own account
(`accounts.account_of_type`), not from `key_source_for_account_type`.
That helper routes through `extended_public_key_for_account_type`, which
has no arm for either DashPay variant and so answers `NoKeySource` for
both — skipping the repair on exactly the account type that needs it
most. `DashpayReceivingFunds` is wallet-owned and funds-bearing, and its
account already carries the xpub the missing addresses derive from; an
unrepaired hole there makes a contact's payments to us invisible. The
helper stays as the fallback for the types `account_of_type` deliberately
does not return: the BLS provider operator account and the Ed25519
platform-node account.

Never fatal: a failed repair restores exactly what the rows carried.

Requires `AddressPool::ensure_contiguous_to` (rust-dashcore dashpay#979).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lassifier

dashpay#4638 landed the wallet-wide paged UTXO inventory and the outpoint
classifier in platform-wallet, and stopped at the C boundary — the Swift
host reaches them, Kotlin does not. These two exports carry them across
JNI and no further.

Each is a serialization shim over exactly ONE platform-wallet call. The
account ordering, the cursor semantics, the page bound, the address
rendering and every classification rule stay in `platform-wallet`, so
both hosts walk the identical inventory and get identical verdicts.
Nothing here decides anything: serde shapes the JSON against the same
field names the Kotlin side decodes, so a renamed key fails that decode
loudly instead of healing a defaulted row.

Paged rather than swept whole because inventory size is chain-controlled:
anyone who knows a watched address can keep sending dust to it, and a
periodic full-inventory read would let them decide how much a phone
allocates on every pass.

Classification queries carry the account tuple the STORE files each coin
under and the script it recorded. The engine checks that claim against
its own pools rather than trusting it, which is what lets it answer
`not-owned` — so a bare outpoint cannot ask the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Room `txos` mirror is write-behind with no feedback loop: a changeset
that fails to deliver an owned output leaves a permanent hole. Because
the engine is REBUILT from this mirror on restart, that hole graduates to
a fund loss on the next launch — observed in the field as the job-flower
106.43 -> 86.33 restart drop, where a rescan nondeterministically drops
the change outputs of sends funded from CoinJoin-account outputs.

Two bounded passes, sharing only the watch-only contact accounts they
both exclude:

  * the insert pass walks the ENGINE's paged inventory and inserts the
    rows the store lacks, one Room transaction per page. Insert-only and
    idempotent, so a sweep is many small commits rather than one large
    one. Only outputs at least `minConfirmations` deep are healed;
    immature holes age into the next sweep. The engine's own isConfirmed
    / isInstantLocked / isCoinbase travel with each row and are stamped
    as given — a coinbase output filed as an ordinary one would misstate
    maturity in the mirror the engine reloads from.

  * the classification pass is inverted: it pages the STORE and asks the
    engine about one page at a time, so neither side builds a set over a
    whole inventory. Every verdict is log-only. `known-uncredited` is the
    one class that is positive evidence (the owning account knows the
    funding txid, owns the script, does not hold the coin, and a funds
    account holds a MINED record spending it), and even that is only
    counted: flipping spent state is the one direction where a
    reconciler bug spends a coin the user still owns. Acting on it is a
    separate, reviewable change.

Watch-only DIP-15 contact coins are excluded on both sides — they are the
contact's money. The engine's inventory omits those accounts outright;
the store-side check resolves ownership through both the explicit account
link and the `core_addresses` projection production writes actually use,
because an accountId-only check silently classifies every contact row.

Triggered inside the SDK on the SPV SYNCED transition and every 30
minutes while synced, rather than left to each host: the hole it repairs
becomes a fund loss on the next engine reload if any host forgets to call
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

Reworked — the Rust half is upstream now

#4638 merged to v4.2-dev on 09-11 (and is on v4.3-dev, this PR's base) and
landed the same wallet-wide paged UTXO inventory this PR was adding, under the
same names. That is why the
branch went CONFLICTING: not a textual clash, but two implementations of one
feature. Rather than resolve it, I deleted our copy and rebound to upstream's.

Dropped (~1,070 lines) — upstream provides all of it:

file why
packages/rs-platform-wallet/src/manager/accessors.rs upstream's WalletUtxoRow carries every field ours did plus address, is_confirmed, is_instantlocked, is_coinbase
packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs upstream has platform_wallet_wallet_utxos_page, its free function, and a classifier
packages/rs-platform-wallet-ffi/src/core_wallet_types.rs upstream's WalletUtxoEntryFFI supersedes ours
the Cargo.toml / Cargo.lock pin change (307150b183) the engine pin is handled separately

Kept#4638 touched zero Kotlin files, so the reconcile itself is still
the only one: PlatformWalletPersistenceHandler.kt and its test, TxoDao.kt,
PlatformWalletManager.kt, WalletManagerNative.kt. And persistence.rs,
which has no upstream equivalent (ensure_contiguous_to appears nowhere on
v4.2-dev).

Rewritten: rs-unified-sdk-jni/src/wallet_manager.rs. Upstream stops at
the C boundary and ships no JNI binding, so this is still needed — but it is
now a serialization shim over one platform-wallet call each. Three things
changed beyond the rebind:

  • The address is no longer derived here. Upstream's row carries the
    engine's own Base58Check rendering, so the dashcore dependency and the
    network parameter are gone. An address computed in the shim could
    disagree with the one the engine credited the coin under, and the store
    keys its address rows by that string.
  • No page bounds are mirrored. limit passes through; the engine holds
    its own default and maximum.
  • The verdicts are not the same verdicts. Ours were
    0 unknown / 1 unspent / 2 spent, and the Kotlin deliberately refused to
    act on a 2 because the engine's spent set includes mempool spends.
    Upstream's are 0 unknown / 1 unspent / 2 known-uncredited / 3 not-owned,
    and known-uncredited requires a MINED record spending the outpoint — so
    it cannot be a mempool artifact. The constants were remapped rather than
    renamed. It is still log-only: flipping spent state is the one direction
    where a reconciler bug spends a coin the user still owns, and this pass is
    insert-only by contract. Acting on it belongs in its own PR.

Upstream's classifier also wants the account tuple and script the STORE filed
each coin under — it checks that claim against its own pools rather than
trusting it, which is what lets it answer not-owned. So the transport now
sends those, and a store row whose owner resolves through neither the explicit
link nor the core_addresses projection is counted rather than asked about.
Two new report counters cover both (misattributed, unresolvedAccount).

One behaviour improvement fell out for free: upstream's row carries the real
isConfirmed / isInstantLocked / isCoinbase, which a healed row now
stamps as given. The old transport had no field to carry them, so the heal
assumed isConfirmed = true, isCoinbase = false — filing a coinbase output as
an ordinary one misstates maturity in the mirror the engine reloads from.

Review threads

Of the 25 open, 22 are marked outdated and most pointed at code this rework
deletes. The three anchored to live code:

  • persistence.rs:4446 — "Repair DashPay pools with their concrete account
    xpub" (blocking).
    Fixed. The resolver now consults
    accounts.account_of_type(account_type) first and uses that account's
    account_xpub as KeySource::Public, keeping
    key_source_for_account_type as the fallback for the types
    account_of_type deliberately does not return (the BLS provider operator
    account, the Ed25519 platform-node account). New test
    dashpay_receiving_pool_holes_are_repaired_from_the_account_xpub: it fails
    without the fix with 30 holes left at indices 20–49, and passes with it.
  • PlatformWalletPersistenceHandler.kt:1374 — "reconcileTxos is ~400
    lines".
    Already addressed by c5cbc448a; the thread just was not
    resolved. reconcileTxos is now 80 lines that resolve the shared
    exclusion set, call healMissingTxos and classifyStoreRows, merge and
    log.
  • wallet_manager.rs:3341 — the JNI-stitching rule. The complaint named
    the composite (get_account_balances + per-account account_utxos_page),
    the hand-rolled cursor and JSON, and the address encoding in the shim. All
    of it is gone: one Rust call per export, serde on both sides, the address
    from the engine.

The two outdated persistence.rs nits (the if !repairable {} if repairable {} shape and the malformed log literal) are fixed in the current code too.

Tests

On this branch (v4.3-dev base):

./gradlew :sdk:testDebugUnitTest                                    456 passed, 0 failed
cargo check -p platform-wallet-ffi -p rs-unified-sdk-jni --tests   clean
cargo test -p platform-wallet-ffi --lib                             368 passed (includes the new DashPay repair test)

The same three commits also pass on a v4.2-dev base (platform-wallet 1123, platform-wallet-ffi 374,
full Kotlin suite). The Rust runs used a local #979 engine — see below.

One thing to know before merging

The address-pool repair calls AddressPool::ensure_contiguous_to, which
exists only on rust-dashcore #979. Both v4.3-dev and v4.2-dev pin
e4208c90, which predates it, so this branch does not build against that pin
as it stands. #979 merges onto
e4208c90 as a fast-forward, so the only thing needed is for #979 to land and
the platform pin to advance.

@HashEngineering
HashEngineering force-pushed the fix/kotlin-sdk-txo-reconcile-v42dev branch from c5cbc44 to 80d61e8 Compare September 18, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants