Repository navigation
fix(kotlin-sdk): port #4638 to Android — credit verdicts at save time and the post-sync TXO reconcile - #5259
HashEngineering wants to merge 33 commits into
Conversation
…lassifier #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>
…eries
kotlinx's default Json omits a property equal to its default value, and
OutpointQuery defaulted standardTag/index/registrationIndex/keyClass to 0.
For the common BIP44 account-0 store row every one of those is 0, so the
query went out as {"typeTag":0,"txid":…,"vout":…,"scriptHex":…} and
rs-unified-sdk-jni's OutpointQuery (whose flattened UtxoAccountTuple
requires those fields) rejected every batch:
malformed outpoint queries: missing field `standardTag` at line 1 column 162
The reverse (store→engine) half of reconcileTxoStore therefore never
classified a single row on device. Drop the defaults so the fields are
always encoded; only the identity halves stay optional, matching the
native tuple. The test FakeEngine now rejects a batch missing any key the
native deserializer requires, which is why the existing reconcile tests
did not catch this.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The heal pass counted and logged healed coins whose transaction record might carry a short netAmount. It was log-only and is not part of #4638, which this branch ports; it can follow on its own. The heal still never touches netAmount, and the tests keep checking that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Register the persistence-extension slot on_persist_wallet_changeset_utxo_verdicts_fn that #4638 added and the JNI layer left unset. The new trampoline packs the round's verdicts into flat arrays (36-byte outpoint keys, verdict codes, observed- spent heights) and calls NativePersistenceBridge.onWalletChangesetUtxoVerdicts, whose default body ignores them, so a bridge that does not override it writes exactly what it wrote before. The descriptor is in the bridge verification table and pinned by a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation Kotlin port of #4638 Part 1. The handler stages the round's credit verdicts (they arrive before the changeset callback) and upsertUtxoRow applies them as Swift's upsertUtxo does: - observed_spent / doomed: the row is written spent, with no spender link; - any verdict vetoes the redelivery recovery clear (flag and sweep stamp); - uncredited changes nothing else; - verdicts live in the round buffer, so a rolled-back round leaves none. One log line per round that applied a verdict, counts only. Tests port BornSpentTxoPersistTests, including a reopened on-disk store restoring zero coins for a verdict-spent row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… TXOs Groundwork for the #4638 store reconcile, which asks the engine about a page of store rows outside the callback lock and applies the verdicts under it: - committedRoundGeneration, bumped when a changeset round's Room transaction commits (and on a standalone callback write), so a verdict read before a round that may have re-credited the coin is refused; - TxoDao.pageUnspentByWallet, the outpoint-cursor walk over a wallet's isSpent = 0 rows that the classify pass pages through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings PlatformWalletPersistenceHandler.reconcileTxos in line with Swift's runCoreTxoReconcile from #4638 Part 2; the Kotlin pass was carried over from #4439 and differed from it. Heal pass (engine -> store), Swift's gates in Swift's order: malformed row (txid not 32 bytes, no script, no address) is invalid; a coin the engine does not call confirmed, or under 100 confirmations, is immature; a coin already in the store is present; a coin whose seven-field account tuple names no store account is refused (no longer inserted unowned). Inserted rows go through upsertUtxoRow with isConfirmed = true, and a row the pending-input drain wrote spent is counted as healedSpent. The contact (DashpayExternalAccount) skip stays. Classify pass (store -> engine): only the wallet's isSpent = 0 rows are asked about, by outpoint cursor. A KnownUncredited verdict marks the row spent (no spender link, lastUpdated bumped) and drops the pending-input rows on it; Unspent, Unknown and NotOwned are counted only. Each page is read with committedRoundGeneration, classified outside the callback lock, and applied under it only if no round committed in between; otherwise the page is re-read, at most 5 times in a row. Every store step re-checks a caller cancellation flag under the lock. A failed engine read, the first page included, now yields an incomplete report instead of null; a failed store step rolls back and is counted. The report mirrors Swift's CoreTxoReconcileReport, and the logs carry counts and SHA-256 reference digests only: one summary line per run ("txos reconcile: mirror consistent (...)" when nothing changed) and one item line per healed / healed-spent / flipped row. Tests updated to the new semantics and ported from CoreTxoReconcileTests, CoreTxoReconcileShutdownTests and CoreTxoReconcilePrivacyTests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PlatformWalletManager.reconcileTxoStore(walletId) now follows Swift's reconcileCoreTxoStore(for:) from #4638 and returns a TxoReconcileOutcome: Skipped(reason) or Reconciled(report). Gates, in order: manager closed; wallet not loaded; a run for it already in flight; SPV not in steady state (SYNCED, or WAIT_FOR_EVENTS with the filter phase at a non-zero target); no scan tip (filter height, else header height); syncFaultDetected(); the wallet's durable watermark (the Room wallet row's syncedHeight) more than 6 blocks behind the tip. The automatic trigger runs on the rising edge into steady state and then every 30 minutes per wallet, stamped at schedule time, never while that wallet's run is in flight. closeSuspending() and removeWallet() bump a reconcile epoch, and the run stops at its next page or store step with completed = false; the run also holds a teardown-gate borrow so the native manager is never destroyed under an engine read. Skip, transport-failure and run-failure lines carry the reason or the exception type and a wallet reference digest only; the cursor and native error messages are no longer logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reconcile skipped itself at the SYNCED edge of a restore (WALLET_BEHIND_TIP) and, stamped at schedule time, did not try again for 30 minutes. The gate read the wallet's persisted Room syncedHeight, which trails the engine by up to one persistence round — exactly the window the SYNCED transition lands in. Swift gates on the engine's own value (platform_wallet_core_wallet_state). Bind it — one JNI export over that one FFI call — and gate on it, through a pure TxoReconcileGates.walletCaughtUp with its own test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
|
⛔ Final review complete — 2 blocking finding(s) (commit d7a2674) · triage: critical |
|
Bots are done — your move: post |
|
/self-review |
thepastaclaw
left a comment
There was a problem hiding this comment.
Final validation — Phase 1 + Phase 2
Verified the supplied findings against head 420996c and the stated Android reconciliation scope. Four non-blocking suggestions remain: restore/reconcile ownership parity, diagnostic-log privacy, strict inventory decoding, and transport-contract coverage. This was a static review; the supplied CI snapshot reports Kotlin build/tests passing, with Rust workspace tests and several builds still pending.
🟡 4 suggestion(s)
Review provenance
Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 7: gpt-6-astra (agent: phase2-reviewer, role: architecture-layering); reviewer 8: gpt-6-astra (agent: phase2-reviewer, role: ffi-engineer); reviewer 9: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); reviewer 10: gpt-6-astra (agent: phase2-reviewer, role: security-auditor); reviewer 11: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 12: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The intricate reconciliation and save-time verdict logic in PlatformWalletPersistenceHandler.kt, including upsertUtxoRow and classifyStoreRows, changes which coins are persisted as spendable and restored into the engine, directly affecting coin-selection eligibility. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-3.8-flash-high(antigravity below 15% reserve: weekly 15% left, 5h 100% left),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— general (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6-astra— security-auditor (completed, effort xhigh); agentphase2-reviewer - Model comparison: every Phase-2 reviewer also ran on
gpt-6-astra; the verifier weighed both sets without knowing which model wrote which
🤖 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`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:2043-2047: Resolve the same TXO owners during reconciliation and restore
The new resolver returns null when coreAddressId is absent, but buildUtxoRestoreData resolves the account through txo.address in that case. This difference is reachable: upsertUtxoRow leaves coreAddressId null when the address row does not yet exist, and the address-pool callback subsequently inserts that address without backfilling the TXO relationship. An unspent legacy row can therefore be eligible for restoration into Rust while every reconciliation run skips it as unresolved, even when the engine has positive evidence that it was spent. Share the restore path's address fallback with reconciliation and add a regression test where the address arrives after the TXO and a KnownUncredited verdict removes the row from the restore set.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:2130-2134: Avoid recoverable outpoint references in reconciliation logs
Each mutation logs an outpoint reference alongside a stable wallet reference. logReference computes an unkeyed SHA-256 digest truncated to six bytes, so a recipient of diagnostic logs can enumerate public blockchain outpoints, hash their wire-order txid plus little-endian vout, and match the reference. The wallet reference then groups those matched outputs into one wallet's history; truncation and the absence of literal identifiers do not prevent this lookup. Remove the outpoint-derived reference and retain aggregate diagnostics, or use a randomly keyed process-local reference whose key is never logged. Extend the privacy test beyond excluding raw identifiers and long hexadecimal strings. This is a non-blocking diagnostic-data exposure under the supplied severity policy, rather than a consensus or authentication violation.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1358-1362: Require inventory account fields to enforce fail-loud decoding
EngineUtxoRow and engineJson explicitly promise that missing routing fields fail decoding rather than healing a row under a defaulted owner. However, standardTag, index, registrationIndex, and keyClass default to zero. A page missing index can therefore decode and resolve to account zero instead of failing. The current Rust producer emits all four fields, so this is a contract-hardening issue rather than a demonstrated failure with the current producer. Remove these defaults, retain the intentionally optional identity halves, and add a missing-routing-field decode test.
In `packages/rs-unified-sdk-jni/src/wallet_manager.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3354-3376: Test the actual Rust/Kotlin reconciliation JSON contract
The new Kotlin persistence tests use FakeEngine, which supplies handwritten inventory JSON, represents cursors as ordinal strings, and validates classification fields against a separately maintained RUST_REQUIRED_QUERY_KEYS list. No Rust tests exercise the new serde models, identity conversion, or account/outpoint cursor reconstruction. These tests consequently cannot detect disagreement between the actual Rust and Kotlin codecs, despite the documented zero-valued-field omission previously rejecting classification batches. Add shared golden payloads decoded by Rust's Vec<OutpointQuery> and Kotlin's EngineUtxoPage serializer, plus cursor round-trip tests through the actual conversion code. Cover zero-valued standard accounts, non-default account fields and DashPay identity halves, inventory flags, and malformed identity/txid rejection.
Out-of-scope follow-up suggestions (1)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Track the same public-outpoint disclosure in existing Swift diagnostics — Existing Swift reconciliation logs also emit outpoint references, and SDKLogger.reference uses unkeyed SHA-256 truncated to six bytes. The same public-outpoint lookup applies, but those implementations are unchanged in this PR and do not expand its merge requirements.
- Follow-up: Track a separate privacy issue to remove public-outpoint-derived references or replace them with secret-keyed diagnostic references.
|
Ready for review — files with no dedicated owner: QuantumExplorer or shumkov. |
|
Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post |
The host TXO-store reconcile ported from #4638 decided in Kotlin which engine coins may be healed into the store, when a run may start, how often it runs and how long a stale page is retried. Those are wallet decisions, so they move to a new manager::txo_reconcile module and every host asks the same questions: - txo_heal_verdict / txo_heal_page tag each inventory row Eligible, Immature (not engine-confirmed, or fewer than 100 confirmations at the tip) or Invalid (no script or address). The watch-only contact exclusion stays where it already was, in wallet_utxos_page and classify_outpoints. - TxoReconciler::begin claims one run per wallet and applies the start gates in the #4638 order: SPV running and steady (Synced, or WaitForEvents with the filter phase at its target), a scan tip (filter height, else header height), no latched sync fault, and the wallet's own engine watermark within 6 blocks of the tip. finish releases the claim and restarts the wallet's cadence when the run went ahead. - TxoReconciler::due_wallets is the schedule: every wallet on the rising edge into steady state, then each wallet again after 30 minutes, never one in flight. SPV not running lowers the edge, and SpvRuntime now counts its starts so a stop and restart between two polls is a new steady state without the host reporting the stop. - txo_reconcile_retry_stale_page holds the limit of five stale pages in a row. Removing a wallet forgets its cadence. The policy is unit-tested here, replacing the Kotlin tests of the same rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… C ABI Thin exports over manager::txo_reconcile, one Rust operation each: - platform_wallet_manager_txo_reconcile_due_wallets (IdentifierArray out) - platform_wallet_manager_txo_reconcile_begin (gate code and scan tip out) - platform_wallet_manager_txo_reconcile_finish - platform_wallet_txo_reconcile_heal_page / _heal_page_free, returning TxoHealRowFFI rows: the existing WalletUtxoEntryFFI plus its verdict - platform_wallet_txo_reconcile_retry_stale_page The TXO_RECONCILE_SKIP_* and TXO_HEAL_VERDICT_* codes are pinned to the Rust enums by a test. The cursor decoding and the entry build and release of platform_wallet_wallet_utxos_page move into shared helpers so the heal page uses the same ones; that export is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ision Kotlin no longer decides anything in the TXO-store reconcile. The JNI bindings and their Kotlin signatures change together: - walletManagerTxoReconcileDueWallets, walletManagerTxoReconcileBegin, walletManagerTxoReconcileFinish and txoReconcileRetryStalePage bridge one platform-wallet-ffi call each. - walletManagerTxoHealPageJson replaces walletManagerUtxosPageJson; each row now carries the engine's healVerdict (golden fixture updated on both sides). walletManagerCoreWalletSyncedHeight is gone: the wallet watermark gate reads it in Rust. On the Kotlin side TxoReconcileGates and TxoReconcileCoordinator are removed. The progress poll asks the native schedule which wallets are due on every tick, running or not, and stopSpv no longer has to report the stop. reconcileTxoStore keeps the closed and loaded-wallet checks, the teardown borrow and the cancellation epoch, takes the start decision and scan tip from the native gate, and always releases the native claim. The persistence handler inserts only rows the engine calls eligible, takes the stale-page decision as a parameter, and no longer screens watch-only contact rows on either pass: the inventory never offers a contact's coin and the classifier gives one no verdict, so such rows now count as unknown. The report's skippedForeign field goes with that screen. Room pagination, owner resolution, the transactions, the callback exclusion lock and the generation and open-round fences stay in Kotlin. The Kotlin tests of the moved rules are replaced by the Rust tests; the handler tests now drive the verdicts and the stale-page decision as inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… caller `walletManagerTxoReconcileBegin` ran inside a cancellable withContext. If the calling job was cancelled while it waited on the wallet lock, the native side had already claimed the wallet and returned RUN, but the result was discarded and the `try`/`finally` that releases the claim was never entered. The wallet then stayed ALREADY_RUNNING, and out of the cadence, for the rest of the session. Run `begin` under NonCancellable, like `finish`; cancellation is still observed inside the `try`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pers name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xo-reconcile-4638-port # Conflicts: # packages/rs-platform-wallet/src/spv/runtime.rs
|
Waiting for bot review — coderabbitai not yet · thepastaclaw requested changes — dismiss the review or push a fix, 1 thread unresolved — resolve it. Wait for the missing reviews, or a writer can post |
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
At head 9e7711d, seven prior findings are fixed and the pagination-index suggestion remains unresolved. The new destructive classification pass can apply a spent verdict after an engine reorg has invalidated it, creating a blocking persisted-wallet-state defect; three additional nonblocking scheduling and claim-lifetime issues are confirmed. This was static verification only: current CI reports Kotlin/emulator and Rust workspace tests passing, with Swift SDK build/tests and PR Hygiene still pending.
🔴 1 blocking | 🟡 4 suggestion(s)
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 8: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 9: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 10: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 11: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 12: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — The intricate reconciliation and save-time verdict logic in PlatformWalletPersistenceHandler.kt, especially upsertUtxoRow, changes which persisted coins are considered spent or spendable and therefore directly affects coin selection after restore. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-3.8-flash-high(lane failed),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-reviewer
🤖 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:1946-1950: Fence destructive verdicts against engine reorgs, not only Room commits
This fence detects host persistence changes, but not changes to the engine snapshot that supplied the verdict. `classify_outpoints` accepts any mined spender, including one above the last chainlock, and releases the wallet read lock before returning. The pinned dash-spv can then handle a fork and call `truncate_above`, removing that spender and restoring its input coin. `build_core_changeset` returns an empty changeset for `ChainTruncated`, so this interleaving need not open or commit a persistence round: both checks here can still pass and mark the now-live coin spent. Later reconciliation cannot undo the flag because healing skips existing rows and classification skips spent rows, so restoration subsequently omits the coin. The broader truncation-persistence gap is tracked in https://github.com/dashpay/platform/issues/5380, but this PR adds the stale destructive write. Coordinate classification and apply with engine mutations through a Rust-owned consistency mechanism, or restrict actionable verdicts to evidence an allowed reorg cannot invalidate. Add a regression that truncates the spender between classification and apply without committing a host round.
In `packages/rs-platform-wallet/src/manager/txo_reconcile.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/manager/txo_reconcile.rs:363-370: Release provisional reconcile claims when begin is cancelled
`begin` inserts the wallet into `in_flight` before awaiting `gate`, but releases it only when that await returns an error. Dropping this public Rust future while it waits for SPV progress or the wallet-manager read lock skips cleanup without returning the successful result that obligates the caller to call `finish`. Subsequent begins return `AlreadyRunning`, `due_wallets` excludes the wallet, and `forget` leaves the claim intact. The blocking C adapter and Kotlin's non-cancellable handoff protect the current Android path, so this is nonblocking native API hardening rather than an Android cancellation failure. Hold a provisional rollback guard across the await and disarm it only when successfully transferring the claim to the caller. Add a regression that drops a blocked `begin` future and verifies the wallet can be claimed again.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/txo_reconcile.rs:313-316: Prevent an old run from restoring a removed wallet's cadence
Wallet removal calls `forget_txo_reconcile`, but an already-running reconciliation can finish afterward and reinsert the timestamp that `forget` removed. This is reachable through Kotlin's epoch-based stop: the handler returns an incomplete report after removal, and `reconcileTxoStore` still calls `finish` with `ran = true`. Re-adding the same deterministic wallet ID while SPV remains steady then waits for the 30-minute cadence instead of being due immediately. Preserve the in-flight exclusion until the old run exits, but prevent its completion from restoring scheduling history for a forgotten lifecycle, for example by associating the claim with a lifecycle generation. Cover `try_begin → forget → finish → due_wallets` in the schedule tests.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt:2415-2418: Do not consume cadence for wallets Kotlin skips
The native `due_wallets` call stamps `last_run` for every returned wallet before this code filters the result against Kotlin's loaded wrappers. Native creation and restoration publish wallets before Kotlin publishes the corresponding wrapper: creation still performs Keystore and Room work, and restoration reconstructs pending keys before wrapping the wallets. If a steady-state poll lands in that gap, `loaded[key] ?: continue` discards the scheduled run but leaves its timestamp intact. Publishing the wrapper afterward does not retry it, so the wallet waits up to 30 minutes despite never having reconciled. Have the native scheduling API account for the host-loaded set, or explicitly release skipped reservations without erasing completed-run history. Add coverage for native publication followed by a poll before Kotlin wrapper publication.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt:48-52: Add an index matching the unspent pagination query
(existing thread: https://github.com/dashpay/platform/pull/5259#discussion_r4236385885)
`TxoEntity` has separate indexes for walletId and the outpoint primary key, but none combining this query's wallet/spent predicates with its cursor and ordering. SQLite can repeatedly filter and sort a wallet's history, or walk the outpoint index while filtering unrelated and spent rows; LIMIT bounds returned rows, not the work needed to find them. Each automatic classification-page read holds `callbackExclusion`, which synchronous persistence callbacks also acquire, so large histories can delay durable sync progress. Add a `(walletId, isSpent, outpoint)` index with the Room migration and schema update. Keeping this PR schema-neutral explains the proposed follow-up, but the supplied discussion contains no linked tracking issue or PR; link that work before treating the suggestion as deferred.
|
Your move: thepastaclaw requested changes on this head; dismiss the review or push a fix; thepastaclaw left review threads unresolved; resolve them. |
`due_wallets` stamped every wallet it reported as run at that moment. Kotlin then drops the ones it has not wrapped yet (creation and restore publish the native wallet first), so such a wallet lost its run and waited up to 30 minutes. Reporting now consumes nothing; the cadence restarts when a run claims the wallet in `begin`. A run its gate refuses still waits for the cadence, as before, so it is not retried on every poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tten Removing a wallet forgets its reconcile cadence, but a run still in flight could finish afterwards and write its timestamp back. Kotlin's epoch stop makes this reachable: the run ends early and still reports that it ran. A wallet re-added under the same id while SPV stays steady then waited out the old run's 30 minutes. Each claim now records whether its wallet was forgotten while it ran; the old run keeps the wallet until it exits, but its finish restores no history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`TxoReconciler::begin` claimed the wallet before awaiting its gate and released the claim only when the gate refused. A caller that dropped the future mid-await, while it waited on SPV progress or the wallet lock, left the wallet claimed for the rest of the session: every later begin answered AlreadyRunning and the schedule never reported it again. The claim is now provisional until the gate passes, and a guard releases it on a refusal or a drop. The Android path was already protected by the blocking C adapter and Kotlin's non-cancellable call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…written The reconcile classified a page, released the engine, and wrote the verdicts afterwards. Its fences there see the host's persistence rounds only, and the engine can move with none: dash-spv follows a fork through `truncate_above` under the wallet-manager write lock, which drops the spenders above the fork and restores the coins they spent, and the truncation reaches no persistence round (the wider gap is #5380). A flip landing after such a fork retires a live coin for good. `TxoReconciler::classify_held` now answers under an owned read guard on the wallet manager and keeps it in the run's claim until the host has applied the page, so a fork waits for that one Room transaction. It is released by `release_held` (Kotlin calls it in a `finally`), by `finish`, by the next page's classification before it waits, and by the manager's shutdown. Mined spenders are actionable again whether or not a ChainLock covers them; mempool spends still are not. `classify_outpoints` and the Swift path are unchanged. Lock order, written into the module docs: the wallet manager's lock, then the host's callback exclusion, then the store transaction — the same order as the engine calling a persistence callback under its write lock. The schedule mutex is a leaf, and tokio's fair RwLock cannot close a cycle because the guard holder never waits for an engine lock. New C exports: `platform_wallet_txo_reconcile_classify_outpoints`, `platform_wallet_manager_txo_reconcile_release_verdicts`. The JNI `walletManagerClassifyOutpoints` becomes `walletManagerTxoReconcileClassifyOutpoints`, with `walletManagerTxoReconcileReleaseVerdicts` beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xo-reconcile-4638-port Conflict in packages/rs-platform-wallet/src/spv/runtime.rs: both sides extended the atomics import (this branch AtomicU64 for run_sessions, v5.1-dev AtomicBool for owner_released). Kept both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… handle registry The per-page release of a reconcile's engine guard resolved the manager handle in the C API registry, while every other C API path takes the registry first and the engine lock second. A cycle followed: a run holds the guard; a block or fork queues an engine write behind it; a native read parks on the engine behind that write while holding the registry for reading; a manager create or destroy queues a registry write behind that read, and the fair registry lock then parks the release. Every native call hung, and close could not recover. Classification now hands the host its guard as an opaque token, `TxoReconcileHeldVerdicts` (`TxoReconcileHeldVerdictsFFI` in the C API), which `platform_wallet_txo_reconcile_held_verdicts_free` drops without any registry lookup or wait. The JNI classify call returns it in an `outHeld` LongArray, written only together with the answers, and `txoReconcileHeldVerdictsFree` frees it; Kotlin takes it out of an AtomicLong before freeing, so it is released once, in the handler's `finally` and again before `finish`. `finish` and manager shutdown still empty the guard's slot as backstops, and a classification that waited across a finish and a new begin no longer hands its guard to the new claim. The registry lock and the rule that no path holding the guard waits for it are added to the documented lock order. The regression test runs the cycle in a child process: guard held, engine writer queued, registry reader parked behind it, registry writer queued; freeing the token still completes. With a registry lookup put back into the release, the child deadlocks and the test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cts token Two gaps on the Android path of the reconcile's held engine guard. The JNI classify call freed the token itself on its ordinary failure paths, but a panic between the native classify and the write of `outHeld` leaked it. Only `finish` would then release the guard, and `finish` resolves the manager handle in the native registry while the guard is held, the lock cycle the token exists to avoid. The token now sits in a drop guard from the moment the native call returns; it is freed on every exit, unwinding included, and disarmed only once `outHeld` has been written. In Kotlin, a throwing `txoReconcileHeldVerdictsFree` in the run's `finally` skipped `finish` and left the wallet claimed for the session. The run now ends through `endTxoReconcileRun`, inside the same NonCancellable context: the free, then the finish, each failure logged by exception type and wallet reference only, neither skipping the other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Waiting for bot review — coderabbitai not yet · thepastaclaw requested changes — dismiss the review or push a fix, 2 threads unresolved — resolve them. Wait for the missing reviews, or a writer can post |
thepastaclaw
left a comment
There was a problem hiding this comment.
Re-review — Final validation — Phase 1 + Phase 2
The Android reconciliation policy is now largely centralized in rs-platform-wallet, and the previously reported cadence, held-verdict, owner-resolution, transport, and open-round issues are fixed. Two blocking lifecycle/ownership issues remain: a JNI marshalling failure can permanently strand a native reconcile claim, and the existing Swift reconciler still bypasses the newly introduced Rust-owned policy. The Room pagination index and several fail-loud transport and diagnostic hardening items also remain; CI checks covering Rust and Kotlin are still in progress.
🔴 2 blocking | 🟡 8 suggestion(s)
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
Review provenance
Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 5: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 6: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 7: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)
- Triage:
criticalbygpt-6.1-sol(effort low) — This is a large, intricate change to wallet TXO persistence, credit verdict handling, and engine/store reconciliation that directly affects which coins are considered spendable and therefore changes a funds/coin-selection critical surface. - Phase 1 reviewers:
muse-spark-1.3-contributor— general (completed, effort xhigh); agentphase1-reviewer,muse-spark-1.3-contributor— rust-quality (completed, effort xhigh); agentphase1-reviewer - Phase 1 model:
muse-spark-1.3-contributor— not quota-gated; passed overgemini-3.8-flash-high(antigravity quota lookup failed),glm-5.3-flash(not used above high effort; tier asks max) - Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
- Fresh verifier:
gpt-6.1-sol— final-verifier; agentsol-verifier - Phase 2 reviewers:
gpt-6.1-sol— general (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— architecture-layering (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— ffi-engineer (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— rust-quality (completed, effort xhigh); agentphase2-reviewer,gpt-6.1-sol— security-auditor (completed, effort xhigh); agentphase2-reviewer
🤖 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-unified-sdk-jni/src/wallet_manager.rs`:
- [BLOCKING] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3515-3549: Release the reconcile claim when outTipHeight marshalling fails
This JNI export claims the wallet before writing `out_tip_height`. It checks only for a null array, so an `IntArray` with the wrong length, or a JNI failure from `set_int_array_region`, returns `-1` after the native claim has been installed. Kotlin enters the cleanup `finally` only after a successful begin return, leaving the wallet permanently `AlreadyRunning` for the manager lifetime. Validate the array length before calling the native begin, and explicitly finish the claim on every post-begin marshalling failure.
- [SUGGESTION] packages/rs-unified-sdk-jni/src/wallet_manager.rs:3782-3809: Initialize outHeld for an empty classification batch
The JNI contract documents `outHeld[0]` as zero when no guard is held, but the empty-query path returns without writing it. A caller that reuses a `LongArray(1)` can retain an opaque token from an earlier call; later cleanup may free a stale token or associate it with the wrong classification call. Validate the array length and write the zero sentinel before the empty return.
In `packages/rs-platform-wallet/src/manager/txo_reconcile.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/manager/txo_reconcile.rs:1-27: Make all mobile callers use the Rust-owned TXO reconcile policy
The new Rust policy is used by Kotlin, but `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift` still owns its own cadence, start gates, heal eligibility, stale retry loop, and direct classify/apply flow. That caller bypasses `TxoReconciler::classify_held` and its engine consistency guard, leaving two mobile policy implementations that can diverge and preserving the old classify-to-apply behavior for iOS. Route Swift through the Rust policy using thin bindings, including the held-verdict lifecycle, instead of leaving policy decisions in the Swift wrapper.
- [SUGGESTION] packages/rs-platform-wallet/src/manager/txo_reconcile.rs:537-541: Read the SPV run session after the steady-state read
`due_wallets` reads `run_session()` before awaiting `scan_progress()`. If SPV stops and restarts between those operations, the poll can pair the old session with the new steady state; the schedule then sees the old session as already steady and misses the rising edge until the normal cadence expires. Reading the session after the steady-state read fails safe: a restart in that gap may cause one extra idempotent run rather than silently skipping the edge.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt:1381-1394: Do not let a stale run claim a re-added wallet
The coroutine captures `txoReconcileEpoch` before waiting for `teardownGate`, but invokes the native `begin` without checking that epoch again. If it is delayed before acquiring the gate while `removeWallet` forgets the old lifecycle and the same wallet ID is re-added, the old coroutine can claim the new wallet. It then observes the stale epoch and returns `MANAGER_CLOSED`; `try_begin` has already stamped `last_run`, while `finish(ran = false)` leaves that stamp in place, suppressing the newly added wallet for the cadence interval despite no reconciliation having run. Reject the stale caller before the native claim and associate the claim with the wallet lifecycle generation so the race after that check cannot claim a replacement wallet.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1398-1419: Make all inventory state fields required at the JNI boundary
The transport comments promise strict decoding, but `address`, `scriptHex`, and all engine flags still have defaults. A payload can therefore omit these fields and still decode; an omitted script becomes an empty byte array, which is not rejected by `healEngineRow`, and an eligible row can be inserted with malformed address/script data or guessed flags. Keep only the intentionally optional DashPay identity halves nullable and require the remaining inventory fields.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:1457-1461: Reject inventory pages with incomplete continuation metadata
`EngineUtxoPage` defaults `utxos`, `cursor`, and `hasMore`, so a malformed or drifted payload can decode as a terminal page. `healMissingTxos` treats either `!hasMore` or a null cursor as completion, meaning an omitted continuation field can silently stop the heal walk and leave later engine coins unrepaired. Make the page fields required and reject the inconsistent state where `hasMore` is true without a cursor.
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt:2189-2201: Keep reconcile diagnostics free of wallet amounts
The summary described as counts and keyed references logs `insertedDuffs` and `flippedDuffs`, which are exact aggregate values of coins healed into or removed from the restore set. Android logcat, support collection, OEM tooling, or a compromised logging path can therefore disclose wallet financial activity even though individual identifiers are protected. Retain counts, completion state, and keyed references, but remove the amount totals from the diagnostic line.
In `packages/rs-platform-wallet-ffi/src/txo_reconcile.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/txo_reconcile.rs:80-127: Catch panics in the new reconcile C exports before crossing into JNI or Swift
The new `platform-wallet-ffi` reconcile exports call `block_on_worker` and blocking Tokio lock operations directly from `extern "C"` functions. `block_on_worker` uses `expect("tokio worker panicked")`, and `blocking_read()` can panic in an incompatible runtime context. JNI's `guard` is on the far side of this C ABI, so it cannot catch a panic that occurs inside the C export; Swift callers have no such guard either. Wrap each new C entry point with the established `catch_panic_to_code` pattern and return a `PlatformWalletFFIResult` error while keeping output initialization and allocated-resource cleanup inside the guarded body.
In `packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt`:
- [SUGGESTION] packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt:48-52: Add an index matching the unspent pagination query
(existing thread: https://github.com/dashpay/platform/pull/5259#discussion_r4236385885)
`pageUnspentByWallet` filters by `walletId` and `isSpent`, then seeks and orders by `outpoint`, but `TxoEntity` has only separate single-column indexes. SQLite may scan and filter a wallet's full history or walk the global outpoint index for every page. This query now runs automatically under `callbackExclusion`, so large histories can delay persistence callbacks. Add a composite `(walletId, isSpent, outpoint)` Room index and the corresponding schema migration.
Out-of-scope follow-up suggestions (1)
These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.
- Persist engine truncations that occur after a reconcile write — The held read guard fixes the pre-apply fork race, but a later engine truncation can still restore a coin without a persistence round that makes the corresponding Room row live again. This broader truncation-persistence gap is concrete and is already tracked separately.
- Follow-up: #5380
| pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileBegin( | ||
| mut env: JNIEnv, | ||
| _class: JClass, | ||
| manager_handle: jlong, | ||
| wallet_id: JByteArray, | ||
| out_tip_height: jni::sys::jintArray, | ||
| ) -> jint { | ||
| guard(&mut env, -1, |env| { | ||
| let Some(wid) = read_id32(env, &wallet_id) else { | ||
| return -1; | ||
| }; | ||
| if out_tip_height.is_null() { | ||
| throw_sdk_exception(env, 1, "outTipHeight must be an IntArray(1)"); | ||
| return -1; | ||
| } | ||
| let mut gate = 0u8; | ||
| let mut tip_height = 0u32; | ||
| let result = unsafe { | ||
| platform_wallet_ffi::platform_wallet_manager_txo_reconcile_begin( | ||
| manager_handle as Handle, | ||
| wid.as_ptr(), | ||
| &mut gate as *mut u8, | ||
| &mut tip_height as *mut u32, | ||
| ) | ||
| }; | ||
| if take_pwffi_error(env, result) { | ||
| return -1; | ||
| } | ||
| // SAFETY: Kotlin allocates `outTipHeight` as an IntArray(1). | ||
| let out = unsafe { jni::objects::JIntArray::from_raw(out_tip_height) }; | ||
| // A tip past i32::MAX cannot cross as a jint; no Dash height is near it. | ||
| let tip = jint::try_from(tip_height).unwrap_or(jint::MAX); | ||
| if env.set_int_array_region(&out, 0, &[tip]).is_err() { | ||
| return -1; | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Release the reconcile claim when outTipHeight marshalling fails
This JNI export claims the wallet before writing out_tip_height. It checks only for a null array, so an IntArray with the wrong length, or a JNI failure from set_int_array_region, returns -1 after the native claim has been installed. Kotlin enters the cleanup finally only after a successful begin return, leaving the wallet permanently AlreadyRunning for the manager lifetime. Validate the array length before calling the native begin, and explicitly finish the claim on every post-begin marshalling failure.
source: gpt-6.1-sol (phase2-reviewer: rust-quality)
| //! Policy of the host TXO-store reconcile (dashpay/platform#4638). | ||
| //! | ||
| //! A host keeps a write-behind mirror of the wallet's coins and restores | ||
| //! the engine from it at every launch, so a coin the mirror lost, or a row | ||
| //! it holds unspent for a coin the engine proves spent, outlives a full | ||
| //! scan. The repair walks the host's own store, but every decision in it | ||
| //! lives here, so each host asks the same questions and gets the same | ||
| //! answers: | ||
| //! | ||
| //! - **when to run** — [`TxoReconciler::due_wallets`] on every SPV progress | ||
| //! poll: each wallet on the rising edge into steady state, then again on | ||
| //! [`TXO_RECONCILE_CADENCE`] while it lasts; | ||
| //! - **whether a run may start** — [`TxoReconciler::begin`]: one run per | ||
| //! wallet at a time, SPV in steady state with a scan tip, no latched sync | ||
| //! fault, and the wallet's own scan watermark within | ||
| //! [`TXO_RECONCILE_TIP_MARGIN`] blocks of the tip; | ||
| //! - **which engine coins may be healed into the store** — [`txo_heal_page`] | ||
| //! tags each inventory row with a [`TxoHealVerdict`]; | ||
| //! - **which store rows to mark spent** — [`TxoReconciler::classify_held`]: | ||
| //! [`classify_outpoints`]'s verdicts, with the engine held still until | ||
| //! the host has written them; | ||
| //! - **how long to retry a page a persistence round staled** — | ||
| //! [`txo_reconcile_retry_stale_page`]. | ||
| //! | ||
| //! The host keeps only what needs its store: paging its rows, resolving the | ||
| //! account that owns each, its transactions, and the fences that keep its | ||
| //! persistence callbacks and teardown out of a run's writes. |
There was a problem hiding this comment.
🔴 Blocking: Make all mobile callers use the Rust-owned TXO reconcile policy
The new Rust policy is used by Kotlin, but packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift still owns its own cadence, start gates, heal eligibility, stale retry loop, and direct classify/apply flow. That caller bypasses TxoReconciler::classify_held and its engine consistency guard, leaving two mobile policy implementations that can diverge and preserving the old classify-to-apply behavior for iOS. Route Swift through the Rust policy using thin bindings, including the held-verdict lifecycle, instead of leaving policy decisions in the Swift wrapper.
source: gpt-6.1-sol (phase2-reviewer: architecture-layering)
| val generation = txoReconcileEpoch.get() | ||
| return try { | ||
| teardownGate.withOp { | ||
| val tipOut = IntArray(1) | ||
| // NonCancellable: a RUN return holds a native claim that only | ||
| // the `finally` below releases. A cancellable withContext would | ||
| // discard that result and throw before the `try` is entered, | ||
| // leaking the claim for the rest of the session. Cancellation | ||
| // is still observed at the first suspension inside the `try`. | ||
| val gate = withContext(NonCancellable + Dispatchers.IO) { | ||
| mapNativeErrors { | ||
| WalletManagerNative.walletManagerTxoReconcileBegin(managerHandle, walletId, tipOut) | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Do not let a stale run claim a re-added wallet
The coroutine captures txoReconcileEpoch before waiting for teardownGate, but invokes the native begin without checking that epoch again. If it is delayed before acquiring the gate while removeWallet forgets the old lifecycle and the same wallet ID is re-added, the old coroutine can claim the new wallet. It then observes the stale epoch and returns MANAGER_CLOSED; try_begin has already stamped last_run, while finish(ran = false) leaves that stamp in place, suppressing the newly added wallet for the cadence interval despite no reconciliation having run. Reject the stale caller before the native claim and associate the claim with the wallet lifecycle generation so the race after that check cannot claim a replacement wallet.
source: gpt-6.1-sol (phase2-reviewer: architecture-layering)
| data class EngineUtxoRow( | ||
| val typeTag: Int, | ||
| val standardTag: Int, | ||
| val index: Int, | ||
| val registrationIndex: Int, | ||
| val keyClass: Int, | ||
| val userIdentityId: String? = null, | ||
| val friendIdentityId: String? = null, | ||
| val txid: String, | ||
| val vout: Int, | ||
| val amount: Long, | ||
| val address: String = "", | ||
| val scriptHex: String = "", | ||
| val height: Int, | ||
| /** The engine's own flags for the coin. Stamped onto a healed row | ||
| * as given: guessing them (a coinbase row filed as non-coinbase) | ||
| * would misstate maturity in the mirror the engine reloads from. */ | ||
| val isConfirmed: Boolean = false, | ||
| val isInstantlocked: Boolean = false, | ||
| val isCoinbase: Boolean = false, | ||
| val isLocked: Boolean = false, | ||
| val healVerdict: Int, |
There was a problem hiding this comment.
🟡 Suggestion: Make all inventory state fields required at the JNI boundary
The transport comments promise strict decoding, but address, scriptHex, and all engine flags still have defaults. A payload can therefore omit these fields and still decode; an omitted script becomes an empty byte array, which is not rejected by healEngineRow, and an eligible row can be inserted with malformed address/script data or guessed flags. Keep only the intentionally optional DashPay identity halves nullable and require the remaining inventory fields.
| data class EngineUtxoRow( | |
| val typeTag: Int, | |
| val standardTag: Int, | |
| val index: Int, | |
| val registrationIndex: Int, | |
| val keyClass: Int, | |
| val userIdentityId: String? = null, | |
| val friendIdentityId: String? = null, | |
| val txid: String, | |
| val vout: Int, | |
| val amount: Long, | |
| val address: String = "", | |
| val scriptHex: String = "", | |
| val height: Int, | |
| /** The engine's own flags for the coin. Stamped onto a healed row | |
| * as given: guessing them (a coinbase row filed as non-coinbase) | |
| * would misstate maturity in the mirror the engine reloads from. */ | |
| val isConfirmed: Boolean = false, | |
| val isInstantlocked: Boolean = false, | |
| val isCoinbase: Boolean = false, | |
| val isLocked: Boolean = false, | |
| val healVerdict: Int, | |
| val address: String, | |
| val scriptHex: String, | |
| val height: Int, | |
| val isConfirmed: Boolean, | |
| val isInstantlocked: Boolean, | |
| val isCoinbase: Boolean, | |
| val isLocked: Boolean, | |
| val healVerdict: Int, |
source: gpt-6.1-sol (phase2-reviewer: architecture-layering)
| data class EngineUtxoPage( | ||
| val utxos: List<EngineUtxoRow> = emptyList(), | ||
| val cursor: String? = null, | ||
| val hasMore: Boolean = false, | ||
| ) |
There was a problem hiding this comment.
🟡 Suggestion: Reject inventory pages with incomplete continuation metadata
EngineUtxoPage defaults utxos, cursor, and hasMore, so a malformed or drifted payload can decode as a terminal page. healMissingTxos treats either !hasMore or a null cursor as completion, meaning an omitted continuation field can silently stop the heal walk and leave later engine coins unrepaired. Make the page fields required and reject the inconsistent state where hasMore is true without a cursor.
source: gpt-6.1-sol (phase2-reviewer: architecture-layering)
| private fun logReconcileSummary(report: TxoReconcileReport, walletRef: String) { | ||
| val counts = "engineRows=${report.engineRows} storeRows=${report.storeRows} " + | ||
| "inserted=${report.inserted} insertedDuffs=${report.insertedDuffs} " + | ||
| "healedSpent=${report.healedSpent} " + | ||
| "flipped=${report.flipped} flippedDuffs=${report.flippedDuffs} " + | ||
| "alreadyPresent=${report.alreadyPresent} unspent=${report.unspent} " + | ||
| "unknown=${report.unknown} notOwned=${report.notOwned} " + | ||
| "skippedImmature=${report.skippedImmature} " + | ||
| "skippedUnresolvedAccount=${report.skippedUnresolvedAccount} " + | ||
| "skippedInvalid=${report.skippedInvalid} " + | ||
| "unresolvedAccount=${report.unresolvedAccount} staleRetries=${report.staleRetries} " + | ||
| "transportFailures=${report.transportFailures} storeFailures=${report.storeFailures} " + | ||
| "completed=${report.completed} wallet=$walletRef" |
There was a problem hiding this comment.
🟡 Suggestion: Keep reconcile diagnostics free of wallet amounts
The summary described as counts and keyed references logs insertedDuffs and flippedDuffs, which are exact aggregate values of coins healed into or removed from the restore set. Android logcat, support collection, OEM tooling, or a compromised logging path can therefore disclose wallet financial activity even though individual identifiers are protected. Retain counts, completion state, and keyed references, but remove the amount totals from the diagnostic line.
| private fun logReconcileSummary(report: TxoReconcileReport, walletRef: String) { | |
| val counts = "engineRows=${report.engineRows} storeRows=${report.storeRows} " + | |
| "inserted=${report.inserted} insertedDuffs=${report.insertedDuffs} " + | |
| "healedSpent=${report.healedSpent} " + | |
| "flipped=${report.flipped} flippedDuffs=${report.flippedDuffs} " + | |
| "alreadyPresent=${report.alreadyPresent} unspent=${report.unspent} " + | |
| "unknown=${report.unknown} notOwned=${report.notOwned} " + | |
| "skippedImmature=${report.skippedImmature} " + | |
| "skippedUnresolvedAccount=${report.skippedUnresolvedAccount} " + | |
| "skippedInvalid=${report.skippedInvalid} " + | |
| "unresolvedAccount=${report.unresolvedAccount} staleRetries=${report.staleRetries} " + | |
| "transportFailures=${report.transportFailures} storeFailures=${report.storeFailures} " + | |
| "completed=${report.completed} wallet=$walletRef" | |
| val counts = "engineRows=${report.engineRows} storeRows=${report.storeRows} " + | |
| "inserted=${report.inserted} healedSpent=${report.healedSpent} " + | |
| "flipped=${report.flipped} alreadyPresent=${report.alreadyPresent} " + | |
| "unspent=${report.unspent} unknown=${report.unknown} notOwned=${report.notOwned} " + | |
| "skippedImmature=${report.skippedImmature} " + | |
| "skippedUnresolvedAccount=${report.skippedUnresolvedAccount} " + | |
| "skippedInvalid=${report.skippedInvalid} unresolvedAccount=${report.unresolvedAccount} " + | |
| "staleRetries=${report.staleRetries} transportFailures=${report.transportFailures} " + | |
| "storeFailures=${report.storeFailures} completed=${report.completed} wallet=$walletRef" |
source: gpt-6.1-sol (phase2-reviewer: architecture-layering)
| pub async fn due_wallets(&self) -> Vec<WalletId> { | ||
| let session = self.spv.run_session(); | ||
| let steady = self.scan_progress().await.is_some_and(|p| p.is_steady()); | ||
| let wallets: Vec<WalletId> = self.wallets.load().keys().copied().collect(); | ||
| lock_schedule(&self.schedule).due_wallets(steady, session, wallets, Instant::now()) |
There was a problem hiding this comment.
🟡 Suggestion: Read the SPV run session after the steady-state read
due_wallets reads run_session() before awaiting scan_progress(). If SPV stops and restarts between those operations, the poll can pair the old session with the new steady state; the schedule then sees the old session as already steady and misses the rising edge until the normal cadence expires. Reading the session after the steady-state read fails safe: a restart in that gap may cause one extra idempotent run rather than silently skipping the edge.
| pub async fn due_wallets(&self) -> Vec<WalletId> { | |
| let session = self.spv.run_session(); | |
| let steady = self.scan_progress().await.is_some_and(|p| p.is_steady()); | |
| let wallets: Vec<WalletId> = self.wallets.load().keys().copied().collect(); | |
| lock_schedule(&self.schedule).due_wallets(steady, session, wallets, Instant::now()) | |
| let steady = self.scan_progress().await.is_some_and(|p| p.is_steady()); | |
| let session = self.spv.run_session(); | |
| let wallets: Vec<WalletId> = self.wallets.load().keys().copied().collect(); | |
| lock_schedule(&self.schedule).due_wallets(steady, session, wallets, Instant::now()) |
source: muse-spark-1.3-contributor (phase1-reviewer: rust-quality)
| pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( | ||
| manager_handle: Handle, | ||
| out_wallet_ids: *mut IdentifierArray, | ||
| ) -> PlatformWalletFFIResult { | ||
| check_ptr!(out_wallet_ids); | ||
| *out_wallet_ids = IdentifierArray::empty(); | ||
| // Looked up under the registry guard, waited on outside it — see | ||
| // `platform_wallet_manager_sync_progress`. | ||
| let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); | ||
| let reconciler = unwrap_option_or_return!(option); | ||
| let due = block_on_worker(async move { reconciler.due_wallets().await }); | ||
| *out_wallet_ids = IdentifierArray::from_hashes(due); | ||
| PlatformWalletFFIResult::ok() | ||
| } | ||
|
|
||
| /// Claim `wallet_id` for one reconcile run and check that it may start. | ||
| /// `*out_gate` is [`TXO_RECONCILE_GATE_RUN`] or one of the | ||
| /// `TXO_RECONCILE_SKIP_*` reasons; on `RUN`, `*out_tip_height` is the scan | ||
| /// tip to pass to `platform_wallet_txo_reconcile_heal_page`, and the claim | ||
| /// holds until `platform_wallet_manager_txo_reconcile_finish`. A skip holds | ||
| /// no claim. Either way the wallet's cadence restarts now, unless another | ||
| /// run already held it. | ||
| /// | ||
| /// # Safety | ||
| /// `wallet_id` points at 32 readable bytes; the out-pointers are writable. | ||
| #[no_mangle] | ||
| pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( | ||
| manager_handle: Handle, | ||
| wallet_id: *const u8, | ||
| out_gate: *mut u8, | ||
| out_tip_height: *mut u32, | ||
| ) -> PlatformWalletFFIResult { | ||
| check_ptr!(wallet_id); | ||
| check_ptr!(out_gate); | ||
| check_ptr!(out_tip_height); | ||
| *out_gate = TXO_RECONCILE_SKIP_WALLET_UNKNOWN; | ||
| *out_tip_height = 0; | ||
| let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); | ||
| let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); | ||
| let reconciler = unwrap_option_or_return!(option); | ||
| match block_on_worker(async move { reconciler.begin(&wid).await }) { | ||
| Ok(tip_height) => { | ||
| *out_gate = TXO_RECONCILE_GATE_RUN; | ||
| *out_tip_height = tip_height; | ||
| } | ||
| Err(skip) => *out_gate = skip.as_u8(), | ||
| } | ||
| PlatformWalletFFIResult::ok() |
There was a problem hiding this comment.
🟡 Suggestion: Catch panics in the new reconcile C exports before crossing into JNI or Swift
The new platform-wallet-ffi reconcile exports call block_on_worker and blocking Tokio lock operations directly from extern "C" functions. block_on_worker uses expect("tokio worker panicked"), and blocking_read() can panic in an incompatible runtime context. JNI's guard is on the far side of this C ABI, so it cannot catch a panic that occurs inside the C export; Swift callers have no such guard either. Wrap each new C entry point with the established catch_panic_to_code pattern and return a PlatformWalletFFIResult error while keeping output initialization and allocated-resource cleanup inside the guarded body.
source: gpt-6.1-sol (phase2-reviewer: general, architecture-layering, ffi-engineer, rust-quality, security-auditor)
| guard(&mut env, ptr::null_mut(), |env| { | ||
| let Some(wid) = read_id32(env, &wallet_id) else { | ||
| return ptr::null_mut(); | ||
| }; | ||
| if out_held.is_null() { | ||
| throw_sdk_exception(env, 1, "outHeld must be a LongArray(1)"); | ||
| return ptr::null_mut(); | ||
| } | ||
| let raw = match env.get_string(&queries_json) { | ||
| Ok(s) => String::from(s), | ||
| Err(_) => { | ||
| throw_sdk_exception(env, 1, "outpoint queries must be a String"); | ||
| return ptr::null_mut(); | ||
| } | ||
| }; | ||
| let parsed: Vec<OutpointQuery> = match serde_json::from_str(&raw) { | ||
| Ok(q) => q, | ||
| Err(e) => { | ||
| throw_sdk_exception(env, 1, &format!("malformed outpoint queries: {e}")); | ||
| return ptr::null_mut(); | ||
| } | ||
| }; | ||
| if parsed.is_empty() { | ||
| return env | ||
| .byte_array_from_slice(&[]) | ||
| .map(|a| a.into_raw()) | ||
| .unwrap_or(ptr::null_mut()); | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: Initialize outHeld for an empty classification batch
The JNI contract documents outHeld[0] as zero when no guard is held, but the empty-query path returns without writing it. A caller that reuses a LongArray(1) can retain an opaque token from an earlier call; later cleanup may free a stale token or associate it with the wrong classification call. Validate the array length and write the zero sentinel before the empty return.
source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: rust-quality)
Issue being fixed or feature implemented
#4638 fixed, for iOS, TXO rows the store writes as unspent for coins the engine never credited
(the rust-dashcore#992 shape: a CoinJoin collateral burn processed before the coin's funding transaction). A
restore then hands those rows back to the engine as spendable at the next launch. #4638 added two things: credit
verdicts delivered at save time (Part 1, prevention) and a post-sync store↔engine reconcile (Part 2). Its Rust
side is already in
v5.1-dev. Android uses neither: the JNI layer never registers the verdict slot, and thereconcile only exists in #4439, which cannot build on
v5.1-devbecause its address-pool hole repair needsrust-dashcore#979's
ensure_contiguous_to.This PR is #4638 for Kotlin: the same Rust, the same rules, nothing that needs #979.
What was done?
wallet_utxos_pageandclassify_outpoints, both added by fix(swift-sdk): stop born-spent TXO rows at the persistence seam and reconcile the store after a full scan #4638.rules):
its target) and every 30 minutes per wallet.
behind the scan tip; a run for the wallet already in flight. Closing the manager or removing the wallet stops
a run between pages.
confirmed by the engine, at least 100 confirmations deep, and owned by an account the store can resolve from
the seven-field tuple. An unresolvable owner is counted, never filed unowned.
only on
KnownUncredited(a mined spender on record), and drops pending-input claims on it. The page is readwith a count of committed persistence rounds; if a round committed before the apply, nothing is written and
the page is classified again.
walletManagerCoreWalletSyncedHeight: a single call toplatform_wallet_core_wallet_state, so the reconcile gates on the engine's watermark as Swift does. Thepersisted copy in Room trails it by a round, which is where the SYNCED transition lands.
rs-unified-sdk-jniregisterson_persist_wallet_changeset_utxo_verdicts_fnand passes the round'sverdicts to
NativePersistenceBridge.onWalletChangesetUtxoVerdictsas flat arrays, before the changesetcallback.
PlatformWalletPersistenceHandlerkeeps them in the round's buffer.upsertUtxoRowwrites anobserved_spent/doomedoutput spent at creation, with no spender link. Any verdict vetoes theredelivery recovery clear (both the flag and the sweep stamp).
uncreditedchanges nothing else. Arolled-back round leaves no verdict behind.
log. Both can follow separately.
Kotlin-specific choices, same behaviour:
open round.
How Has This Been Tested?
On this branch (
v5.1-dev, rust-dashcoree4208c90, no #979):cargo test -p platform-wallet --libcargo test -p platform-wallet-ffi --libcargo test -p rs-unified-sdk-jnicargo clippy … -D warningscargo fmt --check--rerun-tasks)The Kotlin tests port
BornSpentTxoPersistTests(observed_spent / doomed / uncredited, the redelivery veto,round scope, rollback, and a restart over a reopened on-disk store that restores zero coins for a verdict-spent
row) and the relevant cases of
CoreTxoReconcileTests,CoreTxoReconcileShutdownTestsandCoreTxoReconcilePrivacyTests:separately;
and long hex or Base58 runs.
On an Android emulator (Pixel 9 AVD), with the same commits on the integration line, restored from seed and
then relaunched:
rows written spent at creation. The reconcile ran 2 s after SYNCED on the first launch and 1 s after on the
relaunch: 1,081 engine coins, 1,081 store rows, all unspent, zero mutations. 0 collateral burns left unspent.
rows written spent at creation. The reconcile ran 1 s after SYNCED on the first launch and on the relaunch: 779
engine coins, 779 store rows, all unspent, zero mutations.
unchanged and Part 1 fired the same way. (Transaction history is a different matter; that belongs to fix: same block core chain lock height #979.)
Breaking Changes
None. One additive bridge method with a no-op default, and one additional extension slot wired.
Checklist
🤖 Generated with Claude Code
PR Hygiene ·
d7a2674/skip-botsproceeds without the ones not yet reported/self-reviewedCargo.lock,packages/rs-unified-sdk-jni/Cargo.toml,packages/rs-unified-sdk-jni/src/persistence.rsand 1 more) — QuantumExplorer or shumkovkotlin-sdk— you own itrs-platform-wallet-ffi(packages/rs-platform-wallet-ffi/src/lib.rs,packages/rs-platform-wallet-ffi/src/manager.rs,packages/rs-platform-wallet-ffi/src/manager_diagnostics.rsand 1 more) — ZocoLini or llbartekll or romchornyirs-platform-wallet(packages/rs-platform-wallet/src/manager/accessors.rs,packages/rs-platform-wallet/src/manager/mod.rs,packages/rs-platform-wallet/src/manager/txo_reconcile.rsand 2 more) — ZocoLini or llbartekll or romchornyiWhen every merge requirement is met, the
PR Hygienecheck passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.