From 21e482de391c0b59f21b8e53a4acd2a11641d0f3 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 8 Sep 2026 12:16:08 -0700 Subject: [PATCH 1/5] fix(kotlin-sdk): reconcile the TXO store against the engine and repair restored address pools Squashed rebase of fix/kotlin-sdk-txo-reconcile-v42dev onto v4.2-dev, no longer stacked on #4406 (its persistence seam landed on v4.2-dev separately): the shared TXO-insert path (upsertUtxoRow) is v4.2-dev's own insert discipline extracted into one helper, and the swept-refusal counter is gone with the tombstone flag it counted. The core_bridge updated-record UTXO re-emission is dropped (a no-op for the FFI projections, which re-derive UTXOs from records, and a plausible hold-clearing hazard per review). --- .../dashsdk/ffi/WalletManagerNative.kt | 55 ++ .../PlatformWalletPersistenceHandler.kt | 753 +++++++++++++++--- .../dashsdk/persistence/dao/TransactionDao.kt | 10 + .../dashsdk/persistence/dao/TxoDao.kt | 17 + .../dashsdk/wallet/PlatformWalletManager.kt | 112 +++ .../PlatformWalletPersistenceHandlerTest.kt | 649 +++++++++++++++ .../src/core_wallet_types.rs | 14 + .../src/manager_diagnostics.rs | 239 +++++- .../rs-platform-wallet-ffi/src/persistence.rs | 102 ++- .../src/manager/accessors.rs | 340 ++++++++ packages/rs-unified-sdk-jni/Cargo.toml | 5 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 464 +++++++++++ 12 files changed, 2635 insertions(+), 125 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index d0c4e9f2f53..97aa20a9f21 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -145,6 +145,61 @@ internal object WalletManagerNative { gapLimit: Int, ) + /** + * One bounded page of the engine's UTXO inventory for one wallet, + * across every account, as JSON + * `{"utxos":[...],"errors":[...],"cursor":,"hasMore":}` + * — the source of truth the TXO-store reconciler + * ([PlatformWalletManager.reconcileTxoStore]) diffs against the Room + * `txos` mirror. + * + * Paged, not swept whole: a wallet's UTXO count is chain-controlled + * (anyone who knows a watched address can keep sending dust to it), so + * a full-inventory read would let a remote party decide how much this + * process allocates on every SYNCED transition and every 30-minute + * pass. Pass [cursor] `null` to start, then hand back the returned + * `cursor` verbatim while `hasMore` is true. [limit] caps the rows in + * one page; non-positive means the native default, and oversized + * values are clamped natively. + * + * Each `utxos` row carries the owning account tags, the txid hex in + * the same byte order the changeset path hands + * [PlatformWalletPersistenceHandler] (so hex→bytes reproduces the + * `txos.txid` blob), vout, amount (duffs), derived address (empty when + * the script has no address form), scriptHex, height and isLocked. + * Per-account read failures land in `errors` instead of failing the + * page. `network` is [org.dashfoundation.dashsdk.Network.ffiValue]. + */ + external fun walletManagerUtxosPageJson( + managerHandle: Long, + walletId: ByteArray, + network: Int, + cursor: String?, + limit: Int, + ): String? + + /** + * Classify a batch of outpoints against the engine's live state: the + * reverse half of the reconcile transport, and the reason the paged + * inventory above carries no spent-outpoint list. The caller pages its + * OWN mirror rows and asks about them a batch at a time, so neither + * side ever builds a set over the whole engine inventory. + * + * [outpoints] is a flat `n * 36` byte blob in the store's own encoding + * — 32-byte txid in wire order then vout as little-endian `Int`, which + * is exactly the `txos.outpoint` primary key, so callers concatenate + * the column and read the answers back positionally. Returns `n` + * bytes: 0 unknown, 1 unspent, 2 spent. + * + * A 2 means SOME recorded transaction spends the outpoint — possibly + * one still in the mempool. It is not proof of a settled spend. + */ + external fun walletManagerClassifyOutpoints( + managerHandle: Long, + walletId: ByteArray, + outpoints: ByteArray, + ): ByteArray? + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d68d1758f93..6796d5e318b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -13,6 +13,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -1029,111 +1037,630 @@ class PlatformWalletPersistenceHandler( isLocked: Boolean, ): Int = guarded { stage(walletId) { db -> - val outpoint = makeOutpoint(txid, vout) - // Ensure a parent transaction row exists (stub if missing, so - // the TXO FK holds; the real tx upsert overwrites it later). - if (db.transactionDao().getByTxid(txid) == null) { - db.transactionDao().upsert( - TransactionEntity(txid = txid, transactionData = ByteArray(0)), + upsertUtxoRow( + db, walletId, txid, vout, amount, address, scriptPubKey, + height, isCoinbase, isConfirmed, isInstantLocked, isLocked, + ) + } + 0 + } + + /** + * The single TXO-insert discipline, shared by the changeset callback + * ([onWalletChangesetUtxoAdded]) and the reconcile sweep + * ([reconcileTxos]): stub the parent transaction row so the FK holds, + * upsert the TXO preserving any existing spend linkage, then drain + * pending-input rows staged before this funding TXO existed — a 1:1 + * port of the Swift upsertUtxo drain + * (PlatformWalletPersistenceHandler.swift). A spend that arrived first + * was deferred (see onWalletChangesetTransaction); now that the funding + * output is here, resolve the claim and clear the rows so the + * UTXO-restore path won't hand this consumed output back to Rust as + * spendable. + */ + private suspend fun upsertUtxoRow( + db: DashDatabase, + walletId: ByteArray, + txid: ByteArray, + vout: Int, + amount: Long, + address: String, + scriptPubKey: ByteArray, + height: Int, + isCoinbase: Boolean, + isConfirmed: Boolean, + isInstantLocked: Boolean, + isLocked: Boolean, + // The Room account this output belongs to, when the CALLER could + // resolve it (the reconcile resolves it from the engine inventory's + // account tags). Stamped on the row so ownership survives even when + // the address projection is absent — a heal into a store that lost + // BOTH the TXO and its address row must not produce a row the + // restore loader cannot attribute (it would be skipped at the next + // mirror-reload, recreating the fund loss the heal repaired). An + // existing row's accountId always wins; changeset callbacks pass + // null and keep their address-projection behavior. + resolvedAccountId: Long? = null, + ) { + val outpoint = makeOutpoint(txid, vout) + // Ensure a parent transaction row exists (stub if missing, so + // the TXO FK holds; the real tx upsert overwrites it later). + if (db.transactionDao().getByTxid(txid) == null) { + db.transactionDao().upsert( + TransactionEntity(txid = txid, transactionData = ByteArray(0)), + ) + } + val existing = db.txoDao().getByOutpoint(outpoint) + val coreAddressId = if (address.isNotEmpty()) address else null + // A materialised coin the wallet re-delivers unspent follows the + // wallet — the mirror of the SQLite store's upsert valve, which + // holds only never-materialised placeholders. The wallet knows + // this coin, and any network-final spender of a coin it knows is + // wallet-relevant by BIP158 prevout matching, so its own scan + // re-discovers the spend; refusing the re-delivery would lock a + // real coin out forever after a reorg of the winner, and on this + // side of the FFI a row at `isSpent = true` is never restored to + // Rust again. So an UNLINKED row — a sweep hold with its stamp, or + // a legacy flag with nothing behind it — is cleared, stamp + // included. A LINKED row keeps its flag and stamp: the link is + // this store's recorded spend attribution, the pending drain + // below and the sweep pass own that transition, and a spender + // that reached a block is confirmed evidence a re-delivery never + // displaces. + val linked = existing?.spendingTxid != null + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + isSpent = linked && existing!!.isSpent, + walletId = walletId, + txid = txid, + spendingTxid = existing?.spendingTxid, + spendingInputIndex = existing?.spendingInputIndex, + accountId = existing?.accountId ?: resolvedAccountId, + coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), + createdAt = existing?.createdAt ?: java.util.Date(), + lastUpdated = now(), + supersededByTxid = if (linked) existing!!.supersededByTxid else null, + ) + db.txoDao().upsert(row) + // Drain any pending-input rows staged before this funding TXO + // existed — a port of the Swift `upsertUtxo` drain + // (PlatformWalletPersistenceHandler.swift). A spend that arrived + // first was deferred (see onWalletChangesetTransaction); now that + // the funding output is here, resolve the claim and clear the rows + // so the UTXO-restore path won't hand this consumed output back to + // Rust as spendable. + val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) + if (pending.isNotEmpty()) { + // A tombstone outranks every ordinary row regardless of age: + // ordinary rows are competing *observations*, a tombstone is + // the sweep's settled verdict that its winner consumed this + // coin. Prefer the tombstone tagged with the delivering + // wallet; failing that any tombstone on the outpoint still + // holds — the stamp is a txid fact, not a per-wallet one. + val tombstones = pending.filter { it.isSweptTombstone } + val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } + .maxByOrNull { it.createdAt } + ?: tombstones.maxByOrNull { it.createdAt } + if (tombstone != null) { + // A drained tombstone STAMPS, it never mints a spender + // link: the winner need not have its own `transactions` + // row, and a link would make the coin non-releasable + // (the release pass frees stamped, unlinked rows) when a + // later sweep proves the winner never took it. The + // existing link, if any, is carried as it was. + db.txoDao().upsert( + row.copy( + isSpent = true, + supersededByTxid = tombstone.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + // Competing ordinary observations: a network-final spender + // outranks a newer mempool one (its row is the settled + // claim the link guard protects); among equals the newest + // wins, as before (reorg / double-spend: newest wins). + val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } + val (chosen, spending) = ranked.maxWithOrNull( + compareBy>( + { it.second?.context ?: 0 }, + { it.first.createdAt }, + ), + )!! + val spendingContext = spending?.context ?: 0 + val keepExistingLink = + keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) + db.txoDao().upsert( + linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), ) } - val existing = db.txoDao().getByOutpoint(outpoint) - val coreAddressId = if (address.isNotEmpty()) address else null - // A materialised coin the wallet re-delivers unspent follows the - // wallet — the mirror of the SQLite store's upsert valve, which - // holds only never-materialised placeholders. The wallet knows - // this coin, and any network-final spender of a coin it knows is - // wallet-relevant by BIP158 prevout matching, so its own scan - // re-discovers the spend; refusing the re-delivery would lock a - // real coin out forever after a reorg of the winner, and on this - // side of the FFI a row at `isSpent = true` is never restored to - // Rust again. So an UNLINKED row — a sweep hold with its stamp, or - // a legacy flag with nothing behind it — is cleared, stamp - // included. A LINKED row keeps its flag and stamp: the link is - // this store's recorded spend attribution, the pending drain - // below and the sweep pass own that transition, and a spender - // that reached a block is confirmed evidence a re-delivery never - // displaces. - val linked = existing?.spendingTxid != null - val row = TxoEntity( - outpoint = outpoint, - vout = vout, - amount = amount, - address = address, - scriptPubKey = scriptPubKey, - height = height, - isCoinbase = isCoinbase, - isConfirmed = isConfirmed, - isInstantLocked = isInstantLocked, - isLocked = isLocked, - isSpent = linked && existing!!.isSpent, - walletId = walletId, - txid = txid, - spendingTxid = existing?.spendingTxid, - spendingInputIndex = existing?.spendingInputIndex, - accountId = existing?.accountId, - coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), - createdAt = existing?.createdAt ?: java.util.Date(), - lastUpdated = now(), - supersededByTxid = if (linked) existing!!.supersededByTxid else null, - ) - db.txoDao().upsert(row) - // Drain any pending-input rows staged before this funding TXO - // existed — a port of the Swift `upsertUtxo` drain - // (PlatformWalletPersistenceHandler.swift). A spend that arrived - // first was deferred (see onWalletChangesetTransaction); now that - // the funding output is here, resolve the claim and clear the rows - // so the UTXO-restore path won't hand this consumed output back to - // Rust as spendable. - val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) - if (pending.isNotEmpty()) { - // A tombstone outranks every ordinary row regardless of age: - // ordinary rows are competing *observations*, a tombstone is - // the sweep's settled verdict that its winner consumed this - // coin. Prefer the tombstone tagged with the delivering - // wallet; failing that any tombstone on the outpoint still - // holds — the stamp is a txid fact, not a per-wallet one. - val tombstones = pending.filter { it.isSweptTombstone } - val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } - .maxByOrNull { it.createdAt } - ?: tombstones.maxByOrNull { it.createdAt } - if (tombstone != null) { - // A drained tombstone STAMPS, it never mints a spender - // link: the winner need not have its own `transactions` - // row, and a link would make the coin non-releasable - // (the release pass frees stamped, unlinked rows) when a - // later sweep proves the winner never took it. The - // existing link, if any, is carried as it was. - db.txoDao().upsert( - row.copy( - isSpent = true, - supersededByTxid = tombstone.spendingTxid, - lastUpdated = now(), - ), - ) - } else { - // Competing ordinary observations: a network-final spender - // outranks a newer mempool one (its row is the settled - // claim the link guard protects); among equals the newest - // wins, as before (reorg / double-spend: newest wins). - val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } - val (chosen, spending) = ranked.maxWithOrNull( - compareBy>( - { it.second?.context ?: 0 }, - { it.first.createdAt }, - ), - )!! - val spendingContext = spending?.context ?: 0 - val keepExistingLink = - keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) - db.txoDao().upsert( - linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), - ) + for (p in pending) db.documentDao().deletePendingInput(p) + } + } + + /** + * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] + * are the healed holes; a non-zero value after a completed sync means + * a changeset failed to deliver an owned output (the + * CoinJoin-funded-send change-drop class) and would have become a + * fund-loss on the next engine reload from this store. + */ + data class TxoReconcileReport( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + /** Healed TXOs whose pre-existing record's netAmount MAY be short by + * the healed amount. LOG-ONLY: the record can already carry the + * corrected net (a corrective callback racing this sweep), and + * blind addition double-credits. The event pipeline owns net + * correctness. */ + val netAmountSuspects: Int, + /** Healed rows whose owning Room account could not be resolved from + * the inventory's account tuple — ownership rides on the address + * projection alone, and if that row is also missing the healed TXO + * will not survive the next mirror-reload. */ + val healedUnowned: Int = 0, + val skippedImmature: Int, + val skippedNoAddress: Int, + val accountErrors: Int, + /** Store rows marked unspent whose outpoint the engine records as + * spent — the lost-spend-update class (dashpay/platform#4425). + * LOG-ONLY: the engine's spent set includes mempool spends with no + * context, so flipping would persist an unconfirmed spend as + * settled. */ + val wouldFlipSpent: Int = 0, + val wouldFlipSpentDuffs: Long = 0, + /** Store rows marked unspent that the engine has in NEITHER + * inventory — swept/abandoned residue (pre-rust-dashcore#971 + * stores). LOG-ONLY: counted and named in the log, never removed + * by this pass. */ + val wouldRemove: Int = 0, + val wouldRemoveDuffs: Long = 0, + /** Watch-only DIP-15 contact rows excluded from classification — + * the engine's own accounts never report them, so their absence + * from both inventories is expected, not divergence. */ + val skippedForeign: Int = 0, + /** Store rows marked spent for a coin the engine lists UNSPENT — + * either a released coin from a swept transaction whose release + * event a pre-rust-dashcore#971 build lost, or a live spend the + * store wrote moments before the engine settled. The two cannot + * be told apart safely, so this is LOG-ONLY: un-marking a coin + * mid-payment would let the wallet double-spend it. */ + val stuckSpent: Int = 0, + val stuckSpentDuffs: Long = 0, + /** Transport reads that failed mid-sweep — an engine inventory page + * or an outpoint-classification batch that came back empty. The + * pass stops at the first one; whatever it already applied stands + * (insert-only, idempotent) and the rest waits for the next + * cadence tick. A persistently non-zero value means the sweep + * never finishes, so the report's other counters are a partial + * view. */ + val transportFailures: Int = 0, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop: a changeset that fails to + * deliver an owned output leaves a permanent hole, and because the + * engine is REBUILT from this mirror on restart (buildUtxoRestoreData), + * the hole graduates to a fund-loss on the next launch. Observed in the + * field as the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs. + * + * Both directions are BOUNDED, and deliberately so. A wallet's UTXO + * count is chain-controlled — anyone who knows a watched address can + * keep sending dust to it — so a pass that materialized the whole + * inventory would hand a remote party control over how much this + * process allocates on every SYNCED transition and every cadence tick. + * Instead: + * + * * [engineUtxoPage] hands back one bounded page of the engine's + * inventory at a time (`cursor` null to start, then the `cursor` the + * previous page returned while its `hasMore` is true), and each page + * is applied in its own Room transaction. A sweep is therefore many + * small commits rather than one giant one; that is the point, and it + * is safe because the pass is insert-only and idempotent. + * * The reverse direction pages the STORE's own rows and asks + * [classifyOutpoints] about one page at a time (0 unknown, 1 + * unspent, 2 spent), instead of pulling both engine inventories over + * and holding them as sets. + * + * Insert-only by design: rows the engine holds and the mirror lacks are + * added; rows the mirror holds and the engine lacks are LEFT ALONE (the + * mirror may legitimately be ahead — a live spend marks rows spent here + * before the engine's map settles — and it also carries watch-only + * contact outputs the engine's own accounts never report). Spent-state + * repair is deliberately out of scope; the reverse pass only classifies + * and logs. + * + * [minConfirmations] (default 100): the engine snapshot cannot carry + * `isCoinbase`/`isInstantLocked`, so inserted rows get + * `isConfirmed=true` and both flags false — inert for any output at or + * beyond coinbase maturity, which the gate guarantees. Fresher holes + * age into a later sweep. + * + * `netAmount` is reported, never repaired: a record born blind to one + * of its own outputs persisted a net short by exactly that output's + * value, but the record may equally have been corrected already by a + * callback racing this sweep, and blind addition double-credits. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs Room transactions). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, + ): TxoReconcileReport { + var engineUtxos = 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var accountErrors = 0 + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var skippedForeign = 0 + var healedUnowned = 0 + var stuckSpent = 0 + var stuckSpentDuffs = 0L + var transportFailures = 0 + val limit = pageSize.coerceAtLeast(1) + + // Watch-only DIP-15 contact (external) accounts, resolved once up + // front because BOTH passes need the exclusion and the account set + // does not move under a sweep. The engine's UTXO inventory export + // includes these accounts' coins — it tracks them to show payments + // TO contacts — but they are the CONTACT's money and must never be + // healed into the store as ours. Before this check lived on the + // insert pass, a fresh restore's post-backfill reconcile healed + // every contact-payment coin into the store (12 rows / 0.05692493 + // tDASH on the large-wallet validation run of 2026-08-25) while the + // reverse pass — the only place the exclusion existed — dutifully + // counted the same rows as foreign. + val foreignAccountIds = database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + + // ── Insert pass: one engine page at a time, one Room transaction + // each. The page is fetched OUTSIDE the exclusion lock — the fetch + // is a native call into the engine, and the lock exists to keep + // changeset callbacks out of our writes, not out of the engine. + var cursor: String? = null + while (true) { + val pageJson = engineUtxoPage(cursor, limit) + if (pageJson == null) { + // The transport died mid-sweep. Everything already applied + // stands (insert-only, idempotent); the rest waits for the + // next cadence tick. + transportFailures++ + break + } + val page = kotlinx.serialization.json.Json + .parseToJsonElement(pageJson).jsonObject + val utxos = page["utxos"]?.jsonArray + ?: kotlinx.serialization.json.JsonArray(emptyList()) + accountErrors += page["errors"]?.jsonArray?.size ?: 0 + engineUtxos += utxos.size + val nextCursor = page["cursor"]?.jsonPrimitive?.contentOrNull + val hasMore = page["hasMore"]?.jsonPrimitive?.booleanOrNull ?: false + + if (utxos.isNotEmpty()) { + callbackExclusion.withLock { + database.withTransaction { + // Engine-side entries carry only an address; + // ownership resolves through core_addresses + // .accountId (the same second path rowIsForeign uses + // for store rows). An unresolvable address is NOT + // provably foreign — those proceed, keeping this + // pass's provable-only discipline symmetric: it + // neither mutates nor suppresses on guesswork. + suspend fun addressIsForeign(address: String): Boolean { + val owner = + database.coreAddressDao().getByAddress(address)?.accountId + return owner != null && owner in foreignAccountIds + } + for (element in utxos) { + val row = element.jsonObject + val height = row["height"]?.jsonPrimitive?.int ?: 0 + if (height <= 0 || tipHeight - height + 1 < minConfirmations) { + skippedImmature++ + continue + } + val address = row["address"]?.jsonPrimitive?.content.orEmpty() + if (address.isEmpty()) { + skippedNoAddress++ + continue + } + // The inventory tags every UTXO with its owning + // account tuple. The tag is the authoritative + // foreign check — a watch-only external + // account's coin is the CONTACT's money whether + // or not its address row survived persistence. + // The address-based check stays as a fallback + // for inventories predating the tagged export. + val typeTag = row["typeTag"]?.jsonPrimitive?.int ?: -1 + if (typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL || + addressIsForeign(address) + ) { + skippedForeign++ + continue + } + val txid = + row["txid"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + if (txid.size != 32) continue + if (database.txoDao() + .getByOutpoint(makeOutpoint(txid, vout)) != null + ) { + continue + } + val amount = row["amount"]?.jsonPrimitive?.long ?: 0L + val scriptPubKey = + row["scriptHex"]?.jsonPrimitive?.content.orEmpty() + .hexToByteArray() + val isLocked = row["isLocked"]?.jsonPrimitive?.boolean ?: false + // Resolve the Room account from the tuple and + // stamp it on the healed row. Ownership must not + // depend on the address projection: the two + // things persistence loses together are the TXO + // and its address row, and a healed row with + // neither link is skipped by the restore loader + // at the next mirror-reload — recreating the + // fund loss the heal repaired. + val ownerAccountId = if (typeTag >= 0) { + fetchAccount( + database, walletId, typeTag, + row["index"]?.jsonPrimitive?.int ?: 0, + row["standardTag"]?.jsonPrimitive?.int ?: 0, + row["registrationIndex"]?.jsonPrimitive?.int ?: 0, + row["keyClass"]?.jsonPrimitive?.int ?: 0, + row["userIdentityId"]?.jsonPrimitive?.content + ?.hexToByteArray() ?: ByteArray(32), + row["friendIdentityId"]?.jsonPrimitive?.content + ?.hexToByteArray() ?: ByteArray(32), + )?.id + } else { + null + } + if (ownerAccountId == null) { + // Heal anyway — the address projection may + // still attribute it — but surface the + // unresolved owner: if the address row is + // also gone, this row will not survive the + // next mirror-reload. + healedUnowned++ + Log.w( + TAG, + "txos reconcile: healing TXO with UNRESOLVED account " + + "(typeTag=$typeTag " + + "index=${row["index"]?.jsonPrimitive?.int} " + + "address=$address) — ownership rides on the " + + "address projection alone", + ) + } + upsertUtxoRow( + database, walletId, txid, vout, amount, address, scriptPubKey, + height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = isLocked, + resolvedAccountId = ownerAccountId, + ) + inserted++ + insertedDuffs += amount + // netAmount is NOT mutated here. The record's + // net may already be correct (a corrective + // record callback can land while its TXO + // delivery races this sweep), and adding the + // healed amount to an already-corrected net + // double-credits. The event pipeline owns net + // correctness; this pass only reports the + // suspicion. + val priorTx = database.transactionDao().getByTxid(txid) + if (priorTx != null && priorTx.transactionData.isNotEmpty()) { + netAmountSuspects++ + Log.w( + TAG, + "txos reconcile: healed TXO ${txid.toHex()}:$vout " + + "($amount duffs) has a pre-existing record whose " + + "netAmount may be short by that amount — LOG-ONLY, " + + "storedNet=${priorTx.netAmount}", + ) + } + } + } } - for (p in pending) db.documentDao().deletePendingInput(p) } + if (!hasMore || nextCursor == null) break + cursor = nextCursor } - 0 + + // ── Reverse pass: classify store rows the engine disagrees with + // (the widened scope from the #4425 / pre-#971 review). Inverted + // relative to the insert pass — it pages the STORE and asks the + // engine about each page — so that neither side has to hold a set + // over a whole inventory. + // + // Read-only by construction: it writes nothing, so it runs outside + // both the exclusion lock and any transaction. A row a concurrent + // callback moves under it is at worst a stale log line, and every + // verdict here is log-only anyway. + // + // Watch-only DIP-15 contact rows are excluded via the same + // `foreignAccountIds` the insert pass resolved above. Production + // changeset writes leave txos.accountId null and route ownership + // through coreAddressId -> core_addresses.accountId, so the + // exclusion must resolve BOTH paths — an accountId-only check + // silently classifies every contact row. + suspend fun rowIsForeign( + row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity, + ): Boolean { + if (row.accountId != null) return row.accountId in foreignAccountIds + val addr = row.coreAddressId ?: return false + val owner = database.coreAddressDao().getByAddress(addr)?.accountId + return owner != null && owner in foreignAccountIds + } + // An empty BLOB sorts before every real outpoint, so this starts at + // the first row. + var after = ByteArray(0) + while (true) { + val storeRows = database.txoDao().pageByWallet(walletId, after, limit) + if (storeRows.isEmpty()) break + after = storeRows.last().outpoint + + // Rows worth asking the engine about. A row still mid-insert + // (no txid yet) is not classifiable, and a foreign row's + // absence from the engine is expected rather than divergence — + // counted, as before, only when it is unspent. + val classifiable = + ArrayList( + storeRows.size, + ) + for (row in storeRows) { + if (row.txid == null || row.outpoint.size != OUTPOINT_BYTES) continue + if (rowIsForeign(row)) { + if (!row.isSpent) skippedForeign++ + continue + } + classifiable.add(row) + } + if (classifiable.isEmpty()) continue + + val blob = ByteArray(classifiable.size * OUTPOINT_BYTES) + for ((i, row) in classifiable.withIndex()) { + System.arraycopy(row.outpoint, 0, blob, i * OUTPOINT_BYTES, OUTPOINT_BYTES) + } + val verdicts = classifyOutpoints(blob) + if (verdicts == null || verdicts.size != classifiable.size) { + // No verdicts, no classification. Log-only either way, so + // the sweep stops rather than guessing. + transportFailures++ + break + } + for ((i, row) in classifiable.withIndex()) { + val verdict = verdicts[i] + val key = "${row.txid?.toHex()}:${row.vout}" + if (row.isSpent) { + // Rows marked spent for coins the engine still lists + // unspent. Either lost-release residue (pre-#971) or a + // live spend racing the engine — never un-marked, only + // reported: un-marking a coin mid-payment would let the + // wallet double-spend it. + if (verdict == OUTPOINT_CLASS_UNSPENT) { + stuckSpent++ + stuckSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row spent but engine lists it " + + "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + + "(lost release, or a live spend racing the engine)", + ) + } + continue + } + when (verdict) { + OUTPOINT_CLASS_UNSPENT -> {} + OUTPOINT_CLASS_SPENT -> { + // Lost spend update (#4425) — PROBABLY. The engine's + // spent set records every input of every recorded + // transaction, INCLUDING mempool spends, and carries + // no context; flipping the store on it would persist + // an unconfirmed spend as settled, contradicting + // this handler's own in-block gating (see + // onWalletChangesetUtxoSpent). LOG-ONLY until the + // engine exports spends with their confirmation + // context. + wouldFlipSpent++ + wouldFlipSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row unspent but the engine " + + "records a spend (context unknown, possibly " + + "mempool) outpoint=$key amount=${row.amount} — " + + "LOG-ONLY, not flipped", + ) + } + else -> { + // In NEITHER engine inventory: swept/abandoned + // residue (pre-#971 stores) — or an engine gap. + // Deliberately LOG-ONLY: removal by reconciliation + // is the one direction where a bug destroys + // user-visible data, so it stays observable-first. + wouldRemove++ + wouldRemoveDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row in neither engine " + + "inventory outpoint=$key amount=${row.amount} — " + + "ambiguous (swept/abandoned residue, or a " + + "finalized spend whose engine record was " + + "dropped) — LOG-ONLY, not removed", + ) + } + } + } + } + + val report = TxoReconcileReport( + engineUtxos = engineUtxos, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + accountErrors = accountErrors, + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + skippedForeign = skippedForeign, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, + transportFailures = transportFailures, + ) + if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || + stuckSpent > 0 || transportFailures > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + + "$netAmountSuspects netAmount suspect(s) (log-only), " + + "healedUnowned=$healedUnowned, " + + "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + + "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + + "engine=${report.engineUtxos} " + + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + + "foreign=$skippedForeign accountErrors=$accountErrors " + + "transportFailures=$transportFailures — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", + ) + } else { + Log.i(TAG, "txos reconcile: mirror consistent ($engineUtxos engine UTXOs)") + } + return report } override fun onWalletChangesetUtxoSpent( @@ -3960,6 +4487,32 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { + /** `AccountTypeTagFFI::DashpayExternalAccount` — watch-only DIP-15 + * contact accounts the engine's inventories never report. */ + internal const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 + + /** Bytes of a `txos.outpoint` key: 32-byte txid (wire order) plus + * the vout as a little-endian `Int`. Also the wire format of the + * reconcile's outpoint-classification batch. */ + internal const val OUTPOINT_BYTES = 36 + + /** + * Rows per page in both directions of [reconcileTxos] — engine + * UTXOs coming in, store rows going out for classification. + * + * The number itself is not delicate; that there IS one is the + * point. Inventory size is chain-controlled, so an unpaged sweep + * would let anyone who knows a watched address decide how much a + * phone allocates at every SYNCED transition and cadence tick. + */ + const val TXO_RECONCILE_PAGE_SIZE = 512 + + /** [reconcileTxos] classification verdicts, mirroring + * `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`. */ + internal const val OUTPOINT_CLASS_UNKNOWN: Byte = 0 + internal const val OUTPOINT_CLASS_UNSPENT: Byte = 1 + internal const val OUTPOINT_CLASS_SPENT: Byte = 2 + internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index c72a5569832..1887844bcab 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -63,6 +63,16 @@ interface TransactionDao { @Upsert suspend fun upsert(transaction: TransactionEntity) + /** + * TXO-reconcile repair: credit a missed own-output back into the + * transaction's stored net amount. A record born blind to one of its + * own outputs (the CoinJoin-funded-send change-drop) persists + * `netAmount` short by exactly that output's value, so the repair is + * a plain add. Returns the number of rows updated (0 = no such tx). + */ + @Query("UPDATE transactions SET netAmount = netAmount + :delta WHERE txid = :txid") + suspend fun addToNetAmount(txid: ByteArray, delta: Long): Int + @Upsert suspend fun upsertInvolvement(involvement: TransactionAccountInvolvementEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index ac484917836..67b9b6de722 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -21,6 +21,23 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE walletId = :walletId") fun observeByWallet(walletId: ByteArray): Flow> + /** + * One outpoint-ordered page of a wallet's TXOs, for a pass that must + * not hold the whole table at once (the store reconcile's reverse + * half). Pass an empty [after] to start — an empty BLOB sorts before + * every real 36-byte outpoint — then the previous page's last + * `outpoint` to continue. + * + * `outpoint` is the primary key, so the order is an index walk and the + * cursor is exact: no row can be visited twice or skipped because + * another one was inserted or deleted mid-sweep. + */ + @Query( + "SELECT * FROM txos WHERE walletId = :walletId AND outpoint > :after " + + "ORDER BY outpoint LIMIT :limit", + ) + suspend fun pageByWallet(walletId: ByteArray, after: ByteArray, limit: Int): List + /** WalletMemoryExplorer: `txo.walletId == walletId && txo.isSpent == false`. */ @Query("SELECT * FROM txos WHERE walletId = :walletId AND isSpent = 0") fun observeUnspentByWallet(walletId: ByteArray): Flow> diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index aba0c4ceb2d..02b13215340 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1240,6 +1240,69 @@ class PlatformWalletManager( mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } } + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop, and the engine is REBUILT + * from it on restart — an unhealed hole becomes a fund-loss on the next + * launch (the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs). Insert-only; never flips spent state or + * deletes. + * + * Both directions of the sweep are paged, so neither this process nor + * the engine ever holds a whole wallet's inventory: UTXO counts are + * chain-controlled, and a periodic full-inventory read would let anyone + * who knows a watched address decide how much a phone allocates. See + * [PlatformWalletPersistenceHandler.reconcileTxos]. + * + * Call it after the L1 scan settles and again on a slow cadence; + * [tipHeight] is the synced chain height — only outputs at least + * [minConfirmations] deep are healed (immature holes age into the next + * sweep). Returns null when the engine inventory read failed at the + * FIRST page: there is nothing to reconcile against, so there is no + * report to make. A page or classification batch failing later + * truncates the sweep instead, which the report's + * `transportFailures` records. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? { + suspend fun page(cursor: String?, limit: Int): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, network.ffiValue, cursor, limit, + ) + } + } + val pageSize = PlatformWalletPersistenceHandler.TXO_RECONCILE_PAGE_SIZE + // Read the first page before entering the reconcile so a dead + // transport still means "no report", the contract callers had + // before the sweep was paged. The handler asks for the null cursor + // exactly once, so this page is spent, not re-read. + val firstPage = page(null, pageSize) ?: return null + return persistenceHandler.reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = { cursor, limit -> + if (cursor == null) firstPage else page(cursor, limit) + }, + classifyOutpoints = { outpoints -> + withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, outpoints, + ) + } + } + }, + ) + } + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -2017,6 +2080,7 @@ class PlatformWalletManager( if (running) { runCatching { spvSyncProgress() }.getOrNull()?.let { next -> if (next != _spvProgress.value) _spvProgress.value = next + maybeReconcileTxoStores(next) } runCatching { spvTipUnixSeconds() }.getOrNull()?.let { tip -> if (tip != _spvTipUnixSeconds.value) _spvTipUnixSeconds.value = tip @@ -2030,6 +2094,46 @@ class PlatformWalletManager( } } + private var lastTxoReconcileAtMs = 0L + private var txoReconcileWasSynced = false + + /** + * SDK-internal trigger for [reconcileTxoStore] — runs on the SYNCED + * transition of the SPV progress poll and again every + * [TXO_RECONCILE_INTERVAL_MS] while synced, for every loaded wallet. + * Lives here rather than in the host apps so Android and iOS-parity + * hosts both get the heal without wiring anything: the mirror hole it + * repairs (rescan dropping change outputs of CoinJoin-funded sends) + * becomes a fund-loss on the next engine reload if any host forgets + * to call it. Failures are logged and re-tried on the next cadence + * tick — never allowed to kill the progress poll. + */ + private fun maybeReconcileTxoStores(progress: SpvSyncProgressData) { + val synced = progress.overallState == SpvSyncState.SYNCED + val transitioned = synced && !txoReconcileWasSynced + txoReconcileWasSynced = synced + if (!synced) return + val now = System.currentTimeMillis() + if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return + val tipHeight = (progress.filters?.currentHeight ?: 0L).toInt() + if (tipHeight <= 0) return + val walletIds = wallets.value.values.map { it.walletId } + if (walletIds.isEmpty()) return + lastTxoReconcileAtMs = now + scope.launch { + for (walletId in walletIds) { + runCatching { reconcileTxoStore(walletId, tipHeight) } + .onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile failed for wallet ${walletId.toHex()}", + t, + ) + } + } + } + } + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2443,6 +2547,14 @@ class PlatformWalletManager( /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L + /** + * Cadence of the steady-state TXO-store reconcile + * ([maybeReconcileTxoStores]) while SPV reports SYNCED. The + * SYNCED transition itself always triggers a pass regardless of + * this interval. + */ + const val TXO_RECONCILE_INTERVAL_MS = 30 * 60 * 1_000L + /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ const val PWFFI_INVALID_PARAMETER = 2 } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index e9f941bcfd1..b5f56a03769 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -13,6 +13,10 @@ import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge @@ -5990,6 +5994,63 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isSweptTombstone) } + // ── TXO-store reconcile (the job-flower change-drop repair) ─────── + + private val changeTxid = ByteArray(32) { 7 } + private val reconcileTip = 1_536_950 + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isLocked":false}],"errors":[]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + // A send record born blind to its own change output: netAmount + // persisted as the full input value (the job-flower 6cef55ab… + // shape) and NO txos row for the change. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + ), + ) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + assertEquals(1, report.netAmountSuspects) + + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) + assertNotNull(row) + assertFalse(row!!.isSpent) + assertEquals(989_009_773L, row.amount) + assertTrue(row.isConfirmed) + + // The stored netAmount is NOT mutated: the record may already carry + // the corrected net (a corrective callback racing this sweep), and + // blind addition double-credits. The suspicion is logged; the event + // pipeline owns net correctness. + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + @Test fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { // A chained sweep that re-points a still-unfunded claim to a new @@ -6494,6 +6555,594 @@ class PlatformWalletPersistenceHandlerTest { chainLockHeightRound(handler, 600) assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } + + @Test + fun reconcileIsIdempotentAndNeverDoubleCredits() = runTest { + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1), + netAmount = -1_000_010_000L, + ), + ) + val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) + + handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, second.inserted) + assertEquals(0, second.netAmountSuspects) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + + @Test + fun reconcileSkipsImmatureOutputsAndPreservesSpentRows() = runTest { + // Immature: inside the 100-conf gate (flags on the engine snapshot + // can't carry coinbase/IS-lock, so fresh rows wait for a later + // sweep) — nothing inserted. + val fresh = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3), + tipHeight = reconcileTip, + ) + assertEquals(0, fresh.inserted) + assertEquals(1, fresh.skippedImmature) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 0))) + + // A row the mirror already holds — even marked spent while the + // engine still lists it — is left untouched: reconcile is + // insert-only and never flips spend state. + assertEquals( + 0, + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 2, 42L, "yTestAddr", byteArrayOf(0x51), 1_500_000, + false, true, false, false, + ), + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), + tipHeight = reconcileTip, + ) + assertEquals(0, report.inserted) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) + } + + /** Engine inventory JSON with both halves: unspent rows and spent outpoints. */ + private fun engineInventoryJson(unspent: List>, spent: List>): String { + val utxoRows = unspent.joinToString(",") { (txid, vout, amount) -> + """{"typeTag":0,"standardTag":0,"index":0,"txid":"$txid","vout":$vout,""" + + """"amount":$amount,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd",""" + + """"scriptHex":"76a914000088ac","height":1400000,"isLocked":false}""" + } + val spentRows = spent.joinToString(",") { (txid, vout) -> + """{"txid":"$txid","vout":$vout}""" + } + return """{"utxos":[$utxoRows],"spent":[$spentRows],"errors":[]}""" + } + + @Test + fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { + // A store row still marked unspent for a coin the engine records as + // spent (dashpay/platform#4425). The engine's spent set includes + // MEMPOOL spends and carries no context, so persisting the flip + // would settle an unconfirmed spend — counted and logged only. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) + assertNull(row.spendingTxid) + } + + @Test + fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction (pre-rust-dashcore#971 + // stores). Counted and logged, NEVER removed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } + + @Test + fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // insert pass would skip as immature — are consistent, not + // divergence. Every reverse-pass counter must be zero. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + assertEquals(0, report.wouldFlipSpent) + assertEquals(0, report.wouldRemove) + assertEquals(1, report.skippedImmature) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 5))!!.isSpent) + } + + @Test + fun reconcileExcludesWatchOnlyContactRowsFromReversePass() = runTest { + // Watch-only DIP-15 contact rows are never in the engine's + // inventories; flagging them would be a false positive on every + // wallet with contact payments. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 0, + accountTypeName = "DashpayExternalAccount", + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 6, 1_230_000L, "yContactAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!! + db.txoDao().upsert(seeded.copy(accountId = foreignAccountId)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) + } + + @Test + fun reconcileResolvesContactOwnershipThroughCoreAddressId() = runTest { + // Production changeset writes leave txos.accountId null and route + // ownership through coreAddressId -> core_addresses.accountId. The + // exclusion must resolve that path, or every contact row gets + // classified as divergence. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 1, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactRouted", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/0", + isUsed = true, + accountId = foreignAccountId, + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 8, 990_000L, "yContactRouted", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! + assertNull("production shape: accountId is null", seeded.accountId) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + } + + @Test + fun reconcileInsertPassNeverHealsContactAccountCoins() = runTest { + // The engine's UTXO inventory export includes the watch-only DIP-15 + // external accounts' coins — it tracks them to show payments TO + // contacts, but they are the CONTACT's money. With the foreign + // exclusion living only on the reverse pass, a fresh restore's + // post-backfill reconcile healed every contact-payment coin into the + // store as an ownerless row (12 rows / 5,692,493 duffs on the + // 2026-08-25 large-wallet validation run) while the reverse pass + // counted the very same rows as foreign — and the mirror-reload path + // hands such rows back to the engine on the next launch. The insert + // pass must skip any engine UTXO whose address resolves to an + // external account, and count it as foreign, not healed. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 2, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactPaid", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/1", + isUsed = true, + accountId = foreignAccountId, + ), + ) + + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + + """"address":"yContactPaid","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(0L, report.insertedDuffs) + assertEquals(1, report.skippedForeign) + assertNull( + "the contact's coin must not enter the store", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 9)), + ) + } + + @Test + fun reconcileStampsResolvedAccountOnHealedRows() = runTest { + // The blocking scenario from review: persistence lost BOTH the TXO + // and its address row. The heal must resolve the owning Room account + // from the inventory's account tuple and stamp it on the inserted + // row — a healed row with neither accountId nor a resolvable address + // is skipped by the restore loader at the next mirror-reload, + // recreating the fund loss the heal repaired. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + // Production shape: onPersistAccountRegistration stores the FFI's + // 32-zero-byte identity ids verbatim (the entity ctor default of an + // EMPTY array never occurs on persisted rows). + val bip44Id = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 0, + accountIndex = 0, + accountTypeName = "standardBip44", + userIdentityId = ByteArray(32), + friendIdentityId = ByteArray(32), + ), + ) + // Deliberately NO core_addresses row for this address. + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 11, amount = 70_000L, address = "yOrphanAddr"), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(0, report.healedUnowned) + assertEquals( + "the healed row must carry the account resolved from the inventory tuple", + bip44Id, db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 11))!!.accountId, + ) + } + + @Test + fun reconcileCountsHealsWhoseAccountCannotBeResolved() = runTest { + // No matching Room account row at all (a store damaged past the + // account registrations): the heal proceeds — the address projection + // may still attribute it — but the unresolved owner is surfaced. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 12, amount = 5_000L), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(1, report.healedUnowned) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))!!.accountId) + } + + @Test + fun reconcileForeignSkipKeysOffTheInventoryTagWithoutAddressRow() = runTest { + // The tag is the authoritative foreign check: a contact's coin must + // be skipped even when its address row never survived persistence + // (the case the address-based fallback cannot see). + val json = + """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,""" + + """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + + """"address":"yContactNoRow","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(1, report.skippedForeign) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 13))) + } + + @Test + fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { + // A row marked spent while the engine lists the coin unspent: either + // a lost release event (pre-rust-dashcore#971) or a live spend the + // store wrote before the engine settled. Un-marking a coin + // mid-payment would let the wallet double-spend it, so this is + // counted and logged but NEVER changed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 7, 77_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson( + unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), + spent = emptyList(), + ), + tipHeight = reconcileTip, + ) + assertEquals(1, report.stuckSpent) + assertEquals(77_000L, report.stuckSpentDuffs) + assertTrue( + "the row must stay spent — un-marking is never done by reconciliation", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!!.isSpent, + ) + } + + // ── Paged reconcile transport ───────────────────────────────────── + + @Test + fun reconcileWalksEveryPageOfTheEngineInventory() = runTest { + // The engine inventory is chain-controlled in size, so the sweep + // reads it a page at a time. Every page must be applied — a sweep + // that healed only the first one would leave most of a damaged + // mirror unrepaired, and silently. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 5).map { Triple(changeTxid.toHexLower(), 20 + it, 1_000L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + + assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) + assertEquals(5, report.engineUtxos) + assertEquals(5, report.inserted) + assertEquals(5_000L, report.insertedDuffs) + for (vout in 20 until 25) { + assertNotNull( + "the row at vout=$vout must be healed whichever page carried it", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, vout)), + ) + } + } + + @Test + fun reconcileStopsAtAFailedPageAndKeepsWhatItAlreadyHealed() = runTest { + // A transport that dies mid-sweep must not discard the pages that + // already landed — the pass is insert-only and idempotent, so they + // are already correct — and must not report a clean run either. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 4).map { Triple(changeTxid.toHexLower(), 30 + it, 500L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = { cursor, limit -> + if (cursor == null) engine.page(cursor, limit) else null + }, + classifyOutpoints = engine::classify, + ) + + assertEquals(1, report.transportFailures) + assertEquals("only the page that arrived", 2, report.inserted) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 30))) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 32))) + } + + @Test + fun reconcileClassifiesStoreRowsInBoundedBatches() = runTest { + // The reverse direction is inverted: the STORE is paged and the + // engine is asked about one page at a time, so neither side builds + // a set over a whole inventory. Every batch must still be answered + // and counted. + for (vout in 40 until 43) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + + assertEquals("one classification batch per store page", 3, engine.batches) + assertEquals("every store row reached the classifier", 3, engine.classified) + assertEquals(3, report.wouldRemove) + assertEquals(300L, report.wouldRemoveDuffs) + } + + @Test + fun reconcileStopsWhenAClassificationBatchFails() = runTest { + // No verdicts, no classification: the reverse pass stops rather + // than guessing at rows it could not ask the engine about. + for (vout in 50 until 53) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = { null }, + ) + + assertEquals(1, report.transportFailures) + assertEquals(0, report.wouldRemove) + } + + /** + * Drive the paged reconcile from one whole-inventory JSON blob — the + * shape these tests describe an engine in, and the shape the native + * side used to hand over in a single unbounded call. + * + * The blob is served the way the transport now serves it: sliced into + * bounded pages behind an opaque cursor, with a separate positional + * classifier for the outpoints the store asks about. [pageSize] + * defaults to 2, so a test describing more than a couple of rows walks + * the real cursor loop rather than a single page. + */ + private suspend fun PlatformWalletPersistenceHandler.reconcileFromInventory( + walletId: ByteArray, + inventoryJson: String, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = 2, + ): PlatformWalletPersistenceHandler.TxoReconcileReport { + val engine = FakeEngine(inventoryJson) + return reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + } + + /** + * A stand-in for the engine's paged inventory transport, built from the + * whole-inventory JSON a test writes out. Pages come back behind an + * opaque ordinal cursor — the real cursor is opaque too, the handler + * only ever hands back what it was given — and classification answers + * positionally out of the same two inventories: 1 unspent, 2 spent, 0 + * neither. + */ + private class FakeEngine(inventoryJson: String) { + private val utxos: List + private val errors: List + private val unspentKeys: Set + private val spentKeys: Set + + /** Inventory pages served, classification batches answered, and + * outpoints classified across those batches. */ + var pages = 0 + private set + var batches = 0 + private set + var classified = 0 + private set + + init { + val root = kotlinx.serialization.json.Json + .parseToJsonElement(inventoryJson).jsonObject + utxos = root["utxos"]?.jsonArray?.map { it.jsonObject } ?: emptyList() + errors = root["errors"]?.jsonArray?.toList() ?: emptyList() + unspentKeys = utxos.map { + key( + it["txid"]!!.jsonPrimitive.content, + it["vout"]!!.jsonPrimitive.int, + ) + }.toSet() + spentKeys = (root["spent"]?.jsonArray?.toList() ?: emptyList()).map { + key( + it.jsonObject["txid"]!!.jsonPrimitive.content, + it.jsonObject["vout"]!!.jsonPrimitive.int, + ) + }.toSet() + } + + fun page(cursor: String?, limit: Int): String { + pages++ + val start = cursor?.toInt() ?: 0 + val slice = utxos.drop(start).take(limit) + val next = start + slice.size + val hasMore = next < utxos.size + // Account read failures belong to the sweep, not to a page: the + // native side reports each faulted account once, so the fake + // puts them all on the first page. + val faults = if (start == 0) errors.joinToString(",") { it.toString() } else "" + return """{"utxos":[${slice.joinToString(",") { it.toString() }}],""" + + """"errors":[$faults],""" + + """"cursor":${if (hasMore) "\"$next\"" else "null"},"hasMore":$hasMore}""" + } + + fun classify(outpoints: ByteArray): ByteArray { + batches++ + val count = outpoints.size / OUTPOINT_SIZE + classified += count + val verdicts = ByteArray(count) + for (i in 0 until count) { + val base = i * OUTPOINT_SIZE + val txidHex = outpoints.copyOfRange(base, base + 32) + .joinToString("") { "%02x".format(it) } + var vout = 0 + for (b in 3 downTo 0) { + vout = (vout shl 8) or (outpoints[base + 32 + b].toInt() and 0xFF) + } + val k = key(txidHex, vout) + verdicts[i] = when { + k in unspentKeys -> 1 + k in spentKeys -> 2 + else -> 0 + } + } + return verdicts + } + + private fun key(txidHex: String, vout: Int) = "$txidHex:$vout" + + private companion object { + /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ + const val OUTPOINT_SIZE = 36 + } + } } /** diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e8e717d0a18..7fff75fae98 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -41,6 +41,20 @@ impl From<&dashcore::OutPoint> for OutPointFFI { } } +impl From<&OutPointFFI> for dashcore::OutPoint { + /// The inverse of the conversion above, and the one authority for it. + /// Hosts hand outpoints BACK across the boundary when they ask the + /// engine about rows they already hold (the store-reconcile + /// classification batch), so the round trip has to land on exactly the + /// bytes that went out. + fn from(ffi: &OutPointFFI) -> Self { + dashcore::OutPoint { + txid: ::from_byte_array(ffi.txid), + vout: ffi.vout, + } + } +} + /// Outpoint of a TXO that was spent, paired with the spending /// transaction's txid. Replaces the bare `OutPointFFI` on /// `AccountChangeSetFFI.utxos_spent` so the Swift persister can diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..8b81df4250b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -28,8 +28,9 @@ use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, + TrackedAssetLockEntryFFI, WalletIdentityRowFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -563,26 +564,7 @@ pub unsafe extern "C" fn platform_wallet_account_utxos( if rows.is_empty() { return PlatformWalletFFIResult::ok(); } - let entries: Vec = rows - .into_iter() - .map(|s| { - let script_len = s.script_pubkey.len(); - let script_ptr = if script_len == 0 { - std::ptr::null_mut() - } else { - Box::into_raw(s.script_pubkey.into_boxed_slice()) as *mut u8 - }; - AccountUtxoEntryFFI { - outpoint_txid: txid_to_array(&s.outpoint.txid), - outpoint_vout: s.outpoint.vout, - value_duffs: s.value_duffs, - script_pubkey: script_ptr, - script_pubkey_len: script_len, - height: s.height, - is_locked: s.is_locked, - } - }) - .collect(); + let entries: Vec = rows.into_iter().map(utxo_entry_ffi).collect(); let count = entries.len(); let boxed = entries.into_boxed_slice(); *out_utxos = Box::into_raw(boxed) as *const _; @@ -590,6 +572,28 @@ pub unsafe extern "C" fn platform_wallet_account_utxos( PlatformWalletFFIResult::ok() } +/// One snapshot row to its FFI entry, heap-owning the script bytes. Shared +/// by the paged and unpaged exports so their row shape — and the +/// `platform_wallet_account_utxos_free` contract both rely on — stays one +/// thing. +fn utxo_entry_ffi(s: AccountUtxoSnapshot) -> AccountUtxoEntryFFI { + let script_len = s.script_pubkey.len(); + let script_ptr = if script_len == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(s.script_pubkey.into_boxed_slice()) as *mut u8 + }; + AccountUtxoEntryFFI { + outpoint_txid: txid_to_array(&s.outpoint.txid), + outpoint_vout: s.outpoint.vout, + value_duffs: s.value_duffs, + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height: s.height, + is_locked: s.is_locked, + } +} + #[no_mangle] pub unsafe extern "C" fn platform_wallet_account_utxos_free( utxos: *mut AccountUtxoEntryFFI, @@ -610,6 +614,197 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); } +/// One outpoint-ordered page of an account's UTXO inventory — the bounded +/// form of `platform_wallet_account_utxos`. +/// +/// A wallet's UTXO count is chain-controlled (anyone who knows a watched +/// address can keep sending dust to it), so a periodic host-side audit +/// must never materialize the whole inventory at once. `after_txid` + +/// `after_vout` name the last outpoint of the previous page; pass a NULL +/// `after_txid` to start at the beginning. `limit` caps the rows returned +/// (0 means "no limit" — a paging caller should always pass a real cap), +/// and `out_has_more` reports whether further pages remain. +/// +/// Rows are freed with `platform_wallet_account_utxos_free`, the same +/// entry type and the same deallocator as the unpaged call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_utxos_page( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + after_txid: *const u8, + after_vout: u32, + limit: usize, + out_utxos: *mut *const AccountUtxoEntryFFI, + out_count: *mut usize, + out_has_more: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_utxos); + check_ptr!(out_count); + check_ptr!(out_has_more); + *out_utxos = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + // A NULL cursor is "from the beginning" — the only way to say it, since + // the all-zero txid is a legal (if unreachable) outpoint. + let after = if after_txid.is_null() { + None + } else { + let raw: [u8; 32] = std::ptr::read(after_txid as *const [u8; 32]); + Some(dashcore::OutPoint::from(&OutPointFFI { + txid: raw, + vout: after_vout, + })) + }; + let Some((rows, has_more)) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.account_utxos_page_blocking(&wid, &target, after, limit) + }) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows.into_iter().map(utxo_entry_ffi).collect(); + let count = entries.len(); + *out_utxos = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +/// Classify `count` outpoints against the wallet's live engine state, in +/// one pass under one read lock. `out_classes` receives a `count`-byte +/// buffer, positionally aligned with the input: 0 unknown, 1 unspent, 2 +/// spent (see `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`). +/// +/// The inverse direction of `platform_wallet_account_utxos_page`: a host +/// that mirrors the wallet's TXOs pages its OWN rows and asks about them +/// in batches, so neither side ever holds a full engine inventory. Cost is +/// the batch size times the account count, never the inventory size. +/// +/// `2` means some recorded transaction spends the outpoint — including one +/// still in the mempool. It is not proof of a settled spend. +/// +/// Free the verdicts with `platform_wallet_classify_outpoints_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_classify_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + outpoints: *const OutPointFFI, + count: usize, + out_classes: *mut *const u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_classes); + check_ptr!(out_count); + *out_classes = std::ptr::null(); + *out_count = 0; + if count == 0 { + return PlatformWalletFFIResult::ok(); + } + check_ptr!(outpoints); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let requested: Vec = std::slice::from_raw_parts(outpoints, count) + .iter() + .map(dashcore::OutPoint::from) + .collect(); + let Some(classes) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.classify_outpoints_blocking(&wid, &requested) + }) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let len = classes.len(); + *out_classes = Box::into_raw(classes.into_boxed_slice()) as *const u8; + *out_count = len; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_classify_outpoints_free(classes: *mut u8, count: usize) { + if classes.is_null() || count == 0 { + return; + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(classes, count)); +} + +/// The account's spent-outpoint inventory — the second half of the +/// store-reconcile surface (`platform_wallet_account_utxos` is the unspent +/// half). A persistence-mirror row still marked unspent whose outpoint +/// appears here lost its spend update (dashpay/platform#4425); a row in +/// NEITHER inventory is swept/abandoned residue (pre-rust-dashcore#971 +/// stores). Free with `platform_wallet_account_spent_outpoints_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_outpoints); + check_ptr!(out_count); + *out_outpoints = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(rows) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.account_spent_outpoints_blocking(&wid, &target)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows.iter().map(OutPointFFI::from).collect(); + let count = entries.len(); + *out_outpoints = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints_free( + outpoints: *mut OutPointFFI, + count: usize, +) { + if outpoints.is_null() || count == 0 { + return; + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(outpoints, count)); +} + // --------------------------------------------------------------------------- // Phase 6 — Per-account transactions // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2b0b23dbd49..50bf7e69ec2 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4232,6 +4232,7 @@ unsafe fn restore_core_address_pools( pool_entries: &[AccountAddressPoolFFI], network: Network, wallet_id: &[u8; 32], + signing_wallet: Option<&Wallet>, ) -> Result { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let mut pools_routed = 0usize; @@ -4418,11 +4419,102 @@ unsafe fn restore_core_address_pools( } } } + // Resolve the pool's key source BEFORE taking the mutable pool + // borrow, for the hole-repair pass below. Degrades to NoKeySource + // for anything unresolvable (no signing wallet handle, provider + // pools without public derivation, etc.) — repair is then skipped. + let key_source = signing_wallet + .and_then(|wallet| { + key_wallet::transaction_checking::transaction_router::AccountTypeToCheck::try_from( + &*managed_type, + ) + .ok() + .map(|check_type| { + let account_index = match &account_type { + AccountType::Standard { + index, .. + } + | AccountType::CoinJoin { + index, + } + | AccountType::DashpayReceivingFunds { + index, .. + } + | AccountType::DashpayExternalAccount { + index, .. + } => Some(*index), + AccountType::IdentityTopUp { + registration_index, + } => Some(*registration_index), + _ => None, + }; + wallet.key_source_for_account_type(&check_type, account_index) + }) + }) + .unwrap_or(key_wallet::KeySource::NoKeySource); + let mut managed_pools = managed_type.address_pools_mut(); match managed_pools.iter_mut().find(|p| p.pool_type == pool_type) { Some(pool) => { pools_routed += infos.len(); restore_address_pool(pool, infos); + // Hole repair: mirrors have been observed dropping address + // rows (2026-08-19 field wallet: BIP44-change rows 875..=890 + // absent between surviving rows), and ingesting the sparse + // list as-is makes outputs paying the missing addresses + // permanently unrecognizable — a rescan-proof fund loss — + // while the row-derived `highest_generated` suppresses the + // gap-limit re-derivation that would repair it. Derivation + // is pure key arithmetic, so re-derive every missing index + // up to the persisted watermark. Never fatal: a failed + // repair restores exactly what the rows carried (the + // pre-repair behavior). + let repairable = !matches!(key_source, key_wallet::KeySource::NoKeySource) + && !matches!(pool_type, AddressPoolType::AbsentHardened); + if !repairable { + // Announce the skip instead of silently claiming full + // coverage. DashPay contact pools land here by design — + // `key_source_for_account_type` returns NoKeySource for + // both DashPay variants (their keys derive from identity + // material, not an account xpub) — and their pools are + // re-derived by DashPay contact sync at runtime, so a + // sparse restore self-heals through that path instead. + // Hardened pools cannot be publicly derived at all. + tracing::info!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + "load: address-pool hole repair skipped (no public key source); pool restored as persisted" + ); + } + if repairable { + if let Some(max_idx) = pool.highest_generated { + match pool.ensure_contiguous_to(max_idx, &key_source) { + Ok(0) => {} + Ok(filled) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + filled, + "load: repaired address-pool holes left by dropped \ + persisted rows; outputs paying these addresses are \ + recognizable again" + ); + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + error = %e, + "load: address-pool hole repair failed; pool restored \ + as persisted (sparse)" + ); + } + } + } + } } None => { pools_dropped += 1; @@ -4885,7 +4977,13 @@ fn build_wallet_start_state( // SAFETY: `pool_entries` is a valid slice (checked above) and each // row's `addresses_ptr` follows the load-callback contract. unsafe { - restore_core_address_pools(&mut wallet_info, pool_entries, network, &entry.wallet_id)?; + restore_core_address_pools( + &mut wallet_info, + pool_entries, + network, + &entry.wallet_id, + Some(&wallet), + )?; } } @@ -8145,7 +8243,7 @@ mod tests { // SAFETY: `row` / `addr_c` / `path_c` outlive the call below. let stats = unsafe { - restore_core_address_pools(&mut wallet_info, &pools, Network::Testnet, &[0u8; 32]) + restore_core_address_pools(&mut wallet_info, &pools, Network::Testnet, &[0u8; 32], None) } .expect("restore must succeed for a well-formed provider pool"); assert_eq!( diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ace055e60aa..29e3cc796f1 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1,5 +1,6 @@ //! Read-only accessors on [`PlatformWalletManager`]. +use std::ops::Bound; use std::sync::Arc; use dashcore::{OutPoint, Txid}; @@ -206,6 +207,17 @@ pub struct AccountAddressInfoSnapshot { pub public_key_bytes: Vec, } +/// [`PlatformWalletManager::classify_outpoints_blocking`] verdicts. The +/// engine has no record of this outpoint on any account — for a store row +/// still marked unspent, that is residue of a swept or abandoned +/// transaction, not proof the coin is gone. +pub const OUTPOINT_CLASS_UNKNOWN: u8 = 0; +/// The engine holds this outpoint as a live UTXO. +pub const OUTPOINT_CLASS_UNSPENT: u8 = 1; +/// Some recorded transaction spends this outpoint. Says nothing about +/// confirmation: the engine's spent set includes mempool spends. +pub const OUTPOINT_CLASS_SPENT: u8 = 2; + /// Snapshot of one UTXO row inside an account. #[derive(Debug, Clone)] pub struct AccountUtxoSnapshot { @@ -845,6 +857,157 @@ impl PlatformWalletManager

{ .collect() } + /// One outpoint-ordered page of an account's UTXO inventory — the + /// bounded form of [`Self::account_utxos_blocking`], for callers that + /// must not hold a whole wallet's inventory at once. + /// + /// A wallet's UTXO count is chain-controlled: anyone who knows a + /// watched address can keep sending dust to it, so the full-inventory + /// read has no upper bound a mobile process can rely on. Pages solve + /// that: `after` is the last outpoint of the previous page (`None` + /// starts at the beginning), `limit` caps the rows returned, and the + /// returned flag says whether more rows follow. Because the account's + /// UTXOs live in a `BTreeMap` keyed by outpoint, a page is a partial + /// select over the keys — no intermediate copy of the rows the caller + /// skipped. + /// + /// `limit == 0` means "no limit", matching + /// [`Self::account_transactions_blocking`]; a paging caller should + /// always pass a real cap. + /// + /// The order is `OutPoint`'s own — deterministic, but neither + /// chronological nor the display order of a txid. Callers only need it + /// to be stable, which it is for as long as the account's UTXO set + /// does not change. A concurrent change between pages can drop a row + /// out of the sweep or repeat one; both are benign for the reconcile + /// this serves (insert-only, idempotent, and re-run on a cadence). + pub fn account_utxos_page_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + after: Option, + limit: usize, + ) -> (Vec, bool) { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return (Vec::new(), false); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return (Vec::new(), false); + }; + // Keys-only accounts (identity / asset-lock / provider) never + // carry UTXOs by construction — an empty page, never a partial one. + let Some(funds) = account.as_funds() else { + return (Vec::new(), false); + }; + let cursor = match after { + Some(outpoint) => (Bound::Excluded(outpoint), Bound::Unbounded), + None => (Bound::Unbounded, Bound::Unbounded), + }; + let mut iter = funds.utxos.range(cursor); + let take = if limit == 0 { usize::MAX } else { limit }; + let mut rows: Vec = Vec::new(); + for (_, utxo) in iter.by_ref().take(take) { + rows.push(AccountUtxoSnapshot { + outpoint: utxo.outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + height: utxo.height, + is_locked: utxo.is_locked, + }); + } + // One probe past the page rather than an over-fetch-and-truncate: + // `range` is lazy, so this costs a single tree step. + let has_more = iter.next().is_some(); + (rows, has_more) + } + + /// Classify each of `outpoints` against the wallet's live engine + /// state: `0` unknown, `1` unspent, `2` spent. The reverse half of the + /// store-reconcile transport — a persistence mirror pages its own rows + /// and asks about them in batches, instead of pulling both engine + /// inventories over and holding them as sets. + /// + /// The answer is per outpoint, looked up in each account's UTXO map + /// and spent set, so the cost is the batch size times the account + /// count — never the size of either inventory. Every account is + /// consulted under ONE read lock. + /// + /// Unspent wins a tie: an outpoint the engine still holds as a UTXO is + /// unspent whatever else references it. `2` means only that some + /// recorded transaction spends it — the spend may be in the mempool, + /// which is why callers must not treat it as settled. + /// + /// The returned vector is positional and always the same length as + /// `outpoints`; an unknown wallet classifies everything as `0`. + pub fn classify_outpoints_blocking( + &self, + wallet_id: &WalletId, + outpoints: &[OutPoint], + ) -> Vec { + let mut classes = vec![OUTPOINT_CLASS_UNKNOWN; outpoints.len()]; + if outpoints.is_empty() { + return classes; + } + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return classes; + }; + let accounts = info.core_wallet.accounts.all_accounts(); + for account in accounts.iter() { + let Some(funds) = account.as_funds() else { + continue; + }; + let spent = funds.spent_outpoints(); + for (slot, outpoint) in classes.iter_mut().zip(outpoints.iter()) { + if *slot == OUTPOINT_CLASS_UNSPENT { + // Already settled by an earlier account, and unspent is + // the strongest answer there is. + continue; + } + if funds.utxos.contains_key(outpoint) { + *slot = OUTPOINT_CLASS_UNSPENT; + } else if spent.contains(outpoint) { + *slot = OUTPOINT_CLASS_SPENT; + } + } + } + classes + } + + /// The outpoints this account knows were spent by recorded + /// transactions — the second half of the store-reconcile inventory + /// ([`Self::account_utxos_blocking`] is the unspent half). Lets a + /// persistence-mirror audit classify a store row marked unspent: + /// present here → the row lost its spend update (flip it, + /// dashpay/platform#4425); present in neither inventory → residue of a + /// swept/abandoned transaction (pre-rust-dashcore#971 stores). + pub fn account_spent_outpoints_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + let Some(funds) = account.as_funds() else { + return Vec::new(); + }; + funds.spent_outpoints().iter().copied().collect() + } + // ----------------------------------------------------------------- // Phase 6 — Per-account transactions // ----------------------------------------------------------------- @@ -1193,6 +1356,183 @@ fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { } } +#[cfg(test)] +mod utxo_inventory_transport_tests { + use std::sync::Arc; + + use dashcore::{OutPoint, ScriptBuf, TxOut, Txid}; + use key_wallet::account::AccountType; + use key_wallet::account::StandardAccountType; + use key_wallet::utxo::Utxo; + + use crate::manager::accessors::{OUTPOINT_CLASS_UNKNOWN, OUTPOINT_CLASS_UNSPENT}; + use crate::test_support::{test_platform_wallet_manager, NoopTestPersister}; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: ::from_byte_array([byte; 32]), + vout, + } + } + + /// Put `count` UTXOs on the wallet's BIP44 account. The engine normally + /// fills this map from block processing; a test only needs the map's + /// contents, and the accessors read nothing else. + async fn seed_utxos( + manager: &Arc>, + wallet_id: &WalletId, + outpoints: &[OutPoint], + ) { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(wallet_id).expect("known wallet"); + let mut accounts = info.core_wallet.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|a| a.managed_account_type().to_account_type() == bip44()) + .expect("BIP44 account"); + // `Utxo` carries an address; the accessors never read it, so any + // address the account already derived will do. + let address = account + .managed_account_type() + .address_pools() + .first() + .and_then(|pool| { + pool.addresses + .values() + .next() + .map(|info| info.address.clone()) + }) + .expect("a derived address"); + let funds = account.as_funds_mut().expect("funds account"); + for op in outpoints { + funds.utxos.insert( + *op, + Utxo::new( + *op, + TxOut { + value: 1_000, + script_pubkey: ScriptBuf::new(), + }, + address.clone(), + 100, + false, + ), + ); + } + } + + fn bip44() -> AccountType { + AccountType::Standard { + standard_account_type: StandardAccountType::BIP44Account, + index: 0, + } + } + + #[tokio::test] + async fn utxo_pages_cover_the_account_exactly_once_and_stop() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + // Five outpoints across two txids, so the page boundary lands inside + // a txid as well as between them. + let seeded: Vec = vec![ + outpoint(1, 0), + outpoint(1, 1), + outpoint(1, 2), + outpoint(2, 0), + outpoint(2, 1), + ]; + seed_utxos(&manager, &wallet_id, &seeded).await; + + tokio::task::spawn_blocking(move || { + let target = bip44(); + let mut seen: Vec = Vec::new(); + let mut after: Option = None; + let mut pages = 0; + loop { + let (rows, has_more) = + manager.account_utxos_page_blocking(&wallet_id, &target, after, 2); + pages += 1; + assert!(rows.len() <= 2, "a page must never exceed its limit"); + if let Some(last) = rows.last() { + after = Some(last.outpoint); + } + seen.extend(rows.iter().map(|r| r.outpoint)); + if !has_more { + break; + } + assert!(pages < 10, "paging must terminate"); + } + assert_eq!(3, pages, "5 rows at 2 per page"); + assert_eq!(5, seen.len(), "every UTXO is delivered"); + let mut unique = seen.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(5, unique.len(), "and none of them twice"); + let mut sorted = seen.clone(); + sorted.sort(); + assert_eq!(sorted, seen, "pages walk the outpoint order"); + + // The unpaged accessor is the same inventory — the page cursor + // is a transport detail, not a different view. + let whole = manager.account_utxos_blocking(&wallet_id, &target); + assert_eq!(whole.len(), seen.len()); + + // An exhausted cursor is an empty terminal page, not a loop. + let (rows, has_more) = + manager.account_utxos_page_blocking(&wallet_id, &target, seen.last().copied(), 2); + assert!(rows.is_empty()); + assert!(!has_more); + + // A keys-only account has no UTXOs, and says so without + // claiming another page. + let (rows, has_more) = manager.account_utxos_page_blocking( + &wallet_id, + &AccountType::IdentityRegistration, + None, + 2, + ); + assert!(rows.is_empty()); + assert!(!has_more); + }) + .await + .expect("blocking accessor task"); + } + + #[tokio::test] + async fn classification_is_positional_and_covers_unknown_outpoints() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let held = outpoint(3, 7); + seed_utxos(&manager, &wallet_id, &[held]).await; + + tokio::task::spawn_blocking(move || { + let asked = vec![outpoint(9, 0), held, outpoint(9, 1)]; + let classes = manager.classify_outpoints_blocking(&wallet_id, &asked); + assert_eq!( + vec![ + OUTPOINT_CLASS_UNKNOWN, + OUTPOINT_CLASS_UNSPENT, + OUTPOINT_CLASS_UNKNOWN + ], + classes, + "verdicts line up with the outpoints that were asked about", + ); + + // The length contract holds at both edges: an empty batch, and + // an unknown wallet, still answer positionally. + assert!(manager + .classify_outpoints_blocking(&wallet_id, &[]) + .is_empty()); + assert_eq!( + vec![OUTPOINT_CLASS_UNKNOWN; 3], + manager.classify_outpoints_blocking(&[0xFF; 32], &asked), + ); + }) + .await + .expect("blocking accessor task"); + } +} + #[cfg(test)] mod spv_rescan_tests { use std::sync::Arc; diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..d07eabc98cb 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -17,6 +17,10 @@ rs-sdk-ffi = { path = "../rs-sdk-ffi" } platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } key-wallet-ffi = { workspace = true } dash-network = { workspace = true, features = ["ffi"] } +# Address encoding for the reconcile sweep's engine-UTXO export +# (walletManagerUtxosPageJson) — already in the graph via +# platform-wallet-ffi, so this adds no new build cost. +dashcore = { workspace = true } log = "0.4" zeroize = "1" @@ -24,7 +28,6 @@ zeroize = "1" android_logger = "0.14" [dev-dependencies] -dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c1ac4b1d871..954ec8bf30c 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3201,6 +3201,470 @@ fn core_selection_strategy( } } +/// Default page size for `walletManagerUtxosPageJson` when the caller +/// passes a non-positive `limit`, and the hard cap it clamps to. The +/// point of the paged transport is that neither side ever holds a whole +/// wallet's inventory, so the cap is enforced here rather than trusted +/// from the host. +const UTXO_PAGE_DEFAULT: usize = 512; +const UTXO_PAGE_MAX: usize = 4096; + +/// The account tuple, packed into one comparable key. Accounts are swept +/// in the order of this key rather than in the order +/// `get_account_balances` happens to return them: the sweep is resumable +/// across calls, so it needs an order that a concurrently registered or +/// removed account cannot shift underneath it. A new account sorting +/// before the cursor is missed by THIS sweep and picked up by the next; +/// one sorting after it is included. Neither can make the sweep skip or +/// repeat data it has already paged — which an ordinal cursor would. +fn account_sort_key(acc: &platform_wallet_ffi::AccountBalanceEntryFFI) -> [u8; 78] { + let mut key = [0u8; 78]; + key[0] = acc.type_tag as u8; + key[1] = acc.standard_tag as u8; + key[2..6].copy_from_slice(&acc.index.to_be_bytes()); + key[6..10].copy_from_slice(&acc.registration_index.to_be_bytes()); + key[10..14].copy_from_slice(&acc.key_class.to_be_bytes()); + key[14..46].copy_from_slice(&acc.user_identity_id); + key[46..78].copy_from_slice(&acc.friend_identity_id); + key +} + +/// Where a paged inventory sweep left off: the account it was inside and +/// the last outpoint it emitted from that account. +struct UtxoPageCursor { + account_key: [u8; 78], + txid: [u8; 32], + vout: u32, +} + +/// Parse `::`. The cursor is opaque to the +/// host — it only ever hands back what a previous page returned — so an +/// unparseable one restarts the sweep rather than failing it. +fn parse_utxo_page_cursor(raw: &str) -> Option { + let mut parts = raw.split(':'); + let key_hex = parts.next()?; + let txid_hex = parts.next()?; + let vout: u32 = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + let key_bytes = hex_bytes(key_hex)?; + let txid_bytes = hex_bytes(txid_hex)?; + let mut cursor = UtxoPageCursor { + account_key: [0u8; 78], + txid: [0u8; 32], + vout, + }; + if key_bytes.len() != cursor.account_key.len() || txid_bytes.len() != cursor.txid.len() { + return None; + } + cursor.account_key.copy_from_slice(&key_bytes); + cursor.txid.copy_from_slice(&txid_bytes); + Some(cursor) +} + +/// Lower-hex → bytes; `None` on odd length or a non-hex digit. +fn hex_bytes(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + let raw = hex.as_bytes(); + let mut out = Vec::with_capacity(raw.len() / 2); + for pair in raw.chunks(2) { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push(((hi << 4) | lo) as u8); + } + Some(out) +} + +/// One bounded page of the engine's UTXO inventory across every account of +/// one wallet — the source of truth `PlatformWalletManager.reconcileTxoStore` +/// diffs against the Room `txos` mirror (dropped change outputs of +/// CoinJoin-funded sends leave the mirror short; the engine reloads from +/// that mirror on restart, so an un-reconciled hole becomes a fund-loss). +/// +/// Paged rather than swept whole because inventory size is +/// chain-controlled: anyone who knows a watched address can keep sending +/// dust outputs to it, and a periodic full-inventory read would let them +/// decide how much a phone allocates at every SYNCED transition and every +/// 30-minute pass. Here nothing bigger than one page is ever formatted, +/// copied across JNI, or parsed. +/// +/// Returns a JSON object +/// `{"utxos":[...],"errors":[...],"cursor":,"hasMore":}`. +/// Each `utxos` row is one output the engine currently holds, tagged with +/// its owning account. `cursor` is opaque: hand it back verbatim on the +/// next call (`null`/absent starts from the beginning) and keep going while +/// `hasMore` is true. `limit` caps the rows in one page — non-positive +/// means the default, and anything larger than the cap is clamped. +/// +/// `network` follows `Network.ffiValue` (0 mainnet, 2 devnet, 3 regtest, +/// else testnet) and selects the address encoding; an output whose script +/// has no address form carries an empty `address` for the caller to skip. +/// A per-account read failure lands in `errors` instead of failing the +/// page — the reconciler must still see every account that DID read, so +/// one faulted account cannot mask the others' repair. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + network: jni::sys::jint, + cursor: JString, + limit: jni::sys::jint, +) -> jni::sys::jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + let net = match network { + 0 => dashcore::Network::Mainnet, + 2 => dashcore::Network::Devnet, + 3 => dashcore::Network::Regtest, + _ => dashcore::Network::Testnet, + }; + let page_limit = if limit <= 0 { + UTXO_PAGE_DEFAULT + } else { + (limit as usize).min(UTXO_PAGE_MAX) + }; + let resume = if cursor.is_null() { + None + } else { + match env.get_string(&cursor) { + Ok(s) => parse_utxo_page_cursor(&String::from(s)), + Err(_) => None, + } + }; + + let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_get_account_balances( + manager_handle as Handle, + wid.as_ptr(), + &mut entries, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let mut next_cursor: Option = None; + let mut has_more = false; + if !entries.is_null() && count > 0 { + let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; + let keys: Vec<[u8; 78]> = accounts.iter().map(account_sort_key).collect(); + let mut order: Vec = (0..accounts.len()).collect(); + order.sort_by(|a, b| keys[*a].cmp(&keys[*b])); + let mut remaining = page_limit; + for &i in &order { + let acc = &accounts[i]; + let key = keys[i]; + // Resume: accounts before the cursor's are already swept, + // the cursor's own continues after its last outpoint, and + // every later account starts from the beginning. + let after = match &resume { + Some(c) if key < c.account_key => continue, + Some(c) if key == c.account_key => Some((c.txid, c.vout)), + _ => None, + }; + if remaining == 0 { + // The page filled on an earlier account and this one is + // still unswept — resume from the cursor already set. + has_more = true; + break; + } + let spec = platform_wallet_ffi::AccountSpecFFI { + type_tag: acc.type_tag as u8, + standard_tag: acc.standard_tag as u8, + index: acc.index, + registration_index: acc.registration_index, + key_class: acc.key_class, + user_identity_id: acc.user_identity_id, + friend_identity_id: acc.friend_identity_id, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }; + let mut utxos: *const platform_wallet_ffi::AccountUtxoEntryFFI = ptr::null(); + let mut utxo_count: usize = 0; + let mut account_has_more = false; + // The cursor txid has to outlive the call — a pointer taken + // from a temporary inside the argument list would dangle. + let after_txid: Option<[u8; 32]> = after.map(|(txid, _)| txid); + let res = unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_page( + manager_handle as Handle, + wid.as_ptr(), + &spec, + after_txid.as_ref().map_or(ptr::null(), |t| t.as_ptr()), + after.map_or(0, |(_, vout)| vout), + remaining, + &mut utxos, + &mut utxo_count, + &mut account_has_more, + ) + }; + if let Some(msg) = pwffi_error_message(res) { + errors.push(format!( + "{{\"typeTag\":{},\"index\":{},\"message\":{}}}", + acc.type_tag as u8, + acc.index, + json_escape(&msg), + )); + continue; + } + if !utxos.is_null() && utxo_count > 0 { + let items = unsafe { std::slice::from_raw_parts(utxos, utxo_count) }; + for u in items { + let script: &[u8] = if u.script_pubkey.is_null() || u.script_pubkey_len == 0 + { + &[] + } else { + unsafe { + std::slice::from_raw_parts(u.script_pubkey, u.script_pubkey_len) + } + }; + let script_buf = dashcore::ScriptBuf::from(script.to_vec()); + let address = dashcore::Address::from_script(&script_buf, net) + .map(|a| a.to_string()) + .unwrap_or_default(); + // The DashPay identity halves of the account tuple are + // emitted only when set (all-zero on every non-DashPay + // account) — the reconcile needs the COMPLETE tuple to + // resolve the owning Room account and stamp it on healed + // rows, so ownership survives even when the address + // projection is absent. + let mut identity_suffix = String::new(); + if acc.user_identity_id != [0u8; 32] || acc.friend_identity_id != [0u8; 32] + { + identity_suffix = format!( + ",\"userIdentityId\":\"{}\",\"friendIdentityId\":\"{}\"", + hex_lower(&acc.user_identity_id), + hex_lower(&acc.friend_identity_id), + ); + } + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"registrationIndex\":{},\"keyClass\":{},\ + \"txid\":\"{}\",\"vout\":{},\"amount\":{},\ + \"address\":{},\"scriptHex\":\"{}\",\ + \"height\":{},\"isLocked\":{}{}}}", + acc.type_tag as u8, + acc.standard_tag as u8, + acc.index, + acc.registration_index, + acc.key_class, + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + u.value_duffs, + json_escape(&address), + hex_lower(script), + u.height, + u.is_locked, + identity_suffix, + )); + next_cursor = Some(format!( + "{}:{}:{}", + hex_lower(&key), + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + )); + } + remaining -= utxo_count.min(remaining); + unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_free( + utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, + utxo_count, + ) + }; + } + if account_has_more { + // Stopped inside this account: the cursor already names + // its last emitted outpoint. + has_more = true; + break; + } + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + // Without a cursor there is nowhere to resume, so a "more" claim + // would loop the caller forever. Cannot happen — a page only stops + // early after emitting a row — but the loop's termination should not + // rest on that reasoning alone. + if next_cursor.is_none() { + has_more = false; + } + let json = format!( + "{{\"utxos\":[{}],\"errors\":[{}],\"cursor\":{},\"hasMore\":{}}}", + rows.join(","), + errors.join(","), + next_cursor + .map(|c| json_escape(&c)) + .unwrap_or_else(|| "null".to_string()), + has_more, + ); + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Classify a batch of outpoints against the engine's live state — the +/// reverse half of the reconcile transport, and the reason +/// `walletManagerUtxosPageJson` no longer exports a spent-outpoint list. +/// The host pages its OWN mirror rows and asks about them a batch at a +/// time, so neither side builds a set over the whole engine inventory. +/// +/// `outpoints` is a flat `n * 36` byte blob in the store's own outpoint +/// encoding — 32-byte txid in wire order followed by the vout as +/// little-endian `u32`, which is exactly the `txos.outpoint` primary key, +/// so a caller concatenates the column and reads the answers back +/// positionally. Returns `n` bytes: 0 unknown, 1 unspent, 2 spent. +/// +/// A 2 means some recorded transaction spends the outpoint — possibly one +/// still in the mempool. It is not proof of a settled spend, and the +/// reconcile treats it as a signal to log, never to write. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerClassifyOutpoints( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + outpoints: JByteArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + let blob = match env.convert_byte_array(&outpoints) { + Ok(b) => b, + Err(_) => { + throw_sdk_exception(env, 1, "outpoints must be a byte[]"); + return ptr::null_mut(); + } + }; + if !blob.len().is_multiple_of(36) { + throw_sdk_exception( + env, + 1, + "outpoints must be a multiple of 36 bytes (txid || vout LE)", + ); + return ptr::null_mut(); + } + let requested: Vec = blob + .chunks_exact(36) + .map(|chunk| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(&chunk[..32]); + platform_wallet_ffi::OutPointFFI { + txid, + vout: u32::from_le_bytes([chunk[32], chunk[33], chunk[34], chunk[35]]), + } + }) + .collect(); + if requested.is_empty() { + return env + .byte_array_from_slice(&[]) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()); + } + let mut classes: *const u8 = ptr::null(); + let mut class_count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_classify_outpoints( + manager_handle as Handle, + wid.as_ptr(), + requested.as_ptr(), + requested.len(), + &mut classes, + &mut class_count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let verdicts: Vec = if classes.is_null() || class_count == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(classes, class_count) }.to_vec() + }; + unsafe { + platform_wallet_ffi::platform_wallet_classify_outpoints_free( + classes as *mut u8, + class_count, + ) + }; + env.byte_array_from_slice(&verdicts) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Extract-and-free a `PlatformWalletFFIResult`'s error message WITHOUT +/// throwing — the per-account soft-fail path of +/// [`Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson`] +/// reports account faults in-band so the sweep keeps going. `None` on +/// success. +fn pwffi_error_message( + mut result: platform_wallet_ffi::PlatformWalletFFIResult, +) -> Option { + if result.code == platform_wallet_ffi::PlatformWalletFFIResultCode::Success { + return None; + } + let message = if result.message.is_null() { + format!("platform-wallet error (code {})", result.code as i32) + } else { + // SAFETY: non-null message is a valid CString produced by the FFI. + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. + unsafe { platform_wallet_ffi::platform_wallet_ffi_result_free(&mut result) }; + Some(message) +} + +/// Lower-hex of a byte slice (txid bytes are emitted in the same order +/// the changeset path hands Kotlin, so hex→bytes on the Kotlin side +/// reproduces the exact `txos.txid` blob). +fn hex_lower(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Minimal JSON string escape (quotes, backslash, control chars) — the +/// values here are base58/bech32 addresses and FFI error strings. +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the /// wrong length or a JNI error. fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> { From 307150b18382335c70a2b4eec8a22cd5a0d77318 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 8 Sep 2026 12:16:08 -0700 Subject: [PATCH 2/5] chore: pin rust-dashcore to #979 merged onto the v4.2-dev pin (93260bf + 3fa881b2, fork) Temporary fork pin so the branch builds: HashEngineering/rust-dashcore integration/979-on-v42pin-93260bf = v4.2-dev's own pin 93260bf39b with dashpay/rust-dashcore#979 merged in. Re-pin to dashpay once #979 lands. --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfcf96c827e..2395fc0b1eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "dashcore-private", @@ -2495,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2556,7 +2556,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" [[package]] name = "glob" @@ -3630,7 +3630,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3881,7 +3881,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "async-trait", "bincode", @@ -5753,7 +5753,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5791,9 +5791,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6601,7 +6601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6614,7 +6614,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6673,7 +6673,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7534,7 +7534,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8985,7 +8985,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fc7a2a957c..966072a0912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-network-seeds = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-spv = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet-ffi = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet-manager = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-network = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dashcore-rpc = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which From ca3c7395800e58e143f55d778c1dba2e97a5d4c6 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 8 Sep 2026 12:16:40 -0700 Subject: [PATCH 3/5] refactor(platform-wallet, ffi): wallet-wide paged UTXO inventory; drop the dead spent-outpoint surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (dashpay/platform#4439): the composite the JNI used to stitch — enumerate accounts, order them, page each — now lives once in platform-wallet (wallet_utxos_page_blocking: (AccountType, OutPoint) order under one read lock, cursor = the last row) and is exported as platform_wallet_wallet_utxos_page with the owning account tuple on every row (WalletUtxoEntryFFI). The per-account page export that only the stitcher used, and the never-consumed account_spent_outpoints accessor + FFI pair, are deleted. The address-pool repair skip is one if/else if, its log literal is a single line again, and it logs at debug (DashPay contact pools land there by design on every load). --- .../src/core_wallet_types.rs | 26 ++ .../src/manager_diagnostics.rs | 180 +++++------ .../rs-platform-wallet-ffi/src/persistence.rs | 78 ++--- .../src/manager/accessors.rs | 299 +++++++++++------- 4 files changed, 335 insertions(+), 248 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 7fff75fae98..b2355e0477a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -893,6 +893,32 @@ pub struct AccountUtxoEntryFFI { pub is_locked: bool, } +/// One row of a WALLET-wide UTXO inventory page +/// (`platform_wallet_wallet_utxos_page`): the coin plus the complete account +/// tuple that owns it — the same tag layout as [`AccountBalanceEntryFFI`] / +/// `AccountSpecFFI`, so a host can resolve the owning account row and stamp +/// ownership on a healed mirror row without an address projection. The last +/// row's tuple + outpoint is also the resume cursor for the next page. +/// `script_pubkey` is heap-owned and freed by +/// `platform_wallet_wallet_utxos_free`. +#[repr(C)] +pub struct WalletUtxoEntryFFI { + pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, + pub standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub outpoint_txid: [u8; 32], + pub outpoint_vout: u32, + pub value_duffs: u64, + pub script_pubkey: *mut u8, + pub script_pubkey_len: usize, + pub height: u32, + pub is_locked: bool, +} + /// One transaction row in the per-account paginated drill-down. #[repr(C)] #[derive(Debug, Clone, Copy)] diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 8b81df4250b..ea72efe392b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -21,16 +21,16 @@ use platform_wallet::manager::accessors::{ AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, WalletUtxoCursor, WalletUtxoRow, }; use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, - PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, - TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, PlatformAddressProviderStateFFI, + PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + WalletUtxoEntryFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -614,33 +614,38 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); } -/// One outpoint-ordered page of an account's UTXO inventory — the bounded +/// One page of the wallet's UTXO inventory across EVERY funds account, in +/// `(account, outpoint)` order under one read lock — the bounded, host-neutral /// form of `platform_wallet_account_utxos`. /// /// A wallet's UTXO count is chain-controlled (anyone who knows a watched -/// address can keep sending dust to it), so a periodic host-side audit -/// must never materialize the whole inventory at once. `after_txid` + -/// `after_vout` name the last outpoint of the previous page; pass a NULL -/// `after_txid` to start at the beginning. `limit` caps the rows returned -/// (0 means "no limit" — a paging caller should always pass a real cap), -/// and `out_has_more` reports whether further pages remain. +/// address can keep sending dust to it), so a periodic host-side audit must +/// never materialize the whole inventory at once. The page is wallet-wide so +/// the host neither enumerates accounts nor stitches per-account cursors: +/// the ordering invariant lives in `platform-wallet` once +/// (`wallet_utxos_page_blocking`) and every host walks it the same way. /// -/// Rows are freed with `platform_wallet_account_utxos_free`, the same -/// entry type and the same deallocator as the unpaged call. +/// Resume with the LAST ROW of the previous page: `after_spec` (its account +/// tuple, xpub ignored) + `after_txid`/`after_vout` (its outpoint). Pass a +/// NULL `after_spec` to start at the beginning. `limit` caps the rows in one +/// page (0 means "no limit" — a paging caller should always pass a real cap); +/// `out_has_more` reports whether further pages remain. An unknown wallet is +/// an empty terminal page. +/// +/// Rows are freed with `platform_wallet_wallet_utxos_free`. #[no_mangle] -pub unsafe extern "C" fn platform_wallet_account_utxos_page( +pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( manager_handle: Handle, wallet_id: *const u8, - spec: *const AccountSpecFFI, + after_spec: *const AccountSpecFFI, after_txid: *const u8, after_vout: u32, limit: usize, - out_utxos: *mut *const AccountUtxoEntryFFI, + out_utxos: *mut *const WalletUtxoEntryFFI, out_count: *mut usize, out_has_more: *mut bool, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id); - check_ptr!(spec); check_ptr!(out_utxos); check_ptr!(out_count); check_ptr!(out_has_more); @@ -648,28 +653,32 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_page( *out_count = 0; *out_has_more = false; let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - let target = match account_type_from_spec_ref(&*spec) { - Ok(at) => at, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - e, - ); - } - }; - // A NULL cursor is "from the beginning" — the only way to say it, since - // the all-zero txid is a legal (if unreachable) outpoint. - let after = if after_txid.is_null() { + // A NULL spec is "from the beginning" — the only way to say it, since + // every tuple/outpoint pair is a legal cursor. + let after: Option = if after_spec.is_null() { None } else { + check_ptr!(after_txid); + let account_type = match account_type_from_spec_ref(&*after_spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; let raw: [u8; 32] = std::ptr::read(after_txid as *const [u8; 32]); - Some(dashcore::OutPoint::from(&OutPointFFI { - txid: raw, - vout: after_vout, - })) + Some(( + account_type, + dashcore::OutPoint::from(&OutPointFFI { + txid: raw, + vout: after_vout, + }), + )) }; let Some((rows, has_more)) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { - m.account_utxos_page_blocking(&wid, &target, after, limit) + m.wallet_utxos_page_blocking(&wid, after.as_ref(), limit) }) else { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidHandle, @@ -680,13 +689,55 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_page( if rows.is_empty() { return PlatformWalletFFIResult::ok(); } - let entries: Vec = rows.into_iter().map(utxo_entry_ffi).collect(); + let entries: Vec = rows.into_iter().map(wallet_utxo_entry_ffi).collect(); let count = entries.len(); *out_utxos = Box::into_raw(entries.into_boxed_slice()) as *const _; *out_count = count; PlatformWalletFFIResult::ok() } +fn wallet_utxo_entry_ffi(row: WalletUtxoRow) -> WalletUtxoEntryFFI { + let tags = crate::core_wallet_types::account_type_to_tags(&row.account_type); + let coin = utxo_entry_ffi(row.utxo); + WalletUtxoEntryFFI { + type_tag: tags.type_tag, + standard_tag: tags.standard_tag, + index: tags.index, + registration_index: tags.registration_index, + key_class: tags.key_class, + user_identity_id: tags.user_identity_id, + friend_identity_id: tags.friend_identity_id, + outpoint_txid: coin.outpoint_txid, + outpoint_vout: coin.outpoint_vout, + value_duffs: coin.value_duffs, + script_pubkey: coin.script_pubkey, + script_pubkey_len: coin.script_pubkey_len, + height: coin.height, + is_locked: coin.is_locked, + } +} + +/// Free a page returned by `platform_wallet_wallet_utxos_page`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_wallet_utxos_free( + utxos: *mut WalletUtxoEntryFFI, + count: usize, +) { + if utxos.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts(utxos, count); + for entry in slice { + if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.script_pubkey, + entry.script_pubkey_len, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); +} + /// Classify `count` outpoints against the wallet's live engine state, in /// one pass under one read lock. `out_classes` receives a `count`-byte /// buffer, positionally aligned with the input: 0 unknown, 1 unspent, 2 @@ -746,65 +797,6 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints_free(classes: *mut u let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(classes, count)); } -/// The account's spent-outpoint inventory — the second half of the -/// store-reconcile surface (`platform_wallet_account_utxos` is the unspent -/// half). A persistence-mirror row still marked unspent whose outpoint -/// appears here lost its spend update (dashpay/platform#4425); a row in -/// NEITHER inventory is swept/abandoned residue (pre-rust-dashcore#971 -/// stores). Free with `platform_wallet_account_spent_outpoints_free`. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_account_spent_outpoints( - manager_handle: Handle, - wallet_id: *const u8, - spec: *const AccountSpecFFI, - out_outpoints: *mut *const OutPointFFI, - out_count: *mut usize, -) -> PlatformWalletFFIResult { - check_ptr!(wallet_id); - check_ptr!(spec); - check_ptr!(out_outpoints); - check_ptr!(out_count); - *out_outpoints = std::ptr::null(); - *out_count = 0; - let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - let target = match account_type_from_spec_ref(&*spec) { - Ok(at) => at, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - e, - ); - } - }; - let Some(rows) = PLATFORM_WALLET_MANAGER_STORAGE - .with_item(manager_handle, |m| m.account_spent_outpoints_blocking(&wid, &target)) - else { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidHandle, - "Manager handle invalid".to_string(), - ); - }; - if rows.is_empty() { - return PlatformWalletFFIResult::ok(); - } - let entries: Vec = rows.iter().map(OutPointFFI::from).collect(); - let count = entries.len(); - *out_outpoints = Box::into_raw(entries.into_boxed_slice()) as *const _; - *out_count = count; - PlatformWalletFFIResult::ok() -} - -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_account_spent_outpoints_free( - outpoints: *mut OutPointFFI, - count: usize, -) { - if outpoints.is_null() || count == 0 { - return; - } - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(outpoints, count)); -} - // --------------------------------------------------------------------------- // Phase 6 — Per-account transactions // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 50bf7e69ec2..10eb01f048d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4431,21 +4431,13 @@ unsafe fn restore_core_address_pools( .ok() .map(|check_type| { let account_index = match &account_type { - AccountType::Standard { - index, .. + AccountType::Standard { index, .. } + | AccountType::CoinJoin { index } + | AccountType::DashpayReceivingFunds { index, .. } + | AccountType::DashpayExternalAccount { index, .. } => Some(*index), + AccountType::IdentityTopUp { registration_index } => { + Some(*registration_index) } - | AccountType::CoinJoin { - index, - } - | AccountType::DashpayReceivingFunds { - index, .. - } - | AccountType::DashpayExternalAccount { - index, .. - } => Some(*index), - AccountType::IdentityTopUp { - registration_index, - } => Some(*registration_index), _ => None, }; wallet.key_source_for_account_type(&check_type, account_index) @@ -4479,39 +4471,39 @@ unsafe fn restore_core_address_pools( // material, not an account xpub) — and their pools are // re-derived by DashPay contact sync at runtime, so a // sparse restore self-heals through that path instead. - // Hardened pools cannot be publicly derived at all. - tracing::info!( + // Hardened pools cannot be publicly derived at all. An + // expected condition on every contact pool of every load, + // so it is a debug line, not an info line per pool. + tracing::debug!( wallet_id = %hex::encode(wallet_id), ?account_type, ?pool_type, - "load: address-pool hole repair skipped (no public key source); pool restored as persisted" + "load: address-pool hole repair skipped (no public key source); \ + pool restored as persisted" ); - } - if repairable { - if let Some(max_idx) = pool.highest_generated { - match pool.ensure_contiguous_to(max_idx, &key_source) { - Ok(0) => {} - Ok(filled) => { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - ?account_type, - ?pool_type, - filled, - "load: repaired address-pool holes left by dropped \ - persisted rows; outputs paying these addresses are \ - recognizable again" - ); - } - Err(e) => { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - ?account_type, - ?pool_type, - error = %e, - "load: address-pool hole repair failed; pool restored \ - as persisted (sparse)" - ); - } + } else if let Some(max_idx) = pool.highest_generated { + match pool.ensure_contiguous_to(max_idx, &key_source) { + Ok(0) => {} + Ok(filled) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + filled, + "load: repaired address-pool holes left by dropped \ + persisted rows; outputs paying these addresses are \ + recognizable again" + ); + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + error = %e, + "load: address-pool hole repair failed; pool restored \ + as persisted (sparse)" + ); } } } diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 29e3cc796f1..e91e75c7749 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -228,6 +228,21 @@ pub struct AccountUtxoSnapshot { pub is_locked: bool, } +/// One row of a wallet-wide UTXO inventory page +/// ([`PlatformWalletManager::wallet_utxos_page_blocking`]): the coin and +/// the account that owns it. The account is the routing context a +/// persistence mirror needs to stamp ownership on a healed row. +#[derive(Debug, Clone)] +pub struct WalletUtxoRow { + pub account_type: AccountType, + pub utxo: AccountUtxoSnapshot, +} + +/// Where a wallet-wide inventory page left off: the account and outpoint of +/// the last row emitted. Hand the last row's `(account_type, +/// utxo.outpoint)` back to resume. +pub type WalletUtxoCursor = (AccountType, OutPoint); + /// Snapshot of one transaction row inside an account. #[derive(Debug, Clone, Copy)] pub struct AccountTransactionSnapshot { @@ -857,72 +872,108 @@ impl PlatformWalletManager

{ .collect() } - /// One outpoint-ordered page of an account's UTXO inventory — the - /// bounded form of [`Self::account_utxos_blocking`], for callers that - /// must not hold a whole wallet's inventory at once. + /// One page of the wallet's UTXO inventory across EVERY funds account, + /// under one read lock — the bounded form of + /// [`Self::account_utxos_blocking`] for callers that must never hold a + /// whole wallet's inventory at once. /// - /// A wallet's UTXO count is chain-controlled: anyone who knows a - /// watched address can keep sending dust to it, so the full-inventory - /// read has no upper bound a mobile process can rely on. Pages solve - /// that: `after` is the last outpoint of the previous page (`None` - /// starts at the beginning), `limit` caps the rows returned, and the - /// returned flag says whether more rows follow. Because the account's - /// UTXOs live in a `BTreeMap` keyed by outpoint, a page is a partial - /// select over the keys — no intermediate copy of the rows the caller - /// skipped. + /// A wallet's UTXO count is chain-controlled: anyone who knows a watched + /// address can keep sending dust to it, so a full-inventory read has no + /// upper bound a mobile process can rely on. Pages solve that, and a + /// WALLET-wide page (rather than one per account) means the host does + /// not enumerate accounts, order them, or stitch cursors itself — the + /// ordering invariant lives here once, and every host (JNI, Swift) gets + /// the same walk. /// - /// `limit == 0` means "no limit", matching - /// [`Self::account_transactions_blocking`]; a paging caller should - /// always pass a real cap. + /// Rows are ordered by `(AccountType, OutPoint)` — `AccountType`'s own + /// `Ord`, then the outpoint key of the account's UTXO map — which is + /// deterministic and stable for as long as the inventory does not + /// change. `after` is the last row of the previous page (`None` starts + /// at the beginning); `limit` caps the rows returned (`0` means "no + /// limit", a paging caller should always pass a real cap); the returned + /// flag says whether more rows follow. Because each account's UTXOs live + /// in a `BTreeMap`, a page is a partial select over the keys — no + /// intermediate copy of the rows the caller skipped. /// - /// The order is `OutPoint`'s own — deterministic, but neither - /// chronological nor the display order of a txid. Callers only need it - /// to be stable, which it is for as long as the account's UTXO set - /// does not change. A concurrent change between pages can drop a row - /// out of the sweep or repeat one; both are benign for the reconcile - /// this serves (insert-only, idempotent, and re-run on a cadence). - pub fn account_utxos_page_blocking( + /// A concurrently registered account, or a UTXO set that moves between + /// pages, can drop a row out of ONE sweep or repeat one; both are benign + /// for the store reconcile this serves (insert-only, idempotent, re-run + /// on a cadence). An unknown wallet is an empty terminal page. + pub fn wallet_utxos_page_blocking( &self, wallet_id: &WalletId, - target: &AccountType, - after: Option, + after: Option<&WalletUtxoCursor>, limit: usize, - ) -> (Vec, bool) { + ) -> (Vec, bool) { let wm = self.wallet_manager.blocking_read(); let Some(info) = wm.get_wallet_info(wallet_id) else { return (Vec::new(), false); }; + // Funds accounts only — keys-only accounts (identity / asset-lock / + // provider) never carry UTXOs by construction — in cursor order. let accounts = info.core_wallet.accounts.all_accounts(); - let Some(account) = accounts + let mut funds: Vec<( + AccountType, + &key_wallet::managed_account::ManagedCoreFundsAccount, + )> = accounts .iter() - .find(|a| &a.managed_account_type().to_account_type() == target) - else { - return (Vec::new(), false); - }; - // Keys-only accounts (identity / asset-lock / provider) never - // carry UTXOs by construction — an empty page, never a partial one. - let Some(funds) = account.as_funds() else { - return (Vec::new(), false); - }; - let cursor = match after { - Some(outpoint) => (Bound::Excluded(outpoint), Bound::Unbounded), - None => (Bound::Unbounded, Bound::Unbounded), - }; - let mut iter = funds.utxos.range(cursor); + .filter_map(|a| { + a.as_funds() + .map(|f| (a.managed_account_type().to_account_type(), f)) + }) + .collect(); + funds.sort_by(|a, b| a.0.cmp(&b.0)); + let take = if limit == 0 { usize::MAX } else { limit }; - let mut rows: Vec = Vec::new(); - for (_, utxo) in iter.by_ref().take(take) { - rows.push(AccountUtxoSnapshot { - outpoint: utxo.outpoint, - value_duffs: utxo.txout.value, - script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), - height: utxo.height, - is_locked: utxo.is_locked, - }); + let mut rows: Vec = Vec::new(); + let mut has_more = false; + for (position, (account_type, account)) in funds.iter().enumerate() { + // Resume: accounts before the cursor's are already swept, the + // cursor's own continues after its last outpoint, every later + // account starts from the beginning. + let start = match after { + Some((cursor_account, _)) if account_type < cursor_account => continue, + Some((cursor_account, cursor_outpoint)) if account_type == cursor_account => { + Bound::Excluded(*cursor_outpoint) + } + _ => Bound::Unbounded, + }; + let mut iter = account.utxos.range((start, Bound::Unbounded)); + let remaining = take - rows.len(); + if remaining == 0 { + // The page filled on an earlier account; one probe decides + // whether anything is left to page — a single tree step. + if iter.next().is_some() { + has_more = true; + break; + } + continue; + } + for (_, utxo) in iter.by_ref().take(remaining) { + rows.push(WalletUtxoRow { + account_type: *account_type, + utxo: AccountUtxoSnapshot { + outpoint: utxo.outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + height: utxo.height, + is_locked: utxo.is_locked, + }, + }); + } + if rows.len() == take { + // Full page: more follows if this account has another row, + // or any later account has any row at all. + if iter.next().is_some() + || funds[position + 1..] + .iter() + .any(|(_, a)| !a.utxos.is_empty()) + { + has_more = true; + } + break; + } } - // One probe past the page rather than an over-fetch-and-truncate: - // `range` is lazy, so this costs a single tree step. - let has_more = iter.next().is_some(); (rows, has_more) } @@ -979,35 +1030,6 @@ impl PlatformWalletManager

{ classes } - /// The outpoints this account knows were spent by recorded - /// transactions — the second half of the store-reconcile inventory - /// ([`Self::account_utxos_blocking`] is the unspent half). Lets a - /// persistence-mirror audit classify a store row marked unspent: - /// present here → the row lost its spend update (flip it, - /// dashpay/platform#4425); present in neither inventory → residue of a - /// swept/abandoned transaction (pre-rust-dashcore#971 stores). - pub fn account_spent_outpoints_blocking( - &self, - wallet_id: &WalletId, - target: &AccountType, - ) -> Vec { - let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { - return Vec::new(); - }; - let accounts = info.core_wallet.accounts.all_accounts(); - let Some(account) = accounts - .iter() - .find(|a| &a.managed_account_type().to_account_type() == target) - else { - return Vec::new(); - }; - let Some(funds) = account.as_funds() else { - return Vec::new(); - }; - funds.spent_outpoints().iter().copied().collect() - } - // ----------------------------------------------------------------- // Phase 6 — Per-account transactions // ----------------------------------------------------------------- @@ -1377,21 +1399,31 @@ mod utxo_inventory_transport_tests { } } - /// Put `count` UTXOs on the wallet's BIP44 account. The engine normally + /// Put UTXOs on the wallet's BIP44 account. The engine normally /// fills this map from block processing; a test only needs the map's /// contents, and the accessors read nothing else. async fn seed_utxos( manager: &Arc>, wallet_id: &WalletId, outpoints: &[OutPoint], + ) { + seed_account_utxos(manager, wallet_id, bip44(), outpoints).await + } + + /// Put UTXOs on one specific funds account of the wallet. + async fn seed_account_utxos( + manager: &Arc>, + wallet_id: &WalletId, + target: AccountType, + outpoints: &[OutPoint], ) { let mut wm = manager.wallet_manager.write().await; let info = wm.get_wallet_info_mut(wallet_id).expect("known wallet"); let mut accounts = info.core_wallet.accounts.all_accounts_mut(); let account = accounts .iter_mut() - .find(|a| a.managed_account_type().to_account_type() == bip44()) - .expect("BIP44 account"); + .find(|a| a.managed_account_type().to_account_type() == target) + .expect("target funds account"); // `Utxo` carries an address; the accessors never read it, so any // address the account already derived will do. let address = account @@ -1430,11 +1462,34 @@ mod utxo_inventory_transport_tests { } } + /// Every funds account of the test wallet other than BIP44 #0, in the + /// order the page walks them. + async fn other_funds_accounts( + manager: &Arc>, + wallet_id: &WalletId, + ) -> Vec { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("known wallet"); + let mut types: Vec = info + .core_wallet + .accounts + .all_accounts() + .iter() + .filter(|a| a.as_funds().is_some()) + .map(|a| a.managed_account_type().to_account_type()) + .filter(|t| *t != bip44()) + .collect(); + types.sort(); + types + } + #[tokio::test] - async fn utxo_pages_cover_the_account_exactly_once_and_stop() { + async fn utxo_pages_cover_the_wallet_exactly_once_and_stop() { let (manager, wallet_id) = test_platform_wallet_manager().await; - // Five outpoints across two txids, so the page boundary lands inside - // a txid as well as between them. + // Five outpoints across two txids on BIP44 #0, so a page boundary + // lands inside a txid as well as between them — plus two more on a + // second funds account, so the walk has to cross an account + // boundary and order the accounts itself. let seeded: Vec = vec![ outpoint(1, 0), outpoint(1, 1), @@ -1443,55 +1498,77 @@ mod utxo_inventory_transport_tests { outpoint(2, 1), ]; seed_utxos(&manager, &wallet_id, &seeded).await; + let others = other_funds_accounts(&manager, &wallet_id).await; + let second = *others + .first() + .expect("the test wallet has a second funds account"); + let second_seeded = vec![outpoint(0, 5), outpoint(0, 6)]; + seed_account_utxos(&manager, &wallet_id, second, &second_seeded).await; + let total = seeded.len() + second_seeded.len(); tokio::task::spawn_blocking(move || { - let target = bip44(); - let mut seen: Vec = Vec::new(); - let mut after: Option = None; + let mut seen: Vec<(AccountType, OutPoint)> = Vec::new(); + let mut after: Option<(AccountType, OutPoint)> = None; let mut pages = 0; loop { let (rows, has_more) = - manager.account_utxos_page_blocking(&wallet_id, &target, after, 2); + manager.wallet_utxos_page_blocking(&wallet_id, after.as_ref(), 2); pages += 1; assert!(rows.len() <= 2, "a page must never exceed its limit"); if let Some(last) = rows.last() { - after = Some(last.outpoint); + after = Some((last.account_type, last.utxo.outpoint)); } - seen.extend(rows.iter().map(|r| r.outpoint)); + seen.extend(rows.iter().map(|r| (r.account_type, r.utxo.outpoint))); if !has_more { break; } assert!(pages < 10, "paging must terminate"); } - assert_eq!(3, pages, "5 rows at 2 per page"); - assert_eq!(5, seen.len(), "every UTXO is delivered"); + assert_eq!(4, pages, "7 rows at 2 per page"); + assert_eq!( + total, + seen.len(), + "every UTXO of every account is delivered" + ); let mut unique = seen.clone(); unique.sort(); unique.dedup(); - assert_eq!(5, unique.len(), "and none of them twice"); + assert_eq!(total, unique.len(), "and none of them twice"); let mut sorted = seen.clone(); sorted.sort(); - assert_eq!(sorted, seen, "pages walk the outpoint order"); + assert_eq!(sorted, seen, "pages walk the (account, outpoint) order"); + // The lower-sorting account comes out first in its entirety: the + // outpoints seeded on `second` (txid byte 0) sort below BIP44's + // by outpoint, so account order, not outpoint order, must win. + let (first_account, _) = seen[0]; + let first_run = seen.iter().take_while(|(a, _)| *a == first_account).count(); + let expected_first = if first_account == bip44() { + seeded.len() + } else { + second_seeded.len() + }; + assert_eq!( + expected_first, first_run, + "an account is exhausted before the next starts" + ); - // The unpaged accessor is the same inventory — the page cursor - // is a transport detail, not a different view. - let whole = manager.account_utxos_blocking(&wallet_id, &target); - assert_eq!(whole.len(), seen.len()); + // The unpaged per-account accessor is the same inventory — the + // page cursor is a transport detail, not a different view. + let whole = manager.account_utxos_blocking(&wallet_id, &bip44()); + assert_eq!(seeded.len(), whole.len()); // An exhausted cursor is an empty terminal page, not a loop. - let (rows, has_more) = - manager.account_utxos_page_blocking(&wallet_id, &target, seen.last().copied(), 2); + let (rows, has_more) = manager.wallet_utxos_page_blocking(&wallet_id, seen.last(), 2); assert!(rows.is_empty()); assert!(!has_more); - // A keys-only account has no UTXOs, and says so without - // claiming another page. - let (rows, has_more) = manager.account_utxos_page_blocking( - &wallet_id, - &AccountType::IdentityRegistration, - None, - 2, - ); + // A page that fills exactly on the last row reports no more. + let (rows, has_more) = manager.wallet_utxos_page_blocking(&wallet_id, None, total); + assert_eq!(total, rows.len()); + assert!(!has_more); + + // An unknown wallet is an empty terminal page. + let (rows, has_more) = manager.wallet_utxos_page_blocking(&[0xFF; 32], None, 2); assert!(rows.is_empty()); assert!(!has_more); }) From 15e56599d0f5b8768b1709eb953d2e0996b88e7e Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 8 Sep 2026 12:16:40 -0700 Subject: [PATCH 4/5] refactor(unified-sdk-jni): serialize the inventory page over the wallet-wide FFI with serde walletManagerUtxosPageJson is a thin shim over one Rust call again (no account enumeration, no per-page balance computation, no hand-rolled cursor/JSON/hex): serde structs UtxoPage / UtxoPageRow / UtxoPageCursor carry the same field names the Kotlin EngineUtxoPage decodes, so a renamed key fails the Kotlin decode instead of healing a defaulted row. A cursor this export did not produce is rejected with an SDK exception rather than silently restarting the sweep. The errors array is gone with the per-account reads it reported. --- Cargo.lock | 3 + packages/rs-unified-sdk-jni/Cargo.toml | 3 + .../rs-unified-sdk-jni/src/wallet_manager.rs | 509 ++++++++---------- 3 files changed, 221 insertions(+), 294 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2395fc0b1eb..cee3304a021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6489,11 +6489,14 @@ dependencies = [ "dash-network", "dashcore", "dashpay-contract", + "hex", "jni 0.21.1", "key-wallet-ffi", "log", "platform-wallet-ffi", "rs-sdk-ffi", + "serde", + "serde_json", "zeroize", ] diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index d07eabc98cb..cf930cbf03b 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -22,6 +22,9 @@ dash-network = { workspace = true, features = ["ffi"] } # platform-wallet-ffi, so this adds no new build cost. dashcore = { workspace = true } log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +hex = "0.4" zeroize = "1" [target.'cfg(target_os = "android")'.dependencies] diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 954ec8bf30c..0d566114c3b 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3209,73 +3209,101 @@ fn core_selection_strategy( const UTXO_PAGE_DEFAULT: usize = 512; const UTXO_PAGE_MAX: usize = 4096; -/// The account tuple, packed into one comparable key. Accounts are swept -/// in the order of this key rather than in the order -/// `get_account_balances` happens to return them: the sweep is resumable -/// across calls, so it needs an order that a concurrently registered or -/// removed account cannot shift underneath it. A new account sorting -/// before the cursor is missed by THIS sweep and picked up by the next; -/// one sorting after it is included. Neither can make the sweep skip or -/// repeat data it has already paged — which an ordinal cursor would. -fn account_sort_key(acc: &platform_wallet_ffi::AccountBalanceEntryFFI) -> [u8; 78] { - let mut key = [0u8; 78]; - key[0] = acc.type_tag as u8; - key[1] = acc.standard_tag as u8; - key[2..6].copy_from_slice(&acc.index.to_be_bytes()); - key[6..10].copy_from_slice(&acc.registration_index.to_be_bytes()); - key[10..14].copy_from_slice(&acc.key_class.to_be_bytes()); - key[14..46].copy_from_slice(&acc.user_identity_id); - key[46..78].copy_from_slice(&acc.friend_identity_id); - key +/// The account tuple that owns one inventory row — `AccountSpecFFI` minus +/// the xpub, in the field names the Kotlin `EngineUtxoRow` / +/// `PlatformWalletPersistenceHandler.fetchAccount` resolve a Room account +/// by. The DashPay identity halves are emitted only when set (all-zero on +/// every non-DashPay account); the Kotlin side defaults them. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UtxoAccountTuple { + type_tag: u8, + standard_tag: u8, + index: u32, + registration_index: u32, + key_class: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_identity_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + friend_identity_id: Option, } -/// Where a paged inventory sweep left off: the account it was inside and -/// the last outpoint it emitted from that account. -struct UtxoPageCursor { - account_key: [u8; 78], - txid: [u8; 32], +impl UtxoAccountTuple { + fn from_entry(e: &platform_wallet_ffi::WalletUtxoEntryFFI) -> Self { + let identity = |id: &[u8; 32]| (*id != [0u8; 32]).then(|| hex::encode(id)); + UtxoAccountTuple { + type_tag: e.type_tag as u8, + standard_tag: e.standard_tag as u8, + index: e.index, + registration_index: e.registration_index, + key_class: e.key_class, + user_identity_id: identity(&e.user_identity_id), + friend_identity_id: identity(&e.friend_identity_id), + } + } + + /// Back to the FFI spec for the resume cursor. `None` when an identity + /// hex is malformed — a cursor the host did not get from us. + fn to_spec(&self) -> Option { + fn id32(hex_id: &Option) -> Option<[u8; 32]> { + match hex_id { + None => Some([0u8; 32]), + Some(h) => hex::decode(h).ok()?.try_into().ok(), + } + } + Some(platform_wallet_ffi::AccountSpecFFI { + type_tag: self.type_tag, + standard_tag: self.standard_tag, + index: self.index, + registration_index: self.registration_index, + key_class: self.key_class, + user_identity_id: id32(&self.user_identity_id)?, + friend_identity_id: id32(&self.friend_identity_id)?, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }) + } +} + +/// One row of an inventory page — the typed contract the Kotlin +/// `EngineUtxoRow` decodes. `txid` is lower hex in the same byte order the +/// changeset path hands Kotlin, so hex→bytes reproduces the `txos.txid` +/// blob; `address` is empty when the script has no address form. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPageRow { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, vout: u32, + amount: u64, + address: String, + script_hex: String, + height: u32, + is_locked: bool, } -/// Parse `::`. The cursor is opaque to the -/// host — it only ever hands back what a previous page returned — so an -/// unparseable one restarts the sweep rather than failing it. -fn parse_utxo_page_cursor(raw: &str) -> Option { - let mut parts = raw.split(':'); - let key_hex = parts.next()?; - let txid_hex = parts.next()?; - let vout: u32 = parts.next()?.parse().ok()?; - if parts.next().is_some() { - return None; - } - let key_bytes = hex_bytes(key_hex)?; - let txid_bytes = hex_bytes(txid_hex)?; - let mut cursor = UtxoPageCursor { - account_key: [0u8; 78], - txid: [0u8; 32], - vout, - }; - if key_bytes.len() != cursor.account_key.len() || txid_bytes.len() != cursor.txid.len() { - return None; - } - cursor.account_key.copy_from_slice(&key_bytes); - cursor.txid.copy_from_slice(&txid_bytes); - Some(cursor) +/// One page: the rows, the opaque resume cursor (absent on the last page) +/// and whether more pages follow. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPage { + utxos: Vec, + cursor: Option, + has_more: bool, } -/// Lower-hex → bytes; `None` on odd length or a non-hex digit. -fn hex_bytes(hex: &str) -> Option> { - if !hex.len().is_multiple_of(2) { - return None; - } - let raw = hex.as_bytes(); - let mut out = Vec::with_capacity(raw.len() / 2); - for pair in raw.chunks(2) { - let hi = (pair[0] as char).to_digit(16)?; - let lo = (pair[1] as char).to_digit(16)?; - out.push(((hi << 4) | lo) as u8); - } - Some(out) +/// Where a paged inventory sweep left off — the last row's account tuple +/// and outpoint, exactly what `platform_wallet_wallet_utxos_page` resumes +/// from. Serialized as JSON and handed to the host as an opaque string; the +/// host only ever gives back what a previous page returned. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPageCursor { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, + vout: u32, } /// One bounded page of the engine's UTXO inventory across every account of @@ -3288,23 +3316,27 @@ fn hex_bytes(hex: &str) -> Option> { /// chain-controlled: anyone who knows a watched address can keep sending /// dust outputs to it, and a periodic full-inventory read would let them /// decide how much a phone allocates at every SYNCED transition and every -/// 30-minute pass. Here nothing bigger than one page is ever formatted, -/// copied across JNI, or parsed. +/// 30-minute pass. Nothing bigger than one page is ever formatted, copied +/// across JNI, or parsed. /// -/// Returns a JSON object -/// `{"utxos":[...],"errors":[...],"cursor":,"hasMore":}`. -/// Each `utxos` row is one output the engine currently holds, tagged with -/// its owning account. `cursor` is opaque: hand it back verbatim on the -/// next call (`null`/absent starts from the beginning) and keep going while -/// `hasMore` is true. `limit` caps the rows in one page — non-positive -/// means the default, and anything larger than the cap is clamped. +/// This export is a thin shim over ONE Rust call +/// (`platform_wallet_wallet_utxos_page`): the account ordering, the cursor +/// semantics and the page bound all live in `platform-wallet`, so the Swift +/// host walks the identical inventory. Here the rows are only serialized — +/// with serde, against the same field names the Kotlin `EngineUtxoPage` +/// deserializes, so a renamed key fails the Kotlin decode loudly instead of +/// healing a defaulted row. +/// +/// Returns a JSON object `{"utxos":[...],"cursor":,"hasMore":}`. +/// `cursor` is opaque: hand it back verbatim on the next call (`null`/absent +/// starts from the beginning) and keep going while `hasMore` is true. A +/// cursor this export did not produce is rejected with an SDK exception +/// rather than silently restarting the sweep. `limit` caps the rows in one +/// page — non-positive means the default, and anything larger than the cap +/// is clamped. /// /// `network` follows `Network.ffiValue` (0 mainnet, 2 devnet, 3 regtest, -/// else testnet) and selects the address encoding; an output whose script -/// has no address form carries an empty `address` for the caller to skip. -/// A per-account read failure lands in `errors` instead of failing the -/// page — the reconciler must still see every account that DID read, so -/// one faulted account cannot mask the others' repair. +/// else testnet) and selects the address encoding. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson( mut env: JNIEnv, @@ -3330,192 +3362,136 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w } else { (limit as usize).min(UTXO_PAGE_MAX) }; - let resume = if cursor.is_null() { + // Resume point, decoded from the opaque cursor. Kept alive across + // the FFI call — the spec and txid are passed by pointer. + let resume: Option<(platform_wallet_ffi::AccountSpecFFI, [u8; 32], u32)> = if cursor + .is_null() + { None } else { - match env.get_string(&cursor) { - Ok(s) => parse_utxo_page_cursor(&String::from(s)), - Err(_) => None, + let raw = match env.get_string(&cursor) { + Ok(s) => String::from(s), + Err(_) => { + throw_sdk_exception(env, 1, "inventory cursor must be a String"); + return ptr::null_mut(); + } + }; + let parsed = serde_json::from_str::(&raw) + .ok() + .and_then(|c| { + let spec = c.account.to_spec()?; + let txid: [u8; 32] = hex::decode(&c.txid).ok()?.try_into().ok()?; + Some((spec, txid, c.vout)) + }); + match parsed { + Some(r) => Some(r), + None => { + throw_sdk_exception( + env, + 1, + "malformed inventory cursor: only a cursor returned by a previous page may be handed back", + ); + return ptr::null_mut(); + } } }; - let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut entries: *const platform_wallet_ffi::WalletUtxoEntryFFI = ptr::null(); let mut count: usize = 0; + let mut has_more = false; let result = unsafe { - platform_wallet_ffi::platform_wallet_manager_get_account_balances( + platform_wallet_ffi::platform_wallet_wallet_utxos_page( manager_handle as Handle, wid.as_ptr(), + resume + .as_ref() + .map_or(ptr::null(), |(spec, _, _)| spec as *const _), + resume + .as_ref() + .map_or(ptr::null(), |(_, txid, _)| txid.as_ptr()), + resume.as_ref().map_or(0, |(_, _, vout)| *vout), + page_limit, &mut entries, &mut count, + &mut has_more, ) }; if take_pwffi_error(env, result) { return ptr::null_mut(); } - let mut rows: Vec = Vec::new(); - let mut errors: Vec = Vec::new(); - let mut next_cursor: Option = None; - let mut has_more = false; + let mut rows: Vec = Vec::with_capacity(count); if !entries.is_null() && count > 0 { - let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; - let keys: Vec<[u8; 78]> = accounts.iter().map(account_sort_key).collect(); - let mut order: Vec = (0..accounts.len()).collect(); - order.sort_by(|a, b| keys[*a].cmp(&keys[*b])); - let mut remaining = page_limit; - for &i in &order { - let acc = &accounts[i]; - let key = keys[i]; - // Resume: accounts before the cursor's are already swept, - // the cursor's own continues after its last outpoint, and - // every later account starts from the beginning. - let after = match &resume { - Some(c) if key < c.account_key => continue, - Some(c) if key == c.account_key => Some((c.txid, c.vout)), - _ => None, - }; - if remaining == 0 { - // The page filled on an earlier account and this one is - // still unswept — resume from the cursor already set. - has_more = true; - break; - } - let spec = platform_wallet_ffi::AccountSpecFFI { - type_tag: acc.type_tag as u8, - standard_tag: acc.standard_tag as u8, - index: acc.index, - registration_index: acc.registration_index, - key_class: acc.key_class, - user_identity_id: acc.user_identity_id, - friend_identity_id: acc.friend_identity_id, - account_xpub_bytes: ptr::null(), - account_xpub_bytes_len: 0, - }; - let mut utxos: *const platform_wallet_ffi::AccountUtxoEntryFFI = ptr::null(); - let mut utxo_count: usize = 0; - let mut account_has_more = false; - // The cursor txid has to outlive the call — a pointer taken - // from a temporary inside the argument list would dangle. - let after_txid: Option<[u8; 32]> = after.map(|(txid, _)| txid); - let res = unsafe { - platform_wallet_ffi::platform_wallet_account_utxos_page( - manager_handle as Handle, - wid.as_ptr(), - &spec, - after_txid.as_ref().map_or(ptr::null(), |t| t.as_ptr()), - after.map_or(0, |(_, vout)| vout), - remaining, - &mut utxos, - &mut utxo_count, - &mut account_has_more, - ) + let items = unsafe { std::slice::from_raw_parts(entries, count) }; + for e in items { + let script: &[u8] = if e.script_pubkey.is_null() || e.script_pubkey_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(e.script_pubkey, e.script_pubkey_len) } }; - if let Some(msg) = pwffi_error_message(res) { - errors.push(format!( - "{{\"typeTag\":{},\"index\":{},\"message\":{}}}", - acc.type_tag as u8, - acc.index, - json_escape(&msg), - )); - continue; - } - if !utxos.is_null() && utxo_count > 0 { - let items = unsafe { std::slice::from_raw_parts(utxos, utxo_count) }; - for u in items { - let script: &[u8] = if u.script_pubkey.is_null() || u.script_pubkey_len == 0 - { - &[] - } else { - unsafe { - std::slice::from_raw_parts(u.script_pubkey, u.script_pubkey_len) - } - }; - let script_buf = dashcore::ScriptBuf::from(script.to_vec()); - let address = dashcore::Address::from_script(&script_buf, net) - .map(|a| a.to_string()) - .unwrap_or_default(); - // The DashPay identity halves of the account tuple are - // emitted only when set (all-zero on every non-DashPay - // account) — the reconcile needs the COMPLETE tuple to - // resolve the owning Room account and stamp it on healed - // rows, so ownership survives even when the address - // projection is absent. - let mut identity_suffix = String::new(); - if acc.user_identity_id != [0u8; 32] || acc.friend_identity_id != [0u8; 32] - { - identity_suffix = format!( - ",\"userIdentityId\":\"{}\",\"friendIdentityId\":\"{}\"", - hex_lower(&acc.user_identity_id), - hex_lower(&acc.friend_identity_id), - ); - } - rows.push(format!( - "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ - \"registrationIndex\":{},\"keyClass\":{},\ - \"txid\":\"{}\",\"vout\":{},\"amount\":{},\ - \"address\":{},\"scriptHex\":\"{}\",\ - \"height\":{},\"isLocked\":{}{}}}", - acc.type_tag as u8, - acc.standard_tag as u8, - acc.index, - acc.registration_index, - acc.key_class, - hex_lower(&u.outpoint_txid), - u.outpoint_vout, - u.value_duffs, - json_escape(&address), - hex_lower(script), - u.height, - u.is_locked, - identity_suffix, - )); - next_cursor = Some(format!( - "{}:{}:{}", - hex_lower(&key), - hex_lower(&u.outpoint_txid), - u.outpoint_vout, - )); - } - remaining -= utxo_count.min(remaining); - unsafe { - platform_wallet_ffi::platform_wallet_account_utxos_free( - utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, - utxo_count, - ) - }; - } - if account_has_more { - // Stopped inside this account: the cursor already names - // its last emitted outpoint. - has_more = true; - break; - } + let script_buf = dashcore::ScriptBuf::from(script.to_vec()); + let address = dashcore::Address::from_script(&script_buf, net) + .map(|a| a.to_string()) + .unwrap_or_default(); + rows.push(UtxoPageRow { + account: UtxoAccountTuple::from_entry(e), + txid: hex::encode(e.outpoint_txid), + vout: e.outpoint_vout, + amount: e.value_duffs, + address, + script_hex: hex::encode(script), + height: e.height, + is_locked: e.is_locked, + }); } + unsafe { + platform_wallet_ffi::platform_wallet_wallet_utxos_free( + entries as *mut platform_wallet_ffi::WalletUtxoEntryFFI, + count, + ) + }; } - unsafe { - platform_wallet_ffi::platform_wallet_manager_free_account_balances( - entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, - count, - ) + // The cursor is the last row itself; a page with no rows has nowhere + // to resume from and the accessor reports no more in that case. + let next_cursor = if has_more { + rows.last().map(|last| UtxoPageCursor { + account: UtxoAccountTuple { + type_tag: last.account.type_tag, + standard_tag: last.account.standard_tag, + index: last.account.index, + registration_index: last.account.registration_index, + key_class: last.account.key_class, + user_identity_id: last.account.user_identity_id.clone(), + friend_identity_id: last.account.friend_identity_id.clone(), + }, + txid: last.txid.clone(), + vout: last.vout, + }) + } else { + None }; - // Without a cursor there is nowhere to resume, so a "more" claim - // would loop the caller forever. Cannot happen — a page only stops - // early after emitting a row — but the loop's termination should not - // rest on that reasoning alone. - if next_cursor.is_none() { - has_more = false; - } - let json = format!( - "{{\"utxos\":[{}],\"errors\":[{}],\"cursor\":{},\"hasMore\":{}}}", - rows.join(","), - errors.join(","), - next_cursor - .map(|c| json_escape(&c)) - .unwrap_or_else(|| "null".to_string()), - has_more, - ); - env.new_string(json) - .map(|s| s.into_raw()) - .unwrap_or(ptr::null_mut()) + let cursor_json = match next_cursor.as_ref().map(serde_json::to_string) { + Some(Ok(c)) => Some(c), + Some(Err(e)) => { + throw_sdk_exception(env, 1, &format!("inventory cursor encode failed: {e}")); + return ptr::null_mut(); + } + None => None, + }; + let page = UtxoPage { + utxos: rows, + cursor: cursor_json, + has_more: has_more && next_cursor.is_some(), + }; + match serde_json::to_string(&page) { + Ok(json) => env + .new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + Err(e) => { + throw_sdk_exception(env, 1, &format!("inventory page encode failed: {e}")); + ptr::null_mut() + } + } }) } @@ -3610,61 +3586,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } -/// Extract-and-free a `PlatformWalletFFIResult`'s error message WITHOUT -/// throwing — the per-account soft-fail path of -/// [`Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson`] -/// reports account faults in-band so the sweep keeps going. `None` on -/// success. -fn pwffi_error_message( - mut result: platform_wallet_ffi::PlatformWalletFFIResult, -) -> Option { - if result.code == platform_wallet_ffi::PlatformWalletFFIResultCode::Success { - return None; - } - let message = if result.message.is_null() { - format!("platform-wallet error (code {})", result.code as i32) - } else { - // SAFETY: non-null message is a valid CString produced by the FFI. - unsafe { std::ffi::CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned() - }; - // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. - unsafe { platform_wallet_ffi::platform_wallet_ffi_result_free(&mut result) }; - Some(message) -} - -/// Lower-hex of a byte slice (txid bytes are emitted in the same order -/// the changeset path hands Kotlin, so hex→bytes on the Kotlin side -/// reproduces the exact `txos.txid` blob). -fn hex_lower(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{:02x}", b)); - } - s -} - -/// Minimal JSON string escape (quotes, backslash, control chars) — the -/// values here are base58/bech32 addresses and FFI error strings. -fn json_escape(value: &str) -> String { - let mut out = String::with_capacity(value.len() + 2); - out.push('"'); - for c in value.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} - /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the /// wrong length or a JNI error. fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> { From c5cbc448aed89f3aef0032458663dad2f29747c8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 8 Sep 2026 12:16:40 -0700 Subject: [PATCH 5/5] refactor(kotlin-sdk): typed inventory contract, split reconcile passes, real null-transport handling - EngineUtxoPage / EngineUtxoRow (@Serializable) decoded with the strict default Json: an unknown key or missing field throws (test: a malformed row is rejected, nothing healed). - reconcileTxos = resolve the foreign account set, healMissingTxos, classifyStoreRows, merge, log. Each pass returns its own half (HealPass / ClassifyPass); per-row insert logic is healEngineRow. It returns null itself when the first page is unavailable, so PlatformWalletManager.reconcileTxoStore passes the page lambda straight through (no prefetch closure keyed on cursor == null). - Both transport lambdas wrap mapNativeErrors in runCatching { }.getOrNull() and log the fault, so a mid-sweep native failure yields the partial report the truncate-and-report design promises instead of an exception that discards it. - tipHeight falls back to the header tip when the filter sub-phase is absent, and a skipped sweep is logged instead of silently returning. - The dead TransactionDao.addToNetAmount and the accountErrors counter are removed. --- .../dashsdk/ffi/WalletManagerNative.kt | 24 +- .../PlatformWalletPersistenceHandler.kt | 613 +++++++++++------- .../dashsdk/persistence/dao/TransactionDao.kt | 10 - .../dashsdk/wallet/PlatformWalletManager.kt | 79 ++- .../PlatformWalletPersistenceHandlerTest.kt | 86 ++- 5 files changed, 506 insertions(+), 306 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 97aa20a9f21..421a5be66a6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -148,27 +148,29 @@ internal object WalletManagerNative { /** * One bounded page of the engine's UTXO inventory for one wallet, * across every account, as JSON - * `{"utxos":[...],"errors":[...],"cursor":,"hasMore":}` - * — the source of truth the TXO-store reconciler + * `{"utxos":[...],"cursor":,"hasMore":}` — the + * source of truth the TXO-store reconciler * ([PlatformWalletManager.reconcileTxoStore]) diffs against the Room - * `txos` mirror. + * `txos` mirror. A thin shim over one Rust call + * (`platform_wallet_wallet_utxos_page`): account ordering, cursor + * semantics and the page bound live in `platform-wallet`. * * Paged, not swept whole: a wallet's UTXO count is chain-controlled * (anyone who knows a watched address can keep sending dust to it), so * a full-inventory read would let a remote party decide how much this * process allocates on every SYNCED transition and every 30-minute * pass. Pass [cursor] `null` to start, then hand back the returned - * `cursor` verbatim while `hasMore` is true. [limit] caps the rows in - * one page; non-positive means the native default, and oversized - * values are clamped natively. + * `cursor` verbatim while `hasMore` is true — a cursor this export did + * not produce throws. [limit] caps the rows in one page; non-positive + * means the native default, and oversized values are clamped natively. * - * Each `utxos` row carries the owning account tags, the txid hex in - * the same byte order the changeset path hands - * [PlatformWalletPersistenceHandler] (so hex→bytes reproduces the + * Each `utxos` row is a + * [org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler.EngineUtxoRow]: + * the owning account tuple, the txid hex in the same byte order the + * changeset path hands the handler (so hex→bytes reproduces the * `txos.txid` blob), vout, amount (duffs), derived address (empty when * the script has no address form), scriptHex, height and isLocked. - * Per-account read failures land in `errors` instead of failing the - * page. `network` is [org.dashfoundation.dashsdk.Network.ffiValue]. + * `network` is [org.dashfoundation.dashsdk.Network.ffiValue]. */ external fun walletManagerUtxosPageJson( managerHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 6796d5e318b..7d1e8a44338 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import kotlinx.serialization.json.boolean import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.contentOrNull @@ -1187,6 +1189,46 @@ class PlatformWalletPersistenceHandler( } } + /** + * One row of the engine's paged UTXO inventory, exactly as + * `walletManagerUtxosPageJson` serializes it (rs-unified-sdk-jni + * `UtxoPageRow`, serde on that side, kotlinx on this one). The contract + * is typed on BOTH ends so a renamed or missing key fails the decode + * loudly instead of healing a row with a defaulted owner or a zero + * amount into the mirror the engine reloads from. The account tuple + * (`typeTag`…`friendIdentityId`) is the routing context [fetchAccount] + * resolves the owning Room account by; the DashPay identity halves are + * absent on every non-DashPay account. + */ + @Serializable + data class EngineUtxoRow( + val typeTag: Int, + val standardTag: Int = 0, + val index: Int = 0, + val registrationIndex: Int = 0, + val keyClass: Int = 0, + 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, + val isLocked: Boolean = false, + ) + + /** + * One page of the engine's UTXO inventory: the rows, the opaque resume + * cursor (absent on the last page) and whether more pages follow. + */ + @Serializable + data class EngineUtxoPage( + val utxos: List = emptyList(), + val cursor: String? = null, + val hasMore: Boolean = false, + ) + /** * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] * are the healed holes; a non-zero value after a completed sync means @@ -1211,7 +1253,6 @@ class PlatformWalletPersistenceHandler( val healedUnowned: Int = 0, val skippedImmature: Int, val skippedNoAddress: Int, - val accountErrors: Int, /** Store rows marked unspent whose outpoint the engine records as * spent — the lost-spend-update class (dashpay/platform#4425). * LOG-ONLY: the engine's spent set includes mempool spends with no @@ -1225,9 +1266,11 @@ class PlatformWalletPersistenceHandler( * by this pass. */ val wouldRemove: Int = 0, val wouldRemoveDuffs: Long = 0, - /** Watch-only DIP-15 contact rows excluded from classification — - * the engine's own accounts never report them, so their absence - * from both inventories is expected, not divergence. */ + /** Watch-only DIP-15 contact coins excluded on both sides — engine + * rows the insert pass refused to heal as ours, and unspent store + * rows the reverse pass did not classify (the engine's own accounts + * never report them, so their absence is expected, not + * divergence). */ val skippedForeign: Int = 0, /** Store rows marked spent for a coin the engine lists UNSPENT — * either a released coin from a swept transaction whose release @@ -1238,15 +1281,40 @@ class PlatformWalletPersistenceHandler( val stuckSpent: Int = 0, val stuckSpentDuffs: Long = 0, /** Transport reads that failed mid-sweep — an engine inventory page - * or an outpoint-classification batch that came back empty. The - * pass stops at the first one; whatever it already applied stands - * (insert-only, idempotent) and the rest waits for the next - * cadence tick. A persistently non-zero value means the sweep - * never finishes, so the report's other counters are a partial - * view. */ + * after the first, or an outpoint-classification batch, that came + * back null. The pass stops at the first one; whatever it already + * applied stands (insert-only, idempotent) and the rest waits for + * the next cadence tick. A persistently non-zero value means the + * sweep never finishes, so the report's other counters are a + * partial view. */ val transportFailures: Int = 0, ) + /** The insert pass's half of a [TxoReconcileReport]. */ + private data class HealPass( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + val netAmountSuspects: Int, + val healedUnowned: Int, + val skippedImmature: Int, + val skippedNoAddress: Int, + val skippedForeign: Int, + val transportFailures: Int, + ) + + /** The classification pass's half of a [TxoReconcileReport]. */ + private data class ClassifyPass( + val wouldFlipSpent: Int, + val wouldFlipSpentDuffs: Long, + val wouldRemove: Int, + val wouldRemoveDuffs: Long, + val stuckSpent: Int, + val stuckSpentDuffs: Long, + val skippedForeign: Int, + val transportFailures: Int, + ) + /** * Reconcile the Room `txos` mirror against the engine's live UTXO * inventory, healing rows a changeset failed to deliver. The mirror is @@ -1258,31 +1326,29 @@ class PlatformWalletPersistenceHandler( * nondeterministically drops the change outputs of sends funded from * CoinJoin-account outputs. * - * Both directions are BOUNDED, and deliberately so. A wallet's UTXO - * count is chain-controlled — anyone who knows a watched address can - * keep sending dust to it — so a pass that materialized the whole - * inventory would hand a remote party control over how much this - * process allocates on every SYNCED transition and every cadence tick. - * Instead: + * Two passes, each bounded, sharing only the set of watch-only contact + * accounts they both exclude: + * + * * [healMissingTxos] pages the ENGINE ([engineUtxoPage]: `cursor` null + * to start, then the cursor the previous page returned while its + * `hasMore` is true) 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 giant one — that is the point. + * * [classifyStoreRows] pages the STORE and asks [classifyOutpoints] + * about one page at a time (0 unknown, 1 unspent, 2 spent). It + * writes nothing; every verdict is log-only. * - * * [engineUtxoPage] hands back one bounded page of the engine's - * inventory at a time (`cursor` null to start, then the `cursor` the - * previous page returned while its `hasMore` is true), and each page - * is applied in its own Room transaction. A sweep is therefore many - * small commits rather than one giant one; that is the point, and it - * is safe because the pass is insert-only and idempotent. - * * The reverse direction pages the STORE's own rows and asks - * [classifyOutpoints] about one page at a time (0 unknown, 1 - * unspent, 2 spent), instead of pulling both engine inventories over - * and holding them as sets. + * Neither side ever holds a set over a whole inventory: a wallet's UTXO + * count is chain-controlled (anyone who knows a watched address can + * keep sending dust to it), so a pass that materialized it would hand a + * remote party control over how much this process allocates on every + * SYNCED transition and every cadence tick. * - * Insert-only by design: rows the engine holds and the mirror lacks are - * added; rows the mirror holds and the engine lacks are LEFT ALONE (the - * mirror may legitimately be ahead — a live spend marks rows spent here - * before the engine's map settles — and it also carries watch-only - * contact outputs the engine's own accounts never report). Spent-state - * repair is deliberately out of scope; the reverse pass only classifies - * and logs. + * Rows the mirror holds and the engine lacks are LEFT ALONE (the mirror + * may legitimately be ahead — a live spend marks rows spent here before + * the engine's map settles — and it also carries watch-only contact + * outputs the engine's own accounts never report). Spent-state repair + * is deliberately out of scope. * * [minConfirmations] (default 100): the engine snapshot cannot carry * `isCoinbase`/`isInstantLocked`, so inserted rows get @@ -1295,6 +1361,13 @@ class PlatformWalletPersistenceHandler( * value, but the record may equally have been corrected already by a * callback racing this sweep, and blind addition double-credits. * + * Returns null when the FIRST engine page is unavailable — there is + * nothing to reconcile against, so there is no report to make. A page + * or classification batch failing later truncates the sweep instead, + * which the report's `transportFailures` records. A page that does not + * decode as an [EngineUtxoPage] is a contract violation between the JNI + * emitter and this reader, and it throws rather than being absorbed. + * * Must NOT be called from the handler's own [dispatcher] (it takes * [callbackExclusion] and runs Room transactions). */ @@ -1305,23 +1378,7 @@ class PlatformWalletPersistenceHandler( pageSize: Int = TXO_RECONCILE_PAGE_SIZE, engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, - ): TxoReconcileReport { - var engineUtxos = 0 - var inserted = 0 - var insertedDuffs = 0L - var netAmountSuspects = 0 - var skippedImmature = 0 - var skippedNoAddress = 0 - var accountErrors = 0 - var wouldFlipSpent = 0 - var wouldFlipSpentDuffs = 0L - var wouldRemove = 0 - var wouldRemoveDuffs = 0L - var skippedForeign = 0 - var healedUnowned = 0 - var stuckSpent = 0 - var stuckSpentDuffs = 0L - var transportFailures = 0 + ): TxoReconcileReport? { val limit = pageSize.coerceAtLeast(1) // Watch-only DIP-15 contact (external) accounts, resolved once up @@ -1341,178 +1398,286 @@ class PlatformWalletPersistenceHandler( .map { it.id } .toSet() - // ── Insert pass: one engine page at a time, one Room transaction - // each. The page is fetched OUTSIDE the exclusion lock — the fetch - // is a native call into the engine, and the lock exists to keep - // changeset callbacks out of our writes, not out of the engine. + val heal = healMissingTxos( + walletId, tipHeight, minConfirmations, limit, foreignAccountIds, engineUtxoPage, + ) ?: return null + val classify = classifyStoreRows(walletId, limit, foreignAccountIds, classifyOutpoints) + + val report = TxoReconcileReport( + engineUtxos = heal.engineUtxos, + inserted = heal.inserted, + insertedDuffs = heal.insertedDuffs, + netAmountSuspects = heal.netAmountSuspects, + healedUnowned = heal.healedUnowned, + skippedImmature = heal.skippedImmature, + skippedNoAddress = heal.skippedNoAddress, + wouldFlipSpent = classify.wouldFlipSpent, + wouldFlipSpentDuffs = classify.wouldFlipSpentDuffs, + wouldRemove = classify.wouldRemove, + wouldRemoveDuffs = classify.wouldRemoveDuffs, + skippedForeign = heal.skippedForeign + classify.skippedForeign, + stuckSpent = classify.stuckSpent, + stuckSpentDuffs = classify.stuckSpentDuffs, + transportFailures = heal.transportFailures + classify.transportFailures, + ) + if (report.inserted > 0 || report.wouldFlipSpent > 0 || report.wouldRemove > 0 || + report.stuckSpent > 0 || report.transportFailures > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed ${report.inserted} missing TXO(s) " + + "(${report.insertedDuffs} duffs), " + + "${report.netAmountSuspects} netAmount suspect(s) (log-only), " + + "healedUnowned=${report.healedUnowned}, " + + "wouldFlipSpent=${report.wouldFlipSpent} " + + "(${report.wouldFlipSpentDuffs} duffs, log-only), " + + "wouldRemove=${report.wouldRemove} " + + "(${report.wouldRemoveDuffs} duffs, log-only), " + + "stuckSpent=${report.stuckSpent} " + + "(${report.stuckSpentDuffs} duffs, log-only), " + + "engine=${report.engineUtxos} " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} " + + "foreign=${report.skippedForeign} " + + "transportFailures=${report.transportFailures} — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", + ) + } else { + Log.i( + TAG, + "txos reconcile: mirror consistent (${report.engineUtxos} engine UTXOs, " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} foreign=${report.skippedForeign})", + ) + } + return report + } + + /** + * Insert pass of [reconcileTxos]: one engine page at a time, one Room + * transaction each. Each page is fetched OUTSIDE the exclusion lock — + * the fetch is a native call into the engine, and the lock exists to + * keep changeset callbacks out of our writes, not out of the engine. + * Returns null when the first page is unavailable (nothing to reconcile + * against); a later null page truncates the pass and is counted. + */ + private suspend fun healMissingTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int, + limit: Int, + foreignAccountIds: Set, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + ): HealPass? { + var engineUtxos = 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var healedUnowned = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var skippedForeign = 0 + var transportFailures = 0 + var cursor: String? = null while (true) { val pageJson = engineUtxoPage(cursor, limit) if (pageJson == null) { + // No first page: nothing to reconcile against, no report. + if (cursor == null) return null // The transport died mid-sweep. Everything already applied // stands (insert-only, idempotent); the rest waits for the // next cadence tick. transportFailures++ break } - val page = kotlinx.serialization.json.Json - .parseToJsonElement(pageJson).jsonObject - val utxos = page["utxos"]?.jsonArray - ?: kotlinx.serialization.json.JsonArray(emptyList()) - accountErrors += page["errors"]?.jsonArray?.size ?: 0 - engineUtxos += utxos.size - val nextCursor = page["cursor"]?.jsonPrimitive?.contentOrNull - val hasMore = page["hasMore"]?.jsonPrimitive?.booleanOrNull ?: false - - if (utxos.isNotEmpty()) { + // Strict decode: an unknown key, a missing required field or a + // mistyped value throws — the row would otherwise be healed with + // a defaulted owner or a zero amount. + val page = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + engineUtxos += page.utxos.size + + if (page.utxos.isNotEmpty()) { callbackExclusion.withLock { database.withTransaction { - // Engine-side entries carry only an address; - // ownership resolves through core_addresses - // .accountId (the same second path rowIsForeign uses - // for store rows). An unresolvable address is NOT - // provably foreign — those proceed, keeping this - // pass's provable-only discipline symmetric: it - // neither mutates nor suppresses on guesswork. - suspend fun addressIsForeign(address: String): Boolean { - val owner = - database.coreAddressDao().getByAddress(address)?.accountId - return owner != null && owner in foreignAccountIds - } - for (element in utxos) { - val row = element.jsonObject - val height = row["height"]?.jsonPrimitive?.int ?: 0 - if (height <= 0 || tipHeight - height + 1 < minConfirmations) { - skippedImmature++ - continue - } - val address = row["address"]?.jsonPrimitive?.content.orEmpty() - if (address.isEmpty()) { - skippedNoAddress++ - continue - } - // The inventory tags every UTXO with its owning - // account tuple. The tag is the authoritative - // foreign check — a watch-only external - // account's coin is the CONTACT's money whether - // or not its address row survived persistence. - // The address-based check stays as a fallback - // for inventories predating the tagged export. - val typeTag = row["typeTag"]?.jsonPrimitive?.int ?: -1 - if (typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL || - addressIsForeign(address) - ) { - skippedForeign++ - continue - } - val txid = - row["txid"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() - val vout = row["vout"]?.jsonPrimitive?.int ?: continue - if (txid.size != 32) continue - if (database.txoDao() - .getByOutpoint(makeOutpoint(txid, vout)) != null - ) { - continue - } - val amount = row["amount"]?.jsonPrimitive?.long ?: 0L - val scriptPubKey = - row["scriptHex"]?.jsonPrimitive?.content.orEmpty() - .hexToByteArray() - val isLocked = row["isLocked"]?.jsonPrimitive?.boolean ?: false - // Resolve the Room account from the tuple and - // stamp it on the healed row. Ownership must not - // depend on the address projection: the two - // things persistence loses together are the TXO - // and its address row, and a healed row with - // neither link is skipped by the restore loader - // at the next mirror-reload — recreating the - // fund loss the heal repaired. - val ownerAccountId = if (typeTag >= 0) { - fetchAccount( - database, walletId, typeTag, - row["index"]?.jsonPrimitive?.int ?: 0, - row["standardTag"]?.jsonPrimitive?.int ?: 0, - row["registrationIndex"]?.jsonPrimitive?.int ?: 0, - row["keyClass"]?.jsonPrimitive?.int ?: 0, - row["userIdentityId"]?.jsonPrimitive?.content - ?.hexToByteArray() ?: ByteArray(32), - row["friendIdentityId"]?.jsonPrimitive?.content - ?.hexToByteArray() ?: ByteArray(32), - )?.id - } else { - null - } - if (ownerAccountId == null) { - // Heal anyway — the address projection may - // still attribute it — but surface the - // unresolved owner: if the address row is - // also gone, this row will not survive the - // next mirror-reload. - healedUnowned++ - Log.w( - TAG, - "txos reconcile: healing TXO with UNRESOLVED account " + - "(typeTag=$typeTag " + - "index=${row["index"]?.jsonPrimitive?.int} " + - "address=$address) — ownership rides on the " + - "address projection alone", - ) - } - upsertUtxoRow( - database, walletId, txid, vout, amount, address, scriptPubKey, - height, - isCoinbase = false, - isConfirmed = true, - isInstantLocked = false, - isLocked = isLocked, - resolvedAccountId = ownerAccountId, - ) - inserted++ - insertedDuffs += amount - // netAmount is NOT mutated here. The record's - // net may already be correct (a corrective - // record callback can land while its TXO - // delivery races this sweep), and adding the - // healed amount to an already-corrected net - // double-credits. The event pipeline owns net - // correctness; this pass only reports the - // suspicion. - val priorTx = database.transactionDao().getByTxid(txid) - if (priorTx != null && priorTx.transactionData.isNotEmpty()) { - netAmountSuspects++ - Log.w( - TAG, - "txos reconcile: healed TXO ${txid.toHex()}:$vout " + - "($amount duffs) has a pre-existing record whose " + - "netAmount may be short by that amount — LOG-ONLY, " + - "storedNet=${priorTx.netAmount}", - ) + for (row in page.utxos) { + when (healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds)) { + HealOutcome.SKIPPED_IMMATURE -> skippedImmature++ + HealOutcome.SKIPPED_NO_ADDRESS -> skippedNoAddress++ + HealOutcome.SKIPPED_FOREIGN -> skippedForeign++ + HealOutcome.ALREADY_PRESENT -> {} + HealOutcome.HEALED -> { + inserted++ + insertedDuffs += row.amount + } + HealOutcome.HEALED_UNOWNED -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + } + HealOutcome.HEALED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + netAmountSuspects++ + } + HealOutcome.HEALED_UNOWNED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + netAmountSuspects++ + } } } } } } - if (!hasMore || nextCursor == null) break - cursor = nextCursor + if (!page.hasMore || page.cursor == null) break + cursor = page.cursor } + return HealPass( + engineUtxos = engineUtxos, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + skippedForeign = skippedForeign, + transportFailures = transportFailures, + ) + } + + private enum class HealOutcome { + SKIPPED_IMMATURE, + SKIPPED_NO_ADDRESS, + SKIPPED_FOREIGN, + ALREADY_PRESENT, + HEALED, + HEALED_UNOWNED, + HEALED_NET_SUSPECT, + HEALED_UNOWNED_NET_SUSPECT, + } + + /** + * Apply one engine inventory row to the store (inside the caller's + * exclusion lock and Room transaction). Provable-only discipline: it + * neither mutates nor suppresses on guesswork. + */ + private suspend fun healEngineRow( + walletId: ByteArray, + row: EngineUtxoRow, + tipHeight: Int, + minConfirmations: Int, + foreignAccountIds: Set, + ): HealOutcome { + if (row.height <= 0 || tipHeight - row.height + 1 < minConfirmations) { + return HealOutcome.SKIPPED_IMMATURE + } + if (row.address.isEmpty()) return HealOutcome.SKIPPED_NO_ADDRESS + // The inventory tags every UTXO with its owning account tuple. The + // tag is the authoritative foreign check — a watch-only external + // account's coin is the CONTACT's money whether or not its address + // row survived persistence. The address-based check (through + // core_addresses.accountId, the same second path rowIsForeign uses + // for store rows) stays as a fallback. An unresolvable address is + // NOT provably foreign — those proceed. + if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) return HealOutcome.SKIPPED_FOREIGN + val addressOwner = database.coreAddressDao().getByAddress(row.address)?.accountId + if (addressOwner != null && addressOwner in foreignAccountIds) { + return HealOutcome.SKIPPED_FOREIGN + } + val txid = row.txid.hexToByteArray() + require(txid.size == 32) { "engine inventory row carries a ${txid.size}-byte txid" } + if (database.txoDao().getByOutpoint(makeOutpoint(txid, row.vout)) != null) { + return HealOutcome.ALREADY_PRESENT + } + // Resolve the Room account from the tuple and stamp it on the + // healed row. Ownership must not depend on the address projection: + // the two things persistence loses together are the TXO and its + // address row, and a healed row with neither link is skipped by + // the restore loader at the next mirror-reload — recreating the + // fund loss the heal repaired. + val ownerAccountId = fetchAccount( + database, walletId, row.typeTag, row.index, row.standardTag, + row.registrationIndex, row.keyClass, + row.userIdentityId?.hexToByteArray() ?: ByteArray(32), + row.friendIdentityId?.hexToByteArray() ?: ByteArray(32), + )?.id + if (ownerAccountId == null) { + // Heal anyway — the address projection may still attribute it — + // but surface the unresolved owner: if the address row is also + // gone, this row will not survive the next mirror-reload. + Log.w( + TAG, + "txos reconcile: healing TXO with UNRESOLVED account " + + "(typeTag=${row.typeTag} index=${row.index} address=${row.address}) — " + + "ownership rides on the address projection alone", + ) + } + upsertUtxoRow( + database, walletId, txid, row.vout, row.amount, row.address, + row.scriptHex.hexToByteArray(), row.height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = row.isLocked, + resolvedAccountId = ownerAccountId, + ) + // netAmount is NOT mutated here. The record's net may already be + // correct (a corrective record callback can land while its TXO + // delivery races this sweep), and adding the healed amount to an + // already-corrected net double-credits. The event pipeline owns + // net correctness; this pass only reports the suspicion. + val priorTx = database.transactionDao().getByTxid(txid) + val netSuspect = priorTx != null && priorTx.transactionData.isNotEmpty() + if (netSuspect) { + Log.w( + TAG, + "txos reconcile: healed TXO ${row.txid}:${row.vout} (${row.amount} duffs) " + + "has a pre-existing record whose netAmount may be short by that " + + "amount — LOG-ONLY, storedNet=${priorTx?.netAmount}", + ) + } + return when { + ownerAccountId == null && netSuspect -> HealOutcome.HEALED_UNOWNED_NET_SUSPECT + ownerAccountId == null -> HealOutcome.HEALED_UNOWNED + netSuspect -> HealOutcome.HEALED_NET_SUSPECT + else -> HealOutcome.HEALED + } + } + + /** + * Classification pass of [reconcileTxos] (the widened scope from the + * #4425 / pre-#971 review). Inverted relative to the insert pass — it + * pages the STORE and asks the engine about each page — so that neither + * side has to hold a set over a whole inventory. + * + * Read-only by construction: it writes nothing, so it runs outside both + * the exclusion lock and any transaction. A row a concurrent callback + * moves under it is at worst a stale log line, and every verdict here + * is log-only anyway. + * + * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. + * Production changeset writes leave txos.accountId null and route + * ownership through coreAddressId -> core_addresses.accountId, so the + * exclusion resolves BOTH paths — an accountId-only check silently + * classifies every contact row. + */ + private suspend fun classifyStoreRows( + walletId: ByteArray, + limit: Int, + foreignAccountIds: Set, + classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, + ): ClassifyPass { + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var stuckSpent = 0 + var stuckSpentDuffs = 0L + var skippedForeign = 0 + var transportFailures = 0 - // ── Reverse pass: classify store rows the engine disagrees with - // (the widened scope from the #4425 / pre-#971 review). Inverted - // relative to the insert pass — it pages the STORE and asks the - // engine about each page — so that neither side has to hold a set - // over a whole inventory. - // - // Read-only by construction: it writes nothing, so it runs outside - // both the exclusion lock and any transaction. A row a concurrent - // callback moves under it is at worst a stale log line, and every - // verdict here is log-only anyway. - // - // Watch-only DIP-15 contact rows are excluded via the same - // `foreignAccountIds` the insert pass resolved above. Production - // changeset writes leave txos.accountId null and route ownership - // through coreAddressId -> core_addresses.accountId, so the - // exclusion must resolve BOTH paths — an accountId-only check - // silently classifies every contact row. suspend fun rowIsForeign( row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity, ): Boolean { @@ -1621,46 +1786,16 @@ class PlatformWalletPersistenceHandler( } } } - - val report = TxoReconcileReport( - engineUtxos = engineUtxos, - inserted = inserted, - insertedDuffs = insertedDuffs, - netAmountSuspects = netAmountSuspects, - healedUnowned = healedUnowned, - skippedImmature = skippedImmature, - skippedNoAddress = skippedNoAddress, - accountErrors = accountErrors, + return ClassifyPass( wouldFlipSpent = wouldFlipSpent, wouldFlipSpentDuffs = wouldFlipSpentDuffs, wouldRemove = wouldRemove, wouldRemoveDuffs = wouldRemoveDuffs, - skippedForeign = skippedForeign, stuckSpent = stuckSpent, stuckSpentDuffs = stuckSpentDuffs, + skippedForeign = skippedForeign, transportFailures = transportFailures, ) - if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || - stuckSpent > 0 || transportFailures > 0 - ) { - Log.w( - TAG, - "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + - "$netAmountSuspects netAmount suspect(s) (log-only), " + - "healedUnowned=$healedUnowned, " + - "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + - "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + - "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + - "engine=${report.engineUtxos} " + - "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + - "foreign=$skippedForeign accountErrors=$accountErrors " + - "transportFailures=$transportFailures — a non-zero " + - "heal after a completed sync means a changeset dropped an owned output", - ) - } else { - Log.i(TAG, "txos reconcile: mirror consistent ($engineUtxos engine UTXOs)") - } - return report } override fun onWalletChangesetUtxoSpent( @@ -4507,6 +4642,14 @@ class PlatformWalletPersistenceHandler( */ const val TXO_RECONCILE_PAGE_SIZE = 512 + /** + * Decoder for the engine inventory transport. Deliberately the + * strict default (`ignoreUnknownKeys = false`, no coercion): the JNI + * emitter and [EngineUtxoPage] are one contract, and a drift between + * them must fail the decode, not heal a defaulted row. + */ + private val engineJson = Json + /** [reconcileTxos] classification verdicts, mirroring * `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`. */ internal const val OUTPOINT_CLASS_UNKNOWN: Byte = 0 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 1887844bcab..c72a5569832 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -63,16 +63,6 @@ interface TransactionDao { @Upsert suspend fun upsert(transaction: TransactionEntity) - /** - * TXO-reconcile repair: credit a missed own-output back into the - * transaction's stored net amount. A record born blind to one of its - * own outputs (the CoinJoin-funded-send change-drop) persists - * `netAmount` short by exactly that output's value, so the repair is - * a plain add. Returns the number of rows updated (0 = no such tx). - */ - @Query("UPDATE transactions SET netAmount = netAmount + :delta WHERE txid = :txid") - suspend fun addToNetAmount(txid: ByteArray, delta: Long): Int - @Upsert suspend fun upsertInvolvement(involvement: TransactionAccountInvolvementEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 02b13215340..f3b6c250295 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1262,46 +1262,58 @@ class PlatformWalletManager( * sweep). Returns null when the engine inventory read failed at the * FIRST page: there is nothing to reconcile against, so there is no * report to make. A page or classification batch failing later - * truncates the sweep instead, which the report's - * `transportFailures` records. + * truncates the sweep instead, which the report's `transportFailures` + * records. Native failures surface through [mapNativeErrors] as + * exceptions; both transport lambdas turn them into the null the + * handler's truncate-and-report contract is written against (and log + * them), so a mid-sweep fault yields a partial report rather than + * discarding the sweep. A malformed page is NOT absorbed: the handler's + * strict decode throws, and that propagates. */ suspend fun reconcileTxoStore( walletId: ByteArray, tipHeight: Int, minConfirmations: Int = 100, - ): PlatformWalletPersistenceHandler.TxoReconcileReport? { - suspend fun page(cursor: String?, limit: Int): String? = withContext(Dispatchers.IO) { - mapNativeErrors { - WalletManagerNative.walletManagerUtxosPageJson( - managerHandle, walletId, network.ffiValue, cursor, limit, - ) - } - } - val pageSize = PlatformWalletPersistenceHandler.TXO_RECONCILE_PAGE_SIZE - // Read the first page before entering the reconcile so a dead - // transport still means "no report", the contract callers had - // before the sweep was paged. The handler asks for the null cursor - // exactly once, so this page is spent, not re-read. - val firstPage = page(null, pageSize) ?: return null - return persistenceHandler.reconcileTxos( + ): PlatformWalletPersistenceHandler.TxoReconcileReport? = + persistenceHandler.reconcileTxos( walletId = walletId, tipHeight = tipHeight, minConfirmations = minConfirmations, - pageSize = pageSize, engineUtxoPage = { cursor, limit -> - if (cursor == null) firstPage else page(cursor, limit) + withContext(Dispatchers.IO) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, network.ffiValue, cursor, limit, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: engine inventory page failed (cursor=$cursor)", + t, + ) + }.getOrNull() + } }, classifyOutpoints = { outpoints -> withContext(Dispatchers.IO) { - mapNativeErrors { - WalletManagerNative.walletManagerClassifyOutpoints( - managerHandle, walletId, outpoints, + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, outpoints, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: outpoint classification failed", + t, ) - } + }.getOrNull() } }, ) - } /** * Refresh the persisted DashPay payment history for one identity: @@ -2115,8 +2127,23 @@ class PlatformWalletManager( if (!synced) return val now = System.currentTimeMillis() if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return - val tipHeight = (progress.filters?.currentHeight ?: 0L).toInt() - if (tipHeight <= 0) return + // The filter sub-phase is the wallet-relevant height; if dash-spv + // reports SYNCED without one (filter sync disabled, or the phase + // dropped after completion) fall back to the header tip rather than + // silently skipping the heal this exists to deliver. + val tipHeight = ( + progress.filters?.currentHeight?.takeIf { it > 0L } + ?: progress.headers?.currentHeight + ?: 0L + ).toInt() + if (tipHeight <= 0) { + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile skipped: SYNCED progress carries no tip height " + + "(filters=${progress.filters?.currentHeight} headers=${progress.headers?.currentHeight})", + ) + return + } val walletIds = wallets.value.values.map { it.walletId } if (walletIds.isEmpty()) return lastTxoReconcileAtMs = now diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index b5f56a03769..dc030bc087e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -6008,7 +6008,7 @@ class PlatformWalletPersistenceHandlerTest { ): String = """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + - """"height":$height,"isLocked":false}],"errors":[]}""" + """"height":$height,"isLocked":false}]}""" private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } @@ -6624,7 +6624,7 @@ class PlatformWalletPersistenceHandlerTest { val spentRows = spent.joinToString(",") { (txid, vout) -> """{"txid":"$txid","vout":$vout}""" } - return """{"utxos":[$utxoRows],"spent":[$spentRows],"errors":[]}""" + return """{"utxos":[$utxoRows],"spent":[$spentRows]}""" } @Test @@ -6685,7 +6685,7 @@ class PlatformWalletPersistenceHandlerTest { """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + """"address":"yTestAddr","scriptHex":"51",""" + - """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.wouldFlipSpent) assertEquals(0, report.wouldRemove) @@ -6804,7 +6804,7 @@ class PlatformWalletPersistenceHandlerTest { """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + """"address":"yContactPaid","scriptHex":"51",""" + - """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + """"height":1400000,"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) @@ -6878,7 +6878,7 @@ class PlatformWalletPersistenceHandlerTest { """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + """"address":"yContactNoRow","scriptHex":"51",""" + - """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + """"height":1400000,"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) @@ -6936,7 +6936,7 @@ class PlatformWalletPersistenceHandlerTest { pageSize = 2, engineUtxoPage = engine::page, classifyOutpoints = engine::classify, - ) + )!! assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) assertEquals(5, report.engineUtxos) @@ -6969,7 +6969,7 @@ class PlatformWalletPersistenceHandlerTest { if (cursor == null) engine.page(cursor, limit) else null }, classifyOutpoints = engine::classify, - ) + )!! assertEquals(1, report.transportFailures) assertEquals("only the page that arrived", 2, report.inserted) @@ -6996,7 +6996,7 @@ class PlatformWalletPersistenceHandlerTest { pageSize = 1, engineUtxoPage = engine::page, classifyOutpoints = engine::classify, - ) + )!! assertEquals("one classification batch per store page", 3, engine.batches) assertEquals("every store row reached the classifier", 3, engine.classified) @@ -7021,12 +7021,55 @@ class PlatformWalletPersistenceHandlerTest { pageSize = 1, engineUtxoPage = engine::page, classifyOutpoints = { null }, - ) + )!! assertEquals(1, report.transportFailures) assertEquals(0, report.wouldRemove) } + @Test + fun reconcileReturnsNoReportWhenTheFirstPageIsUnavailable() = runTest { + // No first page means nothing to reconcile against — the contract + // callers had before the sweep was paged, now enforced inside the + // handler rather than by a prefetch in the caller. + var classifyCalls = 0 + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> null }, + classifyOutpoints = { outpoints -> classifyCalls++; ByteArray(outpoints.size / 36) }, + ) + assertNull("a dead transport yields no report", report) + assertEquals("and the classification pass never runs", 0, classifyCalls) + } + + @Test + fun reconcileRejectsAMalformedInventoryRowInsteadOfHealingIt() = runTest { + // The transport is a typed contract on both ends. A row missing its + // amount (or carrying a key this reader does not know) must fail the + // decode loudly — the alternative is a defaulted row written into + // the mirror the engine reloads from. + val malformed = """{"utxos":[{"typeTag":0,"txid":"${changeTxid.toHexLower()}",""" + + """"vout":40,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd","height":1400000}],""" + + """"cursor":null,"hasMore":false}""" + val thrown = runCatching { + handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> malformed }, + classifyOutpoints = { outpoints -> ByteArray(outpoints.size / 36) }, + ) + }.exceptionOrNull() + assertTrue( + "strict decode must throw, got $thrown", + thrown is kotlinx.serialization.SerializationException, + ) + assertNull( + "nothing was healed from the malformed page", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 40)), + ) + } + /** * Drive the paged reconcile from one whole-inventory JSON blob — the * shape these tests describe an engine in, and the shape the native @@ -7046,14 +7089,16 @@ class PlatformWalletPersistenceHandlerTest { pageSize: Int = 2, ): PlatformWalletPersistenceHandler.TxoReconcileReport { val engine = FakeEngine(inventoryJson) - return reconcileTxos( - walletId = walletId, - tipHeight = tipHeight, - minConfirmations = minConfirmations, - pageSize = pageSize, - engineUtxoPage = engine::page, - classifyOutpoints = engine::classify, - ) + return checkNotNull( + reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ), + ) { "the fake engine always serves a first page" } } /** @@ -7066,7 +7111,6 @@ class PlatformWalletPersistenceHandlerTest { */ private class FakeEngine(inventoryJson: String) { private val utxos: List - private val errors: List private val unspentKeys: Set private val spentKeys: Set @@ -7083,7 +7127,6 @@ class PlatformWalletPersistenceHandlerTest { val root = kotlinx.serialization.json.Json .parseToJsonElement(inventoryJson).jsonObject utxos = root["utxos"]?.jsonArray?.map { it.jsonObject } ?: emptyList() - errors = root["errors"]?.jsonArray?.toList() ?: emptyList() unspentKeys = utxos.map { key( it["txid"]!!.jsonPrimitive.content, @@ -7104,12 +7147,7 @@ class PlatformWalletPersistenceHandlerTest { val slice = utxos.drop(start).take(limit) val next = start + slice.size val hasMore = next < utxos.size - // Account read failures belong to the sweep, not to a page: the - // native side reports each faulted account once, so the fake - // puts them all on the first page. - val faults = if (start == 0) errors.joinToString(",") { it.toString() } else "" return """{"utxos":[${slice.joinToString(",") { it.toString() }}],""" + - """"errors":[$faults],""" + """"cursor":${if (hasMore) "\"$next\"" else "null"},"hasMore":$hasMore}""" }