Skip to content

fix(kotlin-sdk): port #4638 to Android — credit verdicts at save time and the post-sync TXO reconcile - #5259

Open
HashEngineering wants to merge 33 commits into
v5.1-devfrom
feat/kotlin-sdk-txo-reconcile-4638-port
Open

HashEngineering wants to merge 33 commits into
v5.1-devfrom
feat/kotlin-sdk-txo-reconcile-4638-port

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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 the
reconcile only exists in #4439, which cannot build on v5.1-dev because its address-pool hole repair needs
rust-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?

Kotlin-specific choices, same behaviour:

  • The classify pass walks the store by outpoint rather than by offset, so its own flips never shift the walk.
  • A reconcile store step takes the same lock as the persistence callbacks, so it never has to wait behind an
    open round.
  • Contact (DIP-15 watch-only) coins are also excluded on the Kotlin side, by account tag and by address.

How Has This Been Tested?

On this branch (v5.1-dev, rust-dashcore e4208c90, no #979):

check result
cargo test -p platform-wallet --lib 1,185 passed
cargo test -p platform-wallet-ffi --lib 388 passed, 1 ignored (pre-existing)
cargo test -p rs-unified-sdk-jni 44 passed
cargo clippy … -D warnings clean
cargo fmt --check clean
Kotlin SDK unit tests (--rerun-tasks) 571 passed, 0 failed

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, CoreTxoReconcileShutdownTests and
CoreTxoReconcilePrivacyTests:

  • flip on proof, and the stale-round refusal with re-classification;
  • the heal gates (immature, engine-unconfirmed, unresolved owner, malformed), and healed-spent counted
    separately;
  • idempotence, wallet scope, paging, read failures, and cancellation;
  • the steady-state and watermark gates;
  • every reconcile log line checked for txids in either byte order, outpoints, addresses, scripts, wallet ids,
    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:

  • Wallet A: 168.96778705, 1,081 coins, 7,461 transactions, all identical to the build without this PR. 13,460
    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.
  • Wallet B: 58.05157585, 779 coins, 3,273 transactions, all identical to the build without this PR. 4,564
    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.
  • An earlier revision on an engine with rust-dashcore#979 removed: balances, coins and the reconcile were
    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

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests

🤖 Generated with Claude Code

PR Hygiene · d7a2674

  • Bots — coderabbitai not yet · thepastaclaw requested changes — dismiss the review or push a fix, 2 threads unresolved — resolve them — /skip-bots proceeds without the ones not yet reported
  • Self-review — post /self-reviewed
  • Build failed
  • Approvals
    • files with no dedicated owner (Cargo.lock, packages/rs-unified-sdk-jni/Cargo.toml, packages/rs-unified-sdk-jni/src/persistence.rs and 1 more) — QuantumExplorer or shumkov
    • kotlin-sdk — you own it
    • rs-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.rs and 1 more) — ZocoLini or llbartekll or romchornyi
    • rs-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.rs and 2 more) — ZocoLini or llbartekll or romchornyi

When every merge requirement is met, the PR Hygiene check passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.

HashEngineering and others added 11 commits October 2, 2026 16:15
…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>
cargo fmt --check failed on the inventory/classifier bindings carried from
#4439 (f5fed8c). Formatting only.

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>
@github-actions github-actions Bot added this to the v5.1.0 milestone Oct 3, 2026
@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

⛔ Final review complete — 2 blocking finding(s) (commit d7a2674) · triage: critical

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 8, 2026
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

/self-review

@github-actions github-actions Bot removed the waiting-self-review Waiting for the author to post /self-reviewed label Oct 8, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — 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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — security-auditor (completed, effort xhigh); agent phase2-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.

Comment thread packages/rs-unified-sdk-jni/src/wallet_manager.rs
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Ready for review — files with no dedicated owner: QuantumExplorer or shumkov.
Full checklist in the description.

@github-actions github-actions Bot added the ready-for-human Bots have reported, the author has self-reviewed, and the build is green: this needs a human. label Oct 8, 2026
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Oct 10, 2026
@github-actions github-actions Bot added bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. and removed waiting-bots Waiting for the review bots to report on this head labels Oct 19, 2026
HashEngineering and others added 3 commits October 18, 2026 22:13
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>
HashEngineering and others added 3 commits October 20, 2026 14:08
… 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
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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 /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 21, 2026

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

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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-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.

Comment thread packages/rs-platform-wallet/src/manager/txo_reconcile.rs Outdated
Comment thread packages/rs-platform-wallet/src/manager/txo_reconcile.rs
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Your move: thepastaclaw requested changes on this head; dismiss the review or push a fix; thepastaclaw left review threads unresolved; resolve them.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. and removed waiting-bots Waiting for the review bots to report on this head labels Oct 22, 2026
HashEngineering and others added 7 commits October 24, 2026 17:17
`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>
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

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 /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 25, 2026

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

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: critical by gpt-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); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-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; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-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.

Comment on lines +3515 to +3549
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;
}

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: 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)

Comment on lines +1 to +27
//! 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.

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: 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)

Comment on lines +1381 to +1394
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)
}
}

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: 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)

Comment on lines +1398 to +1419
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,

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

Suggested change
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)

Comment on lines +1457 to +1461
data class EngineUtxoPage(
val utxos: List<EngineUtxoRow> = emptyList(),
val cursor: String? = null,
val hasMore: Boolean = false,
)

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: 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)

Comment on lines +2189 to +2201
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"

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

Suggested change
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)

Comment on lines +537 to +541
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())

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

Suggested change
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)

Comment on lines +80 to +127
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()

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: 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)

Comment on lines +3782 to +3809
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());
}

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: 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)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-bots Waiting for the review bots to report on this head

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants