From d6daace76b424c708da15aa31e8e644bcbc0faf5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 19 Aug 2026 11:13:37 -0700 Subject: [PATCH 01/10] =?UTF-8?q?fix(kotlin-sdk):=20TXO-store=20reconcile?= =?UTF-8?q?=20=E2=80=94=20heal=20mirror=20holes=20against=20the=20engine's?= =?UTF-8?q?=20UTXO=20inventory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Room txos mirror is write-behind with no feedback loop: a changeset that fails to deliver an owned output leaves a permanent hole, and the engine is REBUILT from the mirror on restart (buildUtxoRestoreData), so the hole graduates to a fund-loss on the next launch. Observed on job-flower as the 106.43 -> 86.33 restart drop: the rescan nondeterministically drops the change outputs of sends funded from CoinJoin-account outputs (all three known restores dropped the three Aug 5 change outputs; one of three also dropped the Aug 15 one). - walletManagerAllUtxosJson (JNI): the engine's full per-account UTXO inventory as JSON — account_balances sweep to enumerate accounts, platform_wallet_account_utxos per account, address derived from the script; per-account faults reported in-band so one bad account cannot mask the others' repair. - PlatformWalletManager.reconcileTxoStore / handler.reconcileTxos: insert-only diff of that inventory against Room — never flips spend state, never deletes (the mirror may legitimately be ahead on live spends and carries watch-only contact outputs). 100-conf gate because the snapshot cannot carry isCoinbase/isInstantLocked; fresher holes age into the next sweep. - netAmount repair: a record born blind to its own change output persisted netAmount short by exactly that value (verified: 6cef55ab stored -10.00010000 vs true -0.11000227); credit it back when the transaction row pre-exists with real bytes. - onWalletChangesetUtxoAdded body extracted to upsertUtxoRow so the callback and the reconciler share one insert discipline (stub tx FK row + pending-input drain). Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 19 + .../PlatformWalletPersistenceHandler.kt | 433 ++++++++++++------ .../dashsdk/persistence/dao/TransactionDao.kt | 10 + .../dashsdk/wallet/PlatformWalletManager.kt | 80 ++++ .../PlatformWalletPersistenceHandlerTest.kt | 113 +++++ packages/rs-unified-sdk-jni/Cargo.toml | 5 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 196 ++++++++ 7 files changed, 726 insertions(+), 130 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 40bdef869c3..d6d07ff206f 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 @@ -128,6 +128,25 @@ internal object WalletManagerNative { /** Balance as `long[4]` = {confirmed, unconfirmed, immature, locked}. */ external fun walletGetBalance(walletHandle: Long): LongArray + /** + * The engine's full UTXO inventory for one wallet, every account, as + * JSON `{"utxos":[...],"errors":[...]}` — the source of truth the + * TXO-store reconciler ([PlatformWalletManager.reconcileTxoStore]) + * diffs against the Room `txos` mirror. 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 sweep. `network` is + * [org.dashfoundation.dashsdk.Network.ffiValue]. + */ + external fun walletManagerAllUtxosJson( + managerHandle: Long, + walletId: ByteArray, + network: Int, + ): String? + // ── 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 f75bb977531..5fac67b4ec9 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,12 @@ 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.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 @@ -1034,141 +1040,310 @@ class PlatformWalletPersistenceHandler( isLocked: Boolean, ): Int = guarded { stage(walletId) { db -> - val outpoint = makeOutpoint(txid, vout) - val parentTx = db.transactionDao().getByTxid(txid) - // A globally-swept parent is a transaction Rust has already - // proven can never confirm — a fresh UTXO entry naming its txid - // would (re-)create exactly the phantom output - // `onWalletChangesetTransactionsSwept` deletes on every callback - // that observes the sweep. Bail rather than attach a new row to - // a transaction still excluded from restoration. - // - // This does not fight [onWalletChangesetTransaction]'s - // reinstatement path — it relies on that method running first. - // The JNI bridge (`persist_changeset_account` in - // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for - // an account's `transactions` before this method for that same - // account's `utxos_added`, so a reinstating record for this - // txid in this same round has already cleared the tombstone by - // the time this guard reads it; only a UTXO entry with no - // accompanying record this round still finds the flag set. That - // is genuinely a stale/out-of-order signal — Rust does not - // otherwise re-emit a swept loser's own outputs — and staying - // defensive here is correct: there is no record in flight to - // attribute a resurrected output to. See - // TransactionEntity.isGloballySwept. - if (parentTx?.isGloballySwept == true) return@stage - // Ensure a parent transaction row exists (stub if missing, so - // the TXO FK holds; the real tx upsert overwrites it later). - if (parentTx == 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:895-953). A spend that + * arrived first was deferred (see onWalletChangesetTransaction); now + * that the funding output is here, link the newest pending spend + * (reorg/double-spend: newest wins) 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, + ) { + val outpoint = makeOutpoint(txid, vout) + val parentTx = db.transactionDao().getByTxid(txid) + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its txid + // would (re-)create exactly the phantom output + // `onWalletChangesetTransactionsSwept` deletes on every callback + // that observes the sweep. Bail rather than attach a new row to + // a transaction still excluded from restoration. + // + // This does not fight [onWalletChangesetTransaction]'s + // reinstatement path — it relies on that method running first. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for + // an account's `transactions` before this method for that same + // account's `utxos_added`, so a reinstating record for this + // txid in this same round has already cleared the tombstone by + // the time this guard reads it; only a UTXO entry with no + // accompanying record this round still finds the flag set. That + // is genuinely a stale/out-of-order signal — Rust does not + // otherwise re-emit a swept loser's own outputs — and staying + // defensive here is correct: there is no record in flight to + // attribute a resurrected output to. See + // TransactionEntity.isGloballySwept. + if (parentTx?.isGloballySwept == true) return + // Ensure a parent transaction row exists (stub if missing, so + // the TXO FK holds; the real tx upsert overwrites it later). + if (parentTx == null) { + db.transactionDao().upsert( + TransactionEntity(txid = txid, transactionData = ByteArray(0)), + ) + } + val existing = db.txoDao().getByOutpoint(outpoint) + val coreAddressId = if (address.isNotEmpty()) address else null + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + // The wallet is handing this outpoint over as a UTXO, so it + // holds it unspent — authoritative, and the only thing that + // lifts a mark with neither a spender nor a winner behind + // it (a pre-stamp row from before `holdSpentWithoutSpender` + // named its winner; every hold written today is stamped). A + // row whose spend is still on record keeps its flag — the + // pending drain below owns that transition — and so does a + // `supersededByTxid` hold: the winner that consumed this + // coin is known even though its row never materialized + // here, and a re-delivery cannot outrank that verdict — a + // restore-rescan re-finds the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet. Only an explicit release + // (`releaseByOutpoint`) frees a stamped coin. + isSpent = existing?.isSpent == true && + (existing.spendingTxid != null || existing.supersededByTxid != null), + 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 = existing?.supersededByTxid, + ) + db.txoDao().upsert(row) + // Drain any pending-input rows staged before this funding TXO + // existed — a 1:1 port of the Swift upsertUtxo drain + // (PlatformWalletPersistenceHandler.swift:895-953). A spend that + // arrived first was deferred (see onWalletChangesetTransaction); + // now that the funding output is here, link the newest pending + // spend (reorg/double-spend: newest wins) 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. + // Newest-wins arbitrates between competing *observations* + // (reorg / double-spend sightings), but a tombstone is not an + // observation — it is the sweep's settled verdict that its + // winner consumed this coin. The two coexist in exactly one + // way: records precede sweeps within a round, so the winner's + // own record can stage an ordinary pending row for this + // outpoint moments before the sweep repoints the loser's row + // — which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never + // stamp `supersededByTxid`, and then delete every row + // including the tombstone — the durable hold evaporates and + // the consumed coin re-enters the restore set. + val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } + ?: pending.maxByOrNull { it.createdAt }!! + val spending = db.transactionDao().getByTxid(chosen.spendingTxid) + if (chosen.isSweptTombstone) { + // `onWalletChangesetTransactionsSwept` repointed this row + // at the sweep's winner because the loser it originally + // recorded is gone. A sweep's winner is already final — + // there is no mempool state to wait out — so `isSpent` + // does not gate on `spending` the way an ordinary pending + // spend does; that lookup only succeeds when the winner + // happens to have its own materialized row, which isn't + // guaranteed (and `spendingTxid`'s FK forbids forcing the + // reference otherwise). `supersededByTxid` is what makes + // the mark durable either way — it is what the recovery + // clear above checks so this coin isn't handed back as + // spendable on a later sync. + db.txoDao().upsert( + row.copy( + isSpent = true, + spendingTxid = spending?.txid ?: row.spendingTxid, + spendingInputIndex = chosen.inputIndex, + supersededByTxid = chosen.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + db.txoDao().upsert( + row.copy( + isSpent = row.isSpent || spentInBlock, + spendingTxid = chosen.spendingTxid, + spendingInputIndex = chosen.inputIndex, + lastUpdated = now(), + ), ) } - val existing = db.txoDao().getByOutpoint(outpoint) - val coreAddressId = if (address.isNotEmpty()) address else null - val row = TxoEntity( - outpoint = outpoint, - vout = vout, - amount = amount, - address = address, - scriptPubKey = scriptPubKey, - height = height, - isCoinbase = isCoinbase, - isConfirmed = isConfirmed, - isInstantLocked = isInstantLocked, - isLocked = isLocked, - // The wallet is handing this outpoint over as a UTXO, so it - // holds it unspent — authoritative, and the only thing that - // lifts a mark with neither a spender nor a winner behind - // it (a pre-stamp row from before `holdSpentWithoutSpender` - // named its winner; every hold written today is stamped). A - // row whose spend is still on record keeps its flag — the - // pending drain below owns that transition — and so does a - // `supersededByTxid` hold: the winner that consumed this - // coin is known even though its row never materialized - // here, and a re-delivery cannot outrank that verdict — a - // restore-rescan re-finds the funding output precisely - // because it is blind to an unconfirmed winner no block - // carries yet. Only an explicit release - // (`releaseByOutpoint`) frees a stamped coin. - isSpent = existing?.isSpent == true && - (existing.spendingTxid != null || existing.supersededByTxid != null), - 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 = existing?.supersededByTxid, - ) - db.txoDao().upsert(row) - // Drain any pending-input rows staged before this funding TXO - // existed — a 1:1 port of the Swift upsertUtxo drain - // (PlatformWalletPersistenceHandler.swift:895-953). A spend that - // arrived first was deferred (see onWalletChangesetTransaction); - // now that the funding output is here, link the newest pending - // spend (reorg/double-spend: newest wins) 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. - // Newest-wins arbitrates between competing *observations* - // (reorg / double-spend sightings), but a tombstone is not an - // observation — it is the sweep's settled verdict that its - // winner consumed this coin. The two coexist in exactly one - // way: records precede sweeps within a round, so the winner's - // own record can stage an ordinary pending row for this - // outpoint moments before the sweep repoints the loser's row - // — which keeps its original, older `createdAt`. Letting the - // younger ordinary row win there would take the gated branch - // below (`isSpent` false until the winner confirms), never - // stamp `supersededByTxid`, and then delete every row - // including the tombstone — the durable hold evaporates and - // the consumed coin re-enters the restore set. - val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } - ?: pending.maxByOrNull { it.createdAt }!! - val spending = db.transactionDao().getByTxid(chosen.spendingTxid) - if (chosen.isSweptTombstone) { - // `onWalletChangesetTransactionsSwept` repointed this row - // at the sweep's winner because the loser it originally - // recorded is gone. A sweep's winner is already final — - // there is no mempool state to wait out — so `isSpent` - // does not gate on `spending` the way an ordinary pending - // spend does; that lookup only succeeds when the winner - // happens to have its own materialized row, which isn't - // guaranteed (and `spendingTxid`'s FK forbids forcing the - // reference otherwise). `supersededByTxid` is what makes - // the mark durable either way — it is what the recovery - // clear above checks so this coin isn't handed back as - // spendable on a later sync. - db.txoDao().upsert( - row.copy( - isSpent = true, - spendingTxid = spending?.txid ?: row.spendingTxid, - spendingInputIndex = chosen.inputIndex, - supersededByTxid = chosen.spendingTxid, - lastUpdated = now(), - ), - ) - } else { - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK - db.txoDao().upsert( - row.copy( - isSpent = row.isSpent || spentInBlock, - spendingTxid = chosen.spendingTxid, - spendingInputIndex = chosen.inputIndex, - lastUpdated = now(), - ), + 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, + val netAmountRepairs: Int, + val skippedImmature: Int, + val skippedNoAddress: Int, + val accountErrors: Int, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory ([engineUtxosJson] — the + * `WalletManagerNative.walletManagerAllUtxosJson` payload). 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. + * + * 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. + * + * [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. + * + * Repairs `netAmount` alongside: a record born blind to one of its + * own outputs persisted `netAmount` short by exactly that output's + * value (verified against the job-flower dataset: -10.00010000 + * stored vs -0.11000227 true for tx 6cef55ab…). The bump applies + * only when the transaction row pre-exists with real bytes — a stub + * row created by this very insert has nothing to repair. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs a Room transaction). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + engineUtxosJson: String, + tipHeight: Int, + minConfirmations: Int = 100, + ): TxoReconcileReport { + val root = kotlinx.serialization.json.Json + .parseToJsonElement(engineUtxosJson).jsonObject + val utxos = root["utxos"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) + val accountErrors = root["errors"]?.jsonArray?.size ?: 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountRepairs = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + callbackExclusion.withLock { + database.withTransaction { + 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 + } + 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 + // netAmount repair decision BEFORE the insert stubs a row. + val priorTx = database.transactionDao().getByTxid(txid) + upsertUtxoRow( + database, walletId, txid, vout, amount, address, scriptPubKey, + height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = isLocked, ) + inserted++ + insertedDuffs += amount + if (priorTx != null && priorTx.transactionData.isNotEmpty()) { + if (database.transactionDao().addToNetAmount(txid, amount) > 0) { + netAmountRepairs++ + } + } } - for (p in pending) db.documentDao().deletePendingInput(p) } } - 0 + val report = TxoReconcileReport( + engineUtxos = utxos.size, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountRepairs = netAmountRepairs, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + accountErrors = accountErrors, + ) + if (inserted > 0 || accountErrors > 0) { + Log.w( + TAG, + "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + + "$netAmountRepairs netAmount repair(s), engine=${report.engineUtxos} " + + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + + "accountErrors=$accountErrors — a non-zero heal after a completed " + + "sync means a changeset dropped an owned output", + ) + } else { + Log.i(TAG, "txos reconcile: mirror consistent (${utxos.size} engine UTXOs)") + } + return report } override fun onWalletChangesetUtxoSpent( 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 6bf67adede2..3e823f91569 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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 04bbcc2a314..202d26781c7 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 @@ -1224,6 +1224,37 @@ 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 (and their + * transactions' `netAmount`). 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. + * + * 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; see [PlatformWalletPersistenceHandler.reconcileTxos] + * for why). Returns null when the engine inventory read failed. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? { + val json = withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerAllUtxosJson( + managerHandle, walletId, network.ffiValue, + ) + } + } ?: return null + return persistenceHandler.reconcileTxos(walletId, json, tipHeight, minConfirmations) + } + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -1932,6 +1963,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 @@ -1945,6 +1977,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 @@ -2344,6 +2416,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 600c0168626..eea3670ff1d 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 @@ -5038,6 +5038,61 @@ 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.reconcileTxos( + 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.netAmountRepairs) + + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) + assertNotNull(row) + assertFalse(row!!.isSpent) + assertEquals(989_009_773L, row.amount) + assertTrue(row.isConfirmed) + + // -10.00010000 + 9.89009773 = -0.11000227 — history now matches + // what the engine (and dashj) report for this send. + assertEquals( + -11_000_227L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + @Test fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { // A chained sweep that re-points a still-unfunded claim to a new @@ -5434,4 +5489,62 @@ 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.reconcileTxos(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, second.inserted) + assertEquals(0, second.netAmountRepairs) + assertEquals( + -11_000_227L, + 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.reconcileTxos( + 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.reconcileTxos( + 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) + } } diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..dfe89a8e321 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 +# (walletManagerAllUtxosJson) — 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 1cc5801db3e..b2d9ab829e9 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3152,6 +3152,202 @@ fn core_selection_strategy( } } +/// `platform_wallet_account_utxos` swept across every account — the +/// engine-side UTXO inventory `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). +/// Returns a JSON object `{"utxos":[...],"errors":[...]}` — one `utxos` +/// row per output the engine currently holds, tagged with its owning +/// account. Accounts are enumerated with the same `get_account_balances` +/// sweep the DashPay tab uses; keys-only accounts return no UTXOs and +/// contribute nothing. `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 sweep — 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_walletManagerAllUtxosJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + network: 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 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(); + if !entries.is_null() && count > 0 { + let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; + for acc in accounts { + 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 res = unsafe { + platform_wallet_ffi::platform_wallet_account_utxos( + manager_handle as Handle, + wid.as_ptr(), + &spec, + &mut utxos, + &mut utxo_count, + ) + }; + 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 { + continue; + } + 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(); + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"txid\":\"{}\",\"vout\":{},\"amount\":{},\ + \"address\":{},\"scriptHex\":\"{}\",\ + \"height\":{},\"isLocked\":{}}}", + acc.type_tag as u8, + acc.standard_tag as u8, + acc.index, + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + u.value_duffs, + json_escape(&address), + hex_lower(script), + u.height, + u.is_locked, + )); + } + unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_free( + utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, + utxo_count, + ) + }; + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + let json = format!( + "{{\"utxos\":[{}],\"errors\":[{}]}}", + rows.join(","), + errors.join(","), + ); + env.new_string(json) + .map(|s| s.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_walletManagerAllUtxosJson`] +/// 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 8e936bb2327cc0e4de3dce43a66ca5c2cd280d4b Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 19 Aug 2026 15:58:00 -0700 Subject: [PATCH 02/10] fix(platform-wallet): forward UTXOs from updated records through the core changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gap-limit rescan correction (rust-dashcore fix/key-wallet-rescan-changeset) arrives as a BlockProcessed *updated* record whose output roles flipped from Sent to Received/Change. Deriving new/spent UTXOs from inserted records only delivered the corrected row but left the store's TXO hole in place — the reload fund-loss shape. Ordinary re-confirmations re-emit the same UTXOs, which the persisters absorb idempotently (upsertUtxoRow preserves spend linkage; spend-first outputs are flipped by the deferred-input drain). Co-Authored-By: Claude Fable 5 --- .../src/changeset/core_bridge.rs | 72 +++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index e42251d8d87..3450b84942e 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -808,16 +808,24 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Inserted records bring fresh UTXOs and may consume previous + // ones. Updated records CAN change UTXO topology too: a gap-limit + // rescan re-processing a block can newly attribute an output the + // first processing recorded as Sent (its address was beyond the + // watch window then) — key-wallet corrects the record in place + // and re-emits it here (rust-dashcore fix/key-wallet-rescan- + // changeset). Deriving from `updated` as well forwards that + // correction; for ordinary re-confirmations it re-emits the same + // UTXOs, which the persisters absorb idempotently (the Kotlin + // handler's upsertUtxoRow preserves spend linkage, and a + // spend-first output is flipped spent by its deferred-input + // drain). + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. + // Matured coinbase records: no UTXO topology change, just a + // status update for the persister. // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the @@ -2025,6 +2033,56 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); } + /// A gap-limit rescan correction arrives as an `updated` record whose + /// output roles flipped from Sent to Received/Change (key-wallet + /// fix/key-wallet-rescan-changeset). The bridge must derive its UTXOs — + /// with `inserted`-only derivation the corrected record row lands but + /// the store's TXO set keeps the hole, which is the reload fund-loss + /// this whole chain exists to prevent. + #[tokio::test] + async fn updated_record_correction_contributes_its_utxos() { + let tx = tx_with(&[ + (&our_receive_address(), PAID_TO_CONTACT), + (&our_change_address(), CHANGE), + ]); + let corrected = record( + &tx, + bip44_account_0(), + TransactionDirection::Internal, + vec![our_input()], + vec![ + output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID_TO_CONTACT, + ), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + (PAID_TO_CONTACT + CHANGE) as i64 - FUNDING as i64, + ); + let event = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![], + updated: vec![corrected], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&test_manager(), &event).await; + + assert_eq!( + cs.new_utxos.len(), + 2, + "an updated (corrected) record's owned outputs must reach the store" + ); + assert_eq!(cs.spent_utxos.len(), 1, "its spent input is re-marked idempotently"); + assert_eq!(cs.records.len(), 1, "and the corrected row itself is re-emitted"); + } + /// A contact spending an output that a *pre-fix* build already /// persisted must still clear that stale row, so `derive_spent_utxos` /// stays deliberately unfiltered. Only the transaction row and the From 0d0095d127e94f8982680435048de3d86bbdeafa Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 19 Aug 2026 18:00:33 -0700 Subject: [PATCH 03/10] fix(platform-wallet-ffi): repair address-pool holes during wallet restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_core_address_pools ingested the persisted address rows as-is: a mirror that dropped rows (observed in the field: BIP44-change indices 875..=890 absent between surviving rows) produced an in-memory pool with holes, and the row-derived highest_generated suppressed the gap-limit re-derivation that would have filled them. Outputs paying the missing addresses were permanently unrecognizable — the reason a blockchain rescan could not recover funds a fresh seed-restore could (the rescan rebuilds pools from the store; a fresh restore derives them from the seed). The loader now resolves each pool's key source from the signing wallet (built from the persisted account xpubs a few lines earlier) and calls AddressPool::ensure_contiguous_to after row ingestion: every missing index up to the persisted watermark is re-derived, existing rows and used flags untouched. Unresolvable key sources and hardened pools skip the repair and restore exactly as before. Co-Authored-By: Claude Fable 5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 02031fbea7d..3697235c4f1 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4222,6 +4222,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; @@ -4408,11 +4409,86 @@ 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). + if !matches!(key_source, key_wallet::KeySource::NoKeySource) + && !matches!(pool_type, AddressPoolType::AbsentHardened) + { + 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; @@ -4875,7 +4951,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), + )?; } } @@ -8112,7 +8194,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!( From 9fdc9a3daf167918443337ef27f3787d08271e97 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 20 Aug 2026 11:56:27 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(kotlin-sdk):=20widen=20the=20TXO=20r?= =?UTF-8?q?econcile=20=E2=80=94=20classify=20store=20rows=20the=20engine?= =?UTF-8?q?=20disagrees=20with?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the reconcile (Layer 1): the insert-only pass misses other producers of the same store-divergence class. The engine inventory export now carries both halves (unspent UTXOs + spent outpoints, via a new platform_wallet_account_spent_outpoints FFI), and the reconcile adds a reverse pass classifying every store row: - store-unspent, engine-spent: the row lost its spend update (dashpay/platform#4425) — flipped to spent in place. The only mutation in the reverse pass; worst-case error hides a coin the next reconcile re-inserts, and four upstream layers now keep the post-SYNCED engine trustworthy. - store-unspent, engine-unknown: swept/abandoned residue (pre-rust-dashcore#971 stores) — LOG-ONLY, counted and named, never removed. Removal by reconciliation is the one direction where a bug destroys user-visible data. - store-spent, engine-unspent: lost release event, or a live spend racing the engine's map — indistinguishable at reconcile time, and un-marking a coin mid-payment would let the wallet double-spend it. LOG-ONLY. - Watch-only DIP-15 contact rows are excluded up front: the engine's accounts never report them, so their absence is expected, not divergence. Five new handler tests pin the flip, the never-remove, the never-unmark, the young-coin consistency case, and the contact-row exclusion. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 147 ++++++++++++++++- .../PlatformWalletPersistenceHandlerTest.kt | 148 ++++++++++++++++++ .../src/manager_diagnostics.rs | 70 ++++++++- .../src/manager/accessors.rs | 29 ++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 61 +++++++- 5 files changed, 448 insertions(+), 7 deletions(-) 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 5fac67b4ec9..c744b0cd7a9 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 @@ -1226,6 +1226,29 @@ class PlatformWalletPersistenceHandler( val skippedImmature: Int, val skippedNoAddress: Int, val accountErrors: Int, + /** Store rows marked unspent whose outpoint the engine knows was + * spent — the lost-spend-update class (dashpay/platform#4425); + * flipped to spent in place. */ + val flippedSpent: Int = 0, + val flippedSpentDuffs: 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, ) /** @@ -1272,12 +1295,38 @@ class PlatformWalletPersistenceHandler( val root = kotlinx.serialization.json.Json .parseToJsonElement(engineUtxosJson).jsonObject val utxos = root["utxos"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) + val spent = root["spent"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) val accountErrors = root["errors"]?.jsonArray?.size ?: 0 var inserted = 0 var insertedDuffs = 0L var netAmountRepairs = 0 var skippedImmature = 0 var skippedNoAddress = 0 + var flippedSpent = 0 + var flippedSpentDuffs = 0L + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var skippedForeign = 0 + var stuckSpent = 0 + var stuckSpentDuffs = 0L + // Outpoint keys of BOTH engine inventories, for the reverse pass. + // The unspent side deliberately includes immature outputs the + // insert pass skips: a young coin present in both stores is + // consistent, not divergent. + val engineUnspentKeys = HashSet() + for (element in utxos) { + val row = element.jsonObject + val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + engineUnspentKeys.add("$txidHex:$vout") + } + val engineSpentKeys = HashSet() + for (element in spent) { + val row = element.jsonObject + val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + engineSpentKeys.add("$txidHex:$vout") + } callbackExclusion.withLock { database.withTransaction { for (element in utxos) { @@ -1320,6 +1369,79 @@ class PlatformWalletPersistenceHandler( } } } + + // ── Reverse pass: classify store rows the engine disagrees + // with (the widened scope from the #4425 / pre-#971 review). + // Watch-only DIP-15 contact rows are excluded up front: the + // engine's own accounts never report them, so their absence + // from both inventories is expected. + val foreignAccountIds = database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + @Suppress("NAME_SHADOWING") + val storeRows = database.txoDao().observeByWallet(walletId).first() + // Case 3 (log-only): 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. + for (row in storeRows) { + if (!row.isSpent) continue + if (row.accountId != null && row.accountId in foreignAccountIds) continue + val key = "${row.txid?.toHex() ?: continue}:${row.vout}" + if (key in engineUnspentKeys) { + stuckSpent++ + stuckSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row spent but engine lists it " + + "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + + "(lost release, or a live spend racing the engine)", + ) + } + } + val storeUnspent = storeRows.filter { !it.isSpent } + for (row in storeUnspent) { + if (row.accountId != null && row.accountId in foreignAccountIds) { + skippedForeign++ + continue + } + val key = "${row.txid?.toHex() ?: continue}:${row.vout}" + when { + key in engineUnspentKeys -> {} + key in engineSpentKeys -> { + // Lost spend update (#4425): the engine knows this + // coin was spent; the row missed the flip. Flip in + // place — spendingTxid stays as-is (usually null; + // the spender's row, if it ever arrives, relinks + // via the deferred-input drain). + database.txoDao().upsert(row.copy(isSpent = true)) + flippedSpent++ + flippedSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: flipped lost-spend row to spent " + + "outpoint=$key amount=${row.amount}", + ) + } + 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 unknown to engine " + + "(swept/abandoned residue?) outpoint=$key " + + "amount=${row.amount} — LOG-ONLY, not removed", + ) + } + } + } } } val report = TxoReconcileReport( @@ -1330,15 +1452,28 @@ class PlatformWalletPersistenceHandler( skippedImmature = skippedImmature, skippedNoAddress = skippedNoAddress, accountErrors = accountErrors, + flippedSpent = flippedSpent, + flippedSpentDuffs = flippedSpentDuffs, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + skippedForeign = skippedForeign, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, ) - if (inserted > 0 || accountErrors > 0) { + if (inserted > 0 || accountErrors > 0 || flippedSpent > 0 || wouldRemove > 0 || + stuckSpent > 0 + ) { Log.w( TAG, "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + - "$netAmountRepairs netAmount repair(s), engine=${report.engineUtxos} " + + "$netAmountRepairs netAmount repair(s), " + + "flippedSpent=$flippedSpent ($flippedSpentDuffs duffs), " + + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + + "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + + "engine=${report.engineUtxos} " + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + - "accountErrors=$accountErrors — a non-zero heal after a completed " + - "sync means a changeset dropped an owned output", + "foreign=$skippedForeign accountErrors=$accountErrors — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", ) } else { Log.i(TAG, "txos reconcile: mirror consistent (${utxos.size} engine UTXOs)") @@ -3954,6 +4089,10 @@ 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 + 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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index eea3670ff1d..66f8b377467 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 @@ -5547,4 +5547,152 @@ class PlatformWalletPersistenceHandlerTest { 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 reconcileFlipsLostSpendRowToSpent() = runTest { + // A store row still marked unspent for a coin the engine knows was + // spent — the spend update never reached the store + // (dashpay/platform#4425). + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.flippedSpent) + assertEquals(500_000L, report.flippedSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertTrue(row.isSpent) + assertNull(row.spendingTxid) + + // Idempotent: the row is spent now, the reverse pass only reads + // unspent rows. + val second = handler.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(0, second.flippedSpent) + } + + @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.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.flippedSpent) + 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.reconcileTxos(walletId, json, tipHeight = reconcileTip) + assertEquals(0, report.flippedSpent) + 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.reconcileTxos( + 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 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.reconcileTxos( + 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, + ) + } } diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..cca3345a1e2 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}; @@ -610,6 +611,71 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, 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 + .into_iter() + .map(|op| OutPointFFI { + txid: txid_to_array(&op.txid), + vout: op.vout, + }) + .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/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 34281333598..9b105afcad4 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -829,6 +829,35 @@ impl PlatformWalletManager

{ .collect() } + /// 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 // ----------------------------------------------------------------- diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index b2d9ab829e9..6274233d954 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3200,6 +3200,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w return ptr::null_mut(); } let mut rows: Vec = Vec::new(); + let mut spent_rows: Vec = Vec::new(); let mut errors: Vec = Vec::new(); if !entries.is_null() && count > 0 { let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; @@ -3275,6 +3276,63 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w ) }; } + // Second inventory half: the engine's spent outpoints, so the + // reconcile can classify a store row still marked unspent — + // present here means the row lost its spend update + // (dashpay/platform#4425, flip it); present in neither + // inventory means swept/abandoned residue + // (pre-rust-dashcore#971 stores, log-only). Soft-fail like the + // UTXO loop: one bad account must not mask the rest. + for acc in accounts { + 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 outpoints: *const platform_wallet_ffi::OutPointFFI = ptr::null(); + let mut spent_count: usize = 0; + let res = unsafe { + platform_wallet_ffi::platform_wallet_account_spent_outpoints( + manager_handle as Handle, + wid.as_ptr(), + &spec, + &mut outpoints, + &mut spent_count, + ) + }; + 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 outpoints.is_null() || spent_count == 0 { + continue; + } + let items = unsafe { std::slice::from_raw_parts(outpoints, spent_count) }; + for op in items { + spent_rows.push(format!( + "{{\"txid\":\"{}\",\"vout\":{}}}", + hex_lower(&op.txid), + op.vout, + )); + } + unsafe { + platform_wallet_ffi::platform_wallet_account_spent_outpoints_free( + outpoints as *mut platform_wallet_ffi::OutPointFFI, + spent_count, + ) + }; + } } unsafe { platform_wallet_ffi::platform_wallet_manager_free_account_balances( @@ -3283,8 +3341,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w ) }; let json = format!( - "{{\"utxos\":[{}],\"errors\":[{}]}}", + "{{\"utxos\":[{}],\"spent\":[{}],\"errors\":[{}]}}", rows.join(","), + spent_rows.join(","), errors.join(","), ); env.new_string(json) From 4b9684406a68260e0696e14964fd1d8fca6ae6d7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 21 Aug 2026 07:40:02 -0700 Subject: [PATCH 05/10] =?UTF-8?q?fix(kotlin-sdk):=20review=20round=20?= =?UTF-8?q?=E2=80=94=20the=20reconcile=20mutates=20nothing=20it=20cannot?= =?UTF-8?q?=20prove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thepastaclaw review round on #4439, all four blockers: - The spent-flip is demoted to LOG-ONLY (wouldFlipSpent): the engine's spent set records every input of every recorded transaction including MEMPOOL spends, with no context — persisting the flip would settle an unconfirmed spend, contradicting this handler's own in-block gating. Re-arm as a mutation only when the engine exports spends with context. - upsertUtxoRow reports whether it wrote: a globally-swept-parent refusal is now visible to the reconcile (skippedSwept), which no longer counts phantom heals nor flags netAmounts for rows that were never inserted. - The netAmount repair is demoted to LOG-ONLY (netAmountSuspects): a corrective record callback can land while its TXO delivery races this sweep, and blind addition double-credits. The event pipeline owns net correctness; the reconcile reports the suspicion. - The restore-time pool repair announces every pool it cannot repair (DashPay contact pools have no public key source by design and re-derive through DashPay sync; hardened pools cannot be publicly derived). Plus the review suggestions: contact-row exclusion now resolves ownership through coreAddressId -> core_addresses.accountId (production rows leave txos.accountId null, so the accountId-only check was ineffective), the neither-inventory log names the finalized-drop ambiguity, and the JNI spent-outpoint export uses the canonical OutPointFFI conversion. The reconcile is now fully observe-and-heal-forward: its only mutation is inserting provably-owned engine UTXOs. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 127 ++++++++++++------ .../PlatformWalletPersistenceHandlerTest.kt | 106 ++++++++++++--- .../src/manager_diagnostics.rs | 8 +- .../rs-platform-wallet-ffi/src/persistence.rs | 22 ++- 4 files changed, 194 insertions(+), 69 deletions(-) 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 c744b0cd7a9..4475e3712c4 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 @@ -1075,7 +1075,7 @@ class PlatformWalletPersistenceHandler( isConfirmed: Boolean, isInstantLocked: Boolean, isLocked: Boolean, - ) { + ): Boolean { val outpoint = makeOutpoint(txid, vout) val parentTx = db.transactionDao().getByTxid(txid) // A globally-swept parent is a transaction Rust has already @@ -1099,7 +1099,7 @@ class PlatformWalletPersistenceHandler( // defensive here is correct: there is no record in flight to // attribute a resurrected output to. See // TransactionEntity.isGloballySwept. - if (parentTx?.isGloballySwept == true) return + if (parentTx?.isGloballySwept == true) return false // Ensure a parent transaction row exists (stub if missing, so // the TXO FK holds; the real tx upsert overwrites it later). if (parentTx == null) { @@ -1209,6 +1209,7 @@ class PlatformWalletPersistenceHandler( } for (p in pending) db.documentDao().deletePendingInput(p) } + return true } /** @@ -1222,15 +1223,26 @@ class PlatformWalletPersistenceHandler( val engineUtxos: Int, val inserted: Int, val insertedDuffs: Long, - val netAmountRepairs: Int, + /** 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, val skippedImmature: Int, val skippedNoAddress: Int, val accountErrors: Int, - /** Store rows marked unspent whose outpoint the engine knows was - * spent — the lost-spend-update class (dashpay/platform#4425); - * flipped to spent in place. */ - val flippedSpent: Int = 0, - val flippedSpentDuffs: Long = 0, + /** 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, + /** Engine UTXOs whose insert the shared discipline refused (their + * parent transaction is globally swept) — excluded from + * [inserted], never netAmount-affecting. */ + val skippedSwept: Int = 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 @@ -1299,11 +1311,12 @@ class PlatformWalletPersistenceHandler( val accountErrors = root["errors"]?.jsonArray?.size ?: 0 var inserted = 0 var insertedDuffs = 0L - var netAmountRepairs = 0 + var netAmountSuspects = 0 var skippedImmature = 0 var skippedNoAddress = 0 - var flippedSpent = 0 - var flippedSpentDuffs = 0L + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var skippedSwept = 0 var wouldRemove = 0 var wouldRemoveDuffs = 0L var skippedForeign = 0 @@ -1351,9 +1364,7 @@ class PlatformWalletPersistenceHandler( val scriptPubKey = row["scriptHex"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() val isLocked = row["isLocked"]?.jsonPrimitive?.boolean ?: false - // netAmount repair decision BEFORE the insert stubs a row. - val priorTx = database.transactionDao().getByTxid(txid) - upsertUtxoRow( + val wrote = upsertUtxoRow( database, walletId, txid, vout, amount, address, scriptPubKey, height, isCoinbase = false, @@ -1361,12 +1372,31 @@ class PlatformWalletPersistenceHandler( isInstantLocked = false, isLocked = isLocked, ) + if (!wrote) { + // The shared insert discipline refused (globally-swept + // parent). Counting it as healed — or repairing a + // netAmount for it — would falsify the report. + skippedSwept++ + continue + } 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()) { - if (database.transactionDao().addToNetAmount(txid, amount) > 0) { - netAmountRepairs++ - } + 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}", + ) } } @@ -1380,6 +1410,16 @@ class PlatformWalletPersistenceHandler( .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } .map { it.id } .toSet() + // 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 + } @Suppress("NAME_SHADOWING") val storeRows = database.txoDao().observeByWallet(walletId).first() // Case 3 (log-only): rows marked spent for coins the engine @@ -1388,7 +1428,7 @@ class PlatformWalletPersistenceHandler( // un-marked, only reported. for (row in storeRows) { if (!row.isSpent) continue - if (row.accountId != null && row.accountId in foreignAccountIds) continue + if (rowIsForeign(row)) continue val key = "${row.txid?.toHex() ?: continue}:${row.vout}" if (key in engineUnspentKeys) { stuckSpent++ @@ -1403,7 +1443,7 @@ class PlatformWalletPersistenceHandler( } val storeUnspent = storeRows.filter { !it.isSpent } for (row in storeUnspent) { - if (row.accountId != null && row.accountId in foreignAccountIds) { + if (rowIsForeign(row)) { skippedForeign++ continue } @@ -1411,18 +1451,23 @@ class PlatformWalletPersistenceHandler( when { key in engineUnspentKeys -> {} key in engineSpentKeys -> { - // Lost spend update (#4425): the engine knows this - // coin was spent; the row missed the flip. Flip in - // place — spendingTxid stays as-is (usually null; - // the spender's row, if it ever arrives, relinks - // via the deferred-input drain). - database.txoDao().upsert(row.copy(isSpent = true)) - flippedSpent++ - flippedSpentDuffs += row.amount + // 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: flipped lost-spend row to spent " + - "outpoint=$key amount=${row.amount}", + "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 -> { @@ -1435,9 +1480,11 @@ class PlatformWalletPersistenceHandler( wouldRemoveDuffs += row.amount Log.w( TAG, - "txos reconcile: store row unknown to engine " + - "(swept/abandoned residue?) outpoint=$key " + - "amount=${row.amount} — LOG-ONLY, not removed", + "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", ) } } @@ -1448,26 +1495,28 @@ class PlatformWalletPersistenceHandler( engineUtxos = utxos.size, inserted = inserted, insertedDuffs = insertedDuffs, - netAmountRepairs = netAmountRepairs, + netAmountSuspects = netAmountSuspects, skippedImmature = skippedImmature, skippedNoAddress = skippedNoAddress, accountErrors = accountErrors, - flippedSpent = flippedSpent, - flippedSpentDuffs = flippedSpentDuffs, + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + skippedSwept = skippedSwept, wouldRemove = wouldRemove, wouldRemoveDuffs = wouldRemoveDuffs, skippedForeign = skippedForeign, stuckSpent = stuckSpent, stuckSpentDuffs = stuckSpentDuffs, ) - if (inserted > 0 || accountErrors > 0 || flippedSpent > 0 || wouldRemove > 0 || - stuckSpent > 0 + if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || + stuckSpent > 0 || skippedSwept > 0 ) { Log.w( TAG, "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + - "$netAmountRepairs netAmount repair(s), " + - "flippedSpent=$flippedSpent ($flippedSpentDuffs duffs), " + + "$netAmountSuspects netAmount suspect(s) (log-only), " + + "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + + "skippedSwept=$skippedSwept, " + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + "engine=${report.engineUtxos} " + 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 66f8b377467..24d7bb60945 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 @@ -5077,7 +5077,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(1, report.inserted) assertEquals(989_009_773L, report.insertedDuffs) - assertEquals(1, report.netAmountRepairs) + assertEquals(1, report.netAmountSuspects) val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) assertNotNull(row) @@ -5085,10 +5085,12 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(989_009_773L, row.amount) assertTrue(row.isConfirmed) - // -10.00010000 + 9.89009773 = -0.11000227 — history now matches - // what the engine (and dashj) report for this send. + // 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( - -11_000_227L, + -1_000_010_000L, db.transactionDao().getByTxid(changeTxid)!!.netAmount, ) } @@ -5505,9 +5507,9 @@ class PlatformWalletPersistenceHandlerTest { val second = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) assertEquals(0, second.inserted) - assertEquals(0, second.netAmountRepairs) + assertEquals(0, second.netAmountSuspects) assertEquals( - -11_000_227L, + -1_000_010_000L, db.transactionDao().getByTxid(changeTxid)!!.netAmount, ) } @@ -5562,10 +5564,11 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun reconcileFlipsLostSpendRowToSpent() = runTest { - // A store row still marked unspent for a coin the engine knows was - // spent — the spend update never reached the store - // (dashpay/platform#4425). + 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, @@ -5575,21 +5578,42 @@ class PlatformWalletPersistenceHandlerTest { engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), tipHeight = reconcileTip, ) - assertEquals(1, report.flippedSpent) - assertEquals(500_000L, report.flippedSpentDuffs) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) assertEquals(0, report.wouldRemove) val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! - assertTrue(row.isSpent) + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) assertNull(row.spendingTxid) + } - // Idempotent: the row is spent now, the reverse pass only reads - // unspent rows. - val second = handler.reconcileTxos( + @Test + fun reconcileDoesNotCountSweptRefusalsAsHeals() = runTest { + // The engine offers a UTXO whose parent transaction the store holds + // globally swept: the shared insert discipline refuses the row, and + // the reconcile must not count a heal that did not happen — nor + // flag its netAmount. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + isGloballySwept = true, + ), + ) + val report = handler.reconcileTxos( walletId, - engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), tipHeight = reconcileTip, ) - assertEquals(0, second.flippedSpent) + assertEquals(0, report.inserted) + assertEquals(0L, report.insertedDuffs) + assertEquals(0, report.netAmountSuspects) + assertEquals(1, report.skippedSwept) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1))) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) } @Test @@ -5608,7 +5632,7 @@ class PlatformWalletPersistenceHandlerTest { ) assertEquals(1, report.wouldRemove) assertEquals(250_000L, report.wouldRemoveDuffs) - assertEquals(0, report.flippedSpent) + assertEquals(0, report.wouldFlipSpent) val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! assertFalse(row.isSpent) assertEquals(250_000L, row.amount) @@ -5629,7 +5653,7 @@ class PlatformWalletPersistenceHandlerTest { """"address":"yTestAddr","scriptHex":"51",""" + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" val report = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) - assertEquals(0, report.flippedSpent) + assertEquals(0, report.wouldFlipSpent) assertEquals(0, report.wouldRemove) assertEquals(1, report.skippedImmature) assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 5))!!.isSpent) @@ -5666,6 +5690,48 @@ class PlatformWalletPersistenceHandlerTest { 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.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + } + @Test fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { // A row marked spent while the engine lists the coin unspent: either diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index cca3345a1e2..2e4f6eaee9c 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -652,13 +652,7 @@ pub unsafe extern "C" fn platform_wallet_account_spent_outpoints( if rows.is_empty() { return PlatformWalletFFIResult::ok(); } - let entries: Vec = rows - .into_iter() - .map(|op| OutPointFFI { - txid: txid_to_array(&op.txid), - vout: op.vout, - }) - .collect(); + 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; diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 3697235c4f1..e940ed0133a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4459,9 +4459,25 @@ unsafe fn restore_core_address_pools( // up to the persisted watermark. Never fatal: a failed // repair restores exactly what the rows carried (the // pre-repair behavior). - if !matches!(key_source, key_wallet::KeySource::NoKeySource) - && !matches!(pool_type, AddressPoolType::AbsentHardened) - { + 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) => {} From f5556576fac96f2a003a1667ed10dbb4e118a46c Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 25 Aug 2026 15:25:47 -0700 Subject: [PATCH 06/10] fix(kotlin-sdk): the reconcile insert pass must not heal contact-account coins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foreign exclusion lived only on the reverse pass: store rows were checked against the watch-only DIP-15 external accounts, but the insert pass healed every engine-inventory UTXO the store lacked — and the engine's inventory export includes those accounts' coins (it tracks them to show payments TO contacts; they are the contact's money). Device evidence (fresh restore of a 7,300-tx CoinJoin wallet, 2026-08-25): the post-backfill reconcile healed 12 contact-payment coins (5,692,493 duffs) into the store as ownerless rows while the reverse pass counted the very same rows as foreign — and the mirror-reload path hands such store rows back to the engine at the next launch. Hoist the foreign-account resolution above the insert pass and skip any engine UTXO whose address resolves (via core_addresses.accountId, the same second path rowIsForeign uses) to an external account, counting it as foreign rather than healed. An unresolvable address is not provably foreign and proceeds, keeping the pass's provable-only discipline symmetric. Also un-escape the netAmount-suspect log template, which printed literal "${txid.toHex()}:$vout" instead of values. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 46 +++++++++++++---- .../PlatformWalletPersistenceHandlerTest.kt | 50 +++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) 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 4475e3712c4..08a97f2de05 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 @@ -1342,6 +1342,32 @@ class PlatformWalletPersistenceHandler( } callbackExclusion.withLock { database.withTransaction { + // Watch-only DIP-15 contact (external) accounts, resolved up + // front because BOTH passes need the exclusion. 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() + // 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 @@ -1354,6 +1380,10 @@ class PlatformWalletPersistenceHandler( skippedNoAddress++ continue } + if (addressIsForeign(address)) { + skippedForeign++ + continue + } val txid = row["txid"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() val vout = row["vout"]?.jsonPrimitive?.int ?: continue if (txid.size != 32) continue @@ -1392,24 +1422,18 @@ class PlatformWalletPersistenceHandler( netAmountSuspects++ Log.w( TAG, - "txos reconcile: healed TXO ${'$'}{txid.toHex()}:${'$'}vout " + - "(${'$'}amount duffs) has a pre-existing record whose " + + "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}", + "storedNet=${priorTx.netAmount}", ) } } // ── Reverse pass: classify store rows the engine disagrees // with (the widened scope from the #4425 / pre-#971 review). - // Watch-only DIP-15 contact rows are excluded up front: the - // engine's own accounts never report them, so their absence - // from both inventories is expected. - val foreignAccountIds = database.accountDao() - .observeByWallet(walletId).first() - .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } - .map { it.id } - .toSet() + // 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 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 24d7bb60945..5d84678b582 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 @@ -5732,6 +5732,56 @@ class PlatformWalletPersistenceHandlerTest { 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.reconcileTxos(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 reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { // A row marked spent while the engine lists the coin unspent: either From 7c1abcbbf8a2a5668df72090100d139819c82921 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 25 Aug 2026 16:07:23 -0700 Subject: [PATCH 07/10] chore: bump the rust-dashcore pin to the #979 branch merged with dev (a5d7ea0b) The #979 engine fixes and #974's coalesced committed-range sweeps now live on one rev: fix/key-wallet-rescan-changeset merged with dev, with the durable pending-sweep re-keyed to the coalesced model (manager-level swept-awaiting-commit receipt, resume-only seeding). Replaces the two superseded pins this branch carried (6768f983, 9a68e652). The rev also carries rust-dashcore #981 (Mnemonic::from_phrase is now the auto-detecting parse), so the three parse_mnemonic_any_language wordlist walks (wallet_lifecycle, rs-platform-wallet-ffi derivation + identity_keys_from_mnemonic, rs-sdk-ffi signer_simple) collapse to thin delegates and the language-tagged test call sites drop the argument. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 46 ++++++++-------- Cargo.toml | 16 +++--- .../rs-platform-wallet-ffi/src/derivation.rs | 25 +++------ .../src/identity_keys_from_mnemonic.rs | 25 +++------ .../src/manager/accessors.rs | 4 +- .../src/manager/dashpay_sync.rs | 4 +- .../src/manager/wallet_lifecycle.rs | 48 ++++++----------- .../rs-platform-wallet/src/test_support.rs | 8 +-- .../identity/network/contact_requests.rs | 10 ++-- .../src/wallet/identity/network/discovery.rs | 4 +- .../identity/network/identity_handle.rs | 6 +-- .../src/wallet/identity/network/invitation.rs | 4 +- .../src/wallet/identity/network/loading.rs | 6 +-- .../src/wallet/identity/network/payments.rs | 54 +++++++++---------- .../wallet/identity/network/seed_binding.rs | 4 +- .../src/wallet/provider_key_at_index.rs | 6 +-- packages/rs-sdk-ffi/src/signer_simple.rs | 27 +++------- 17 files changed, 120 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8b0122484b..ba1d5339eae 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.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" 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=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" dependencies = [ "bincode", "dashcore-private", @@ -2493,7 +2493,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2554,7 +2554,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2923,7 +2923,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" [[package]] name = "glob" @@ -3858,7 +3858,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4114,7 +4114,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" dependencies = [ "aes", "async-trait", @@ -4143,7 +4143,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4159,7 +4159,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=090faea22494b2b9d6d3995e78f87b8e2a3bd5be#090faea22494b2b9d6d3995e78f87b8e2a3bd5be" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a5d7ea0b84694a3d097209a2c1647b86eee0fec0#a5d7ea0b84694a3d097209a2c1647b86eee0fec0" dependencies = [ "async-trait", "bincode", @@ -4670,7 +4670,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5770,7 +5770,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6578,7 +6578,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6591,7 +6591,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6650,7 +6650,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7510,7 +7510,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8959,7 +8959,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bcf2fd57918..36f8074dbb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "090faea22494b2b9d6d3995e78f87b8e2a3bd5be" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "a5d7ea0b84694a3d097209a2c1647b86eee0fec0" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/derivation.rs b/packages/rs-platform-wallet-ffi/src/derivation.rs index bf34953c3ec..e4813d1857b 100644 --- a/packages/rs-platform-wallet-ffi/src/derivation.rs +++ b/packages/rs-platform-wallet-ffi/src/derivation.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use dashcore::secp256k1::Secp256k1; use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -14,24 +14,11 @@ use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_result_or_return}; fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Since rust-dashcore #981, `Mnemonic::from_phrase` IS the + // auto-detecting parse — the wordlist walk this helper used to do + // itself now lives upstream. + Mnemonic::from_phrase(phrase) + .map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index ef4ad2bf93d..0010d8ac2c6 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -55,24 +55,11 @@ pub(crate) unsafe fn zeroize_and_free_row(row: &mut IdentityKeyPreviewFFI) { /// Parse a BIP-39 mnemonic against every supported wordlist. pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Since rust-dashcore #981, `Mnemonic::from_phrase` IS the + // auto-detecting parse — the wordlist walk this helper used to do + // itself now lives upstream. + Mnemonic::from_phrase(phrase) + .map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Resolve a wallet's BIP-39 mnemonic via a Swift-owned diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 9b105afcad4..191ffb979d6 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1210,7 +1210,7 @@ fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { mod spv_rescan_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1258,7 +1258,7 @@ mod spv_rescan_tests { event_handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 7c1b45e1d7e..89949c1c70c 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -525,7 +525,7 @@ impl std::fmt::Debug for DashPaySyncManager { mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -586,7 +586,7 @@ mod tests { /// sync would skip. async fn register_test_wallet(manager: &Arc>) -> WalletId { let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index abd5bccb91a..6e4737a13d4 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dash_spv::chain::CheckpointManager; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -22,33 +22,15 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; -/// Parse a BIP-39 mnemonic against every supported wordlist in turn, -/// returning the first language that yields a valid mnemonic. +/// Parse a BIP-39 mnemonic in any supported wordlist. /// -/// `key_wallet::Mnemonic` only exposes language-tagged constructors, -/// so callers that take a user-supplied mnemonic must walk the -/// language list themselves to avoid rejecting non-English phrases as -/// "invalid English". BIP-39 wordlists are mutually exclusive per -/// phrase, so the first match is unambiguous. +/// Since rust-dashcore #981, `Mnemonic::from_phrase` IS the +/// auto-detecting parse — this helper used to walk the wordlists itself +/// because the upstream constructor was language-tagged. Kept as a thin +/// wrapper so the creation-path error message stays stable. fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + Mnemonic::from_phrase(phrase) + .map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], @@ -880,7 +862,7 @@ impl PlatformWalletManager

{ #[cfg(test)] mod scoped_wallet_id_tests { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -892,7 +874,7 @@ mod scoped_wallet_id_tests { fn wallet_id_for(network: Network) -> [u8; 32] { let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -908,7 +890,7 @@ mod scoped_wallet_id_tests { /// Mirrors the `register_wallet` derivation exactly. fn wallet_group_id_for(network: Network) -> [u8; 32] { let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -991,7 +973,7 @@ mod scoped_wallet_id_tests { mod register_wallet_duplicate_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1058,7 +1040,7 @@ mod register_wallet_duplicate_tests { let network = Network::Testnet; let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // First registration succeeds. `Some(0)` skips the SPV-tip @@ -1105,7 +1087,7 @@ mod remove_versus_recreate_tests { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1224,7 +1206,7 @@ mod remove_versus_recreate_tests { if already_fired { return; } - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // The real registration path: inner `WalletManager` first, diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 31c7abdf446..0f2ecfb61a1 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -573,7 +573,7 @@ pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, ) { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; // Canonical all-`abandon` BIP-39 test vector. @@ -591,7 +591,7 @@ pub async fn test_platform_wallet_manager() -> ( )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // `Some(0)` skips the SPV birth-height lookup so the create never hits the // network. @@ -639,9 +639,9 @@ pub(crate) async fn mnemonic_wallet_manager( ) { use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::ManagedWalletInfo; - use key_wallet::{Language, Mnemonic}; + use key_wallet::Mnemonic; - let mnemonic = Mnemonic::from_phrase(phrase, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(phrase).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic( mnemonic, Network::Testnet, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index c99018792cf..b145fbc9159 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4603,7 +4603,7 @@ mod contact_info_provider_tests { use crate::wallet::identity::crypto::contact_info::derive_contact_info_keys; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use key_wallet::bip32::KeyDerivationType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; // Canonical BIP-39 test mnemonic. @@ -4619,7 +4619,7 @@ mod contact_info_provider_tests { /// open round-trips. #[tokio::test] async fn contact_info_seal_open_matches_resident_derivation_at_real_auth_path() { - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -4702,7 +4702,7 @@ mod contact_info_provider_tests { async fn ecdh_shared_secret_returns_zeroizing_matching_resident_derivation() { use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -4804,7 +4804,7 @@ mod stamp_race_tests { use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use std::collections::BTreeMap; @@ -4841,7 +4841,7 @@ mod stamp_race_tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index ba68afb0c29..4b0dd32f8c5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -625,7 +625,7 @@ mod tests { use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; @@ -633,7 +633,7 @@ mod tests { abandon abandon abandon abandon abandon about"; fn test_master() -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master xpriv") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 4f0a3a51c1e..f82c1f846ed 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -479,7 +479,7 @@ impl IdentityWallet { mod tests { use super::*; use dpp::util::hash::ripemd160_sha256; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -497,7 +497,7 @@ mod tests { /// touches — the identity-auth derivation walks the master xpriv, /// not the per-account collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") @@ -508,7 +508,7 @@ mod tests { /// (`RootExtendedPrivKey::new_master(seed).to_extended_priv_key(network)` /// is byte-for-byte `ExtendedPrivKey::new_master(network, seed)`). fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index c44eecb6685..e27836960d2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -1490,7 +1490,7 @@ mod tests { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletError; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1562,7 +1562,7 @@ mod tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bdbde9b0850..24d907bef62 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -527,7 +527,7 @@ mod tests { }; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -545,7 +545,7 @@ mod tests { /// never touches — it walks the master xpriv, not the per-account /// collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") @@ -554,7 +554,7 @@ mod tests { /// The BIP-32 master node for [`TEST_MNEMONIC`] on `network` — the /// same node `derive_extended_private_key` reconstructs internally. fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index c084ea667a1..75125d1a56e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1498,7 +1498,7 @@ mod tests { use dpp::prelude::Identifier; use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1736,7 +1736,7 @@ mod tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1769,7 +1769,7 @@ mod tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1805,7 +1805,7 @@ mod tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1831,7 +1831,7 @@ mod tests { owner: &Identifier, contact: &Identifier, ) -> key_wallet::bip32::ExtendedPubKey { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet = key_wallet::wallet::Wallet::from_seed_bytes( @@ -2452,7 +2452,7 @@ mod tests { handler, )); let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -4627,7 +4627,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -4934,7 +4934,7 @@ mod tests { // The signer's seed (the faithful test stand-in derives from it). let seed = { let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); mnemonic.to_seed("") }; @@ -5075,7 +5075,7 @@ mod tests { let watched = Identifier::from([0x42; 32]); let contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -5199,7 +5199,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5341,7 +5341,7 @@ mod tests { } let provider = SeedCryptoProvider::from_seed( - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""), Network::Testnet, @@ -5414,7 +5414,7 @@ mod tests { ) .expect("auth path at the legacy key id"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -5561,7 +5561,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5789,7 +5789,7 @@ mod tests { let (manager, _persister, wallet_id) = make_watch_only_wallet().await; let iw = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = iw.identity(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -5897,7 +5897,7 @@ mod tests { // so the send fails AFTER the drain has run. let pay_contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -6002,7 +6002,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6035,7 +6035,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6113,7 +6113,7 @@ mod tests { // The sending side, so the external-account lookup passes. let shared_key = [0x55u8; 32]; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let compact = { @@ -6198,7 +6198,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6231,7 +6231,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6312,7 +6312,7 @@ mod tests { .expect("register receiving account"); plant_receival_utxo(&manager, wallet_id, owner_id, contact_id, 0xC2, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6360,7 +6360,7 @@ mod tests { // broadcast (and its preceding used-flip persist). fund_bip44_account_0(&manager, wallet_id, 0xB7, 120_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6535,7 +6535,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6578,7 +6578,7 @@ mod tests { // broadcast (a funding-build failure returns before it). fund_bip44_account_0(&manager, wallet_id, 0xA1, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6768,7 +6768,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6828,7 +6828,7 @@ mod tests { let funded = amount + 526; fund_bip44_account_0(&manager, wallet_id, 0xA1, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6883,7 +6883,7 @@ mod tests { let funded = amount + 1226; fund_bip44_account_0(&manager, wallet_id, 0xB2, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index 8ba700b191c..17d51ae1a48 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -121,7 +121,7 @@ mod tests { use super::SeedBindingVerification; use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -167,7 +167,7 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - Mnemonic::from_phrase(phrase, Language::English) + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 629a7c574cc..8f37ba409ee 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -710,7 +710,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -728,13 +728,13 @@ mod tests { fn seed_bearing_wallet(network: Network) -> Wallet { let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction") } fn second_seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B, Language::English) + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B) .expect("valid test mnemonic B"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet B construction") diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 1eb97522e5a..e7747b0029b 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -28,26 +28,13 @@ use dash_async::block_on; pub(crate) fn parse_mnemonic_any_language( phrase: &str, ) -> Result { - use key_wallet::mnemonic::{Language, Mnemonic}; - - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + use key_wallet::mnemonic::Mnemonic; + + // Since rust-dashcore #981, `Mnemonic::from_phrase` IS the + // auto-detecting parse — the wordlist walk this helper used to do + // itself now lives upstream. + Mnemonic::from_phrase(phrase) + .map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Create a signer from a private key. From 4c6c6ec0b2f0db79d2440ab5f3c7865b219a949d Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 25 Aug 2026 16:46:05 -0700 Subject: [PATCH 08/10] fix(kotlin-sdk): stamp the resolved Room account on healed TXOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the inventory identifies each UTXO's owning account, but the heal ignored the tags — upsertUtxoRow preserved an existing accountId and set coreAddressId only when the address row already existed. A heal into a store that lost BOTH the TXO and its address row (the two divergence classes this PR repairs) inserted a row with neither ownership link, which buildUtxoRestoreData skips at the next mirror-reload — recreating the visible fund loss the heal repaired. The inventory export now emits the complete account tuple per row (registrationIndex, keyClass, and the DashPay identity ids when set, alongside the existing typeTag/standardTag/index), the reconcile resolves the Room account from it and passes the id through a new upsertUtxoRow parameter (existing rows' accountId still wins; changeset callbacks pass null and keep their address-projection behavior). A heal whose account cannot be resolved still lands but is surfaced as healedUnowned in the report and log line. The account tag also becomes the authoritative insert-pass foreign check: a watch-only external account's coin is skipped by its typeTag whether or not its address row survived persistence, with the address-based lookup kept as a fallback for untagged inventories. Co-Authored-By: Claude Fable 5 --- .../PlatformWalletPersistenceHandler.kt | 66 ++++++++++++++++- .../PlatformWalletPersistenceHandlerTest.kt | 70 +++++++++++++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 20 +++++- 3 files changed, 153 insertions(+), 3 deletions(-) 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 08a97f2de05..2ec6958d52a 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 @@ -1075,6 +1075,16 @@ class PlatformWalletPersistenceHandler( 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, ): Boolean { val outpoint = makeOutpoint(txid, vout) val parentTx = db.transactionDao().getByTxid(txid) @@ -1140,7 +1150,7 @@ class PlatformWalletPersistenceHandler( txid = txid, spendingTxid = existing?.spendingTxid, spendingInputIndex = existing?.spendingInputIndex, - accountId = existing?.accountId, + accountId = existing?.accountId ?: resolvedAccountId, coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), @@ -1229,6 +1239,11 @@ class PlatformWalletPersistenceHandler( * 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, @@ -1320,6 +1335,7 @@ class PlatformWalletPersistenceHandler( var wouldRemove = 0 var wouldRemoveDuffs = 0L var skippedForeign = 0 + var healedUnowned = 0 var stuckSpent = 0 var stuckSpentDuffs = 0L // Outpoint keys of BOTH engine inventories, for the reverse pass. @@ -1380,7 +1396,14 @@ class PlatformWalletPersistenceHandler( skippedNoAddress++ continue } - if (addressIsForeign(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 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 } @@ -1394,6 +1417,42 @@ class PlatformWalletPersistenceHandler( 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", + ) + } val wrote = upsertUtxoRow( database, walletId, txid, vout, amount, address, scriptPubKey, height, @@ -1401,6 +1460,7 @@ class PlatformWalletPersistenceHandler( isConfirmed = true, isInstantLocked = false, isLocked = isLocked, + resolvedAccountId = ownerAccountId, ) if (!wrote) { // The shared insert discipline refused (globally-swept @@ -1520,6 +1580,7 @@ class PlatformWalletPersistenceHandler( inserted = inserted, insertedDuffs = insertedDuffs, netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, skippedImmature = skippedImmature, skippedNoAddress = skippedNoAddress, accountErrors = accountErrors, @@ -1539,6 +1600,7 @@ class PlatformWalletPersistenceHandler( TAG, "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + "$netAmountSuspects netAmount suspect(s) (log-only), " + + "healedUnowned=$healedUnowned, " + "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + "skippedSwept=$skippedSwept, " + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + 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 5d84678b582..d070aa31243 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 @@ -5782,6 +5782,76 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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.reconcileTxos( + 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.reconcileTxos( + 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.reconcileTxos(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 diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 6274233d954..87649ef07ae 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3252,14 +3252,31 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w 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\":{}}}", + \"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, @@ -3267,6 +3284,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w hex_lower(script), u.height, u.is_locked, + identity_suffix, )); } unsafe { From d07a702138e8221fed48b51381085973418df8f6 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 26 Aug 2026 14:40:56 -0700 Subject: [PATCH 09/10] fix(platform-wallet-ffi): finish the #981 Mnemonic::from_phrase adaptation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin bump's adaptation to rust-dashcore#981 (from_phrase now auto-detects the wordlist) missed four test call sites in this file, so `cargo test -p platform-wallet-ffi` did not COMPILE on this branch — the crate's 279 tests never ran, here or in CI. Drops the now-removed Language argument and the import that only served it. No behavior change: the same static BIP-39 English vector parses. Co-Authored-By: Claude Opus 5 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e940ed0133a..ff9fd3e45ed 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7836,7 +7836,7 @@ mod tests { use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::Wallet; /// Regression: restored pool addresses must be tagged with the @@ -7985,7 +7985,6 @@ mod tests { // `account_collection_test.rs` uses. let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8019,7 +8018,6 @@ mod tests { fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8135,7 +8133,6 @@ mod tests { fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8578,7 +8575,6 @@ mod tests { fn account_xpub_survives_persist_restore_round_trip() { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); From bc0d859f8d5b2efa9e491086b763d3b5a7fca850 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 26 Aug 2026 14:41:19 -0700 Subject: [PATCH 10/10] fix(kotlin-sdk): bound the reconcile transport in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the reconcile pulled the engine's ENTIRE UTXO inventory across the boundary in one call, formatted it as one JSON string, and built HashSets over both engine inventories to classify store rows. A wallet's UTXO count is chain-controlled — anyone who knows a watched address can keep sending dust to it — so a remote party decided how much a phone allocated at every SYNCED transition and every 30-minute cadence tick. The pass meant to protect funds was itself the unbounded allocation. Both directions are now paged, and nothing bigger than one page is ever materialized, copied across JNI, parsed, or held as a set. Engine side, two new accessors next to the ones they bound: account_utxos_page_blocking takes (after, limit) and returns the page plus whether more follow — the account's UTXOs live in a BTreeMap keyed by outpoint, so a page is a partial select over the keys with no copy of the rows it skipped, and has_more is one lazy step past the page. classify_outpoints_blocking answers a batch of outpoints positionally (0 unknown / 1 unspent / 2 spent, unspent wins ties) by probing each account's UTXO map and borrowed spent set under ONE read lock, so its cost is the batch size times the account count, never an inventory size. walletManagerAllUtxosJson is replaced by walletManagerUtxosPageJson, returning {utxos,errors,cursor,hasMore} with the same per-row format including the account tuple. Accounts are swept in packed-tuple sort order rather than the get_account_balances ordinal: the sweep resumes across calls, so it needs an order a concurrently registered or removed account cannot shift underneath it — an ordinal cursor could skip or repeat data already paged. Page size is clamped natively (512 default, 4096 cap) rather than trusted from the host. The spent-outpoint export is no longer part of the inventory; walletManagerClassifyOutpoints replaces it, taking the flat n*36 blob that IS the Room txos.outpoint primary key, so the caller concatenates the column and reads verdicts back positionally. Handler side, reconcileTxos takes the two transports as lambdas plus a page size. The insert pass applies one engine page per Room transaction — many small commits instead of one giant one, which is safe precisely because the pass is insert-only and idempotent — and fetches pages outside the exclusion lock, since the lock exists to keep changeset callbacks out of our writes, not out of the engine. The reverse pass is inverted: it pages the STORE's own rows (new TxoDao.pageByWallet, an index walk on the outpoint primary key, so no row is visited twice or skipped when another is inserted mid-sweep) and asks the engine about one page at a time. Being read-only it now runs outside both the lock and any transaction. The engineUnspentKeys / engineSpentKeys HashSets are deleted. Every verdict, counter, and the single summary log line are unchanged. A page or classification batch that fails truncates the sweep instead of faulting it — whatever already landed is correct, and the rest waits for the next cadence tick — recorded in the new transportFailures counter. reconcileTxoStore prefetches page 0 so a dead transport still means "no report", the contract callers had before. Tests: 2 Rust accessor tests (pages cover the account exactly once, terminate, agree with the unpaged accessor, and stay empty for keys-only accounts; classification is positional at both edges) and 4 Kotlin (page walk, mid-sweep page failure keeping earlier heals, batched store classification, classification failure). A FakeEngine serves a whole-inventory blob as pages behind an opaque cursor, and the existing reconcile tests now run through it at 2 rows per page, so they exercise the cursor loop rather than a single page. Co-Authored-By: Claude Opus 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 62 +- .../PlatformWalletPersistenceHandler.kt | 627 +++++++++++------- .../dashsdk/persistence/dao/TxoDao.kt | 17 + .../dashsdk/wallet/PlatformWalletManager.kt | 62 +- .../PlatformWalletPersistenceHandlerTest.kt | 264 +++++++- .../src/core_wallet_types.rs | 14 + .../src/manager_diagnostics.rs | 175 ++++- .../src/manager/accessors.rs | 311 +++++++++ packages/rs-unified-sdk-jni/Cargo.toml | 2 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 451 +++++++++---- 10 files changed, 1537 insertions(+), 448 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 d6d07ff206f..556cfaa5839 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 @@ -129,24 +129,60 @@ internal object WalletManagerNative { external fun walletGetBalance(walletHandle: Long): LongArray /** - * The engine's full UTXO inventory for one wallet, every account, as - * JSON `{"utxos":[...],"errors":[...]}` — the source of truth the - * TXO-store reconciler ([PlatformWalletManager.reconcileTxoStore]) - * diffs against the Room `txos` mirror. 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 sweep. `network` is - * [org.dashfoundation.dashsdk.Network.ffiValue]. - */ - external fun walletManagerAllUtxosJson( + * 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 2ec6958d52a..68f66db6cb3 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 @@ -14,6 +14,8 @@ 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 @@ -1276,59 +1278,82 @@ class PlatformWalletPersistenceHandler( * 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 ([engineUtxosJson] — the - * `WalletManagerNative.walletManagerAllUtxosJson` payload). 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. + * 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. * - * 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. + * 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. + * `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. * - * Repairs `netAmount` alongside: a record born blind to one of its - * own outputs persisted `netAmount` short by exactly that output's - * value (verified against the job-flower dataset: -10.00010000 - * stored vs -0.11000227 true for tx 6cef55ab…). The bump applies - * only when the transaction row pre-exists with real bytes — a stub - * row created by this very insert has nothing to repair. + * `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 a Room transaction). + * [callbackExclusion] and runs Room transactions). */ suspend fun reconcileTxos( walletId: ByteArray, - engineUtxosJson: String, tipHeight: Int, minConfirmations: Int = 100, + pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, ): TxoReconcileReport { - val root = kotlinx.serialization.json.Json - .parseToJsonElement(engineUtxosJson).jsonObject - val utxos = root["utxos"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) - val spent = root["spent"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) - val accountErrors = root["errors"]?.jsonArray?.size ?: 0 + 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 skippedSwept = 0 @@ -1338,183 +1363,261 @@ class PlatformWalletPersistenceHandler( var healedUnowned = 0 var stuckSpent = 0 var stuckSpentDuffs = 0L - // Outpoint keys of BOTH engine inventories, for the reverse pass. - // The unspent side deliberately includes immature outputs the - // insert pass skips: a young coin present in both stores is - // consistent, not divergent. - val engineUnspentKeys = HashSet() - for (element in utxos) { - val row = element.jsonObject - val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() - val vout = row["vout"]?.jsonPrimitive?.int ?: continue - engineUnspentKeys.add("$txidHex:$vout") - } - val engineSpentKeys = HashSet() - for (element in spent) { - val row = element.jsonObject - val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() - val vout = row["vout"]?.jsonPrimitive?.int ?: continue - engineSpentKeys.add("$txidHex:$vout") - } - callbackExclusion.withLock { - database.withTransaction { - // Watch-only DIP-15 contact (external) accounts, resolved up - // front because BOTH passes need the exclusion. 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() - // 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", - ) - } - val wrote = upsertUtxoRow( - database, walletId, txid, vout, amount, address, scriptPubKey, - height, - isCoinbase = false, - isConfirmed = true, - isInstantLocked = false, - isLocked = isLocked, - resolvedAccountId = ownerAccountId, - ) - if (!wrote) { - // The shared insert discipline refused (globally-swept - // parent). Counting it as healed — or repairing a - // netAmount for it — would falsify the report. - skippedSwept++ - continue - } - 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}", - ) + 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", + ) + } + val wrote = upsertUtxoRow( + database, walletId, txid, vout, amount, address, scriptPubKey, + height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = isLocked, + resolvedAccountId = ownerAccountId, + ) + if (!wrote) { + // The shared insert discipline refused + // (globally-swept parent). Counting it as + // healed — or flagging a netAmount for it — + // would falsify the report. + skippedSwept++ + continue + } + 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}", + ) + } + } } } + } + if (!hasMore || nextCursor == null) break + cursor = nextCursor + } - // ── Reverse pass: classify store rows the engine disagrees - // with (the widened scope from the #4425 / pre-#971 review). - // 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 + // ── 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 } - @Suppress("NAME_SHADOWING") - val storeRows = database.txoDao().observeByWallet(walletId).first() - // Case 3 (log-only): 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. - for (row in storeRows) { - if (!row.isSpent) continue - if (rowIsForeign(row)) continue - val key = "${row.txid?.toHex() ?: continue}:${row.vout}" - if (key in engineUnspentKeys) { + 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( @@ -1524,59 +1627,53 @@ class PlatformWalletPersistenceHandler( "(lost release, or a live spend racing the engine)", ) } + continue } - val storeUnspent = storeRows.filter { !it.isSpent } - for (row in storeUnspent) { - if (rowIsForeign(row)) { - skippedForeign++ - 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", + ) } - val key = "${row.txid?.toHex() ?: continue}:${row.vout}" - when { - key in engineUnspentKeys -> {} - key in engineSpentKeys -> { - // 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", - ) - } + 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 = utxos.size, + engineUtxos = engineUtxos, inserted = inserted, insertedDuffs = insertedDuffs, netAmountSuspects = netAmountSuspects, @@ -1592,9 +1689,10 @@ class PlatformWalletPersistenceHandler( skippedForeign = skippedForeign, stuckSpent = stuckSpent, stuckSpentDuffs = stuckSpentDuffs, + transportFailures = transportFailures, ) if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || - stuckSpent > 0 || skippedSwept > 0 + stuckSpent > 0 || skippedSwept > 0 || transportFailures > 0 ) { Log.w( TAG, @@ -1607,11 +1705,12 @@ class PlatformWalletPersistenceHandler( "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + "engine=${report.engineUtxos} " + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + - "foreign=$skippedForeign accountErrors=$accountErrors — a non-zero " + + "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 (${utxos.size} engine UTXOs)") + Log.i(TAG, "txos reconcile: mirror consistent ($engineUtxos engine UTXOs)") } return report } @@ -4228,6 +4327,28 @@ class PlatformWalletPersistenceHandler( * 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/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index cb33aa46bc3..e085e2997f1 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 @@ -20,6 +20,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 202d26781c7..535e85d2647 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 @@ -1226,33 +1226,65 @@ class PlatformWalletManager( /** * Reconcile the Room `txos` mirror against the engine's live UTXO - * inventory, healing rows a changeset failed to deliver (and their - * transactions' `netAmount`). 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. + * 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; see [PlatformWalletPersistenceHandler.reconcileTxos] - * for why). Returns null when the engine inventory read failed. + * [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? { - val json = withContext(Dispatchers.IO) { + suspend fun page(cursor: String?, limit: Int): String? = withContext(Dispatchers.IO) { mapNativeErrors { - WalletManagerNative.walletManagerAllUtxosJson( - managerHandle, walletId, network.ffiValue, + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, network.ffiValue, cursor, limit, ) } - } ?: return null - return persistenceHandler.reconcileTxos(walletId, json, tipHeight, minConfirmations) + } + 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, + ) + } + } + }, + ) } /** 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 d070aa31243..ab3e58d27c7 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 @@ -4,6 +4,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 @@ -5069,7 +5073,7 @@ class PlatformWalletPersistenceHandlerTest { ), ) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), tipHeight = reconcileTip, @@ -5503,8 +5507,8 @@ class PlatformWalletPersistenceHandlerTest { ) val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) - handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) - val second = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, second.inserted) assertEquals(0, second.netAmountSuspects) @@ -5519,7 +5523,7 @@ class PlatformWalletPersistenceHandlerTest { // 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.reconcileTxos( + val fresh = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3), tipHeight = reconcileTip, @@ -5541,7 +5545,7 @@ class PlatformWalletPersistenceHandlerTest { val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! db.txoDao().upsert(seeded.copy(isSpent = true)) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), tipHeight = reconcileTip, @@ -5573,7 +5577,7 @@ class PlatformWalletPersistenceHandlerTest { walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, false, true, false, false, ) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), tipHeight = reconcileTip, @@ -5600,7 +5604,7 @@ class PlatformWalletPersistenceHandlerTest { isGloballySwept = true, ), ) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), tipHeight = reconcileTip, @@ -5625,7 +5629,7 @@ class PlatformWalletPersistenceHandlerTest { walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, false, true, false, false, ) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = emptyList()), tipHeight = reconcileTip, @@ -5652,7 +5656,7 @@ class PlatformWalletPersistenceHandlerTest { """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + """"address":"yTestAddr","scriptHex":"51",""" + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" - val report = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.wouldFlipSpent) assertEquals(0, report.wouldRemove) assertEquals(1, report.skippedImmature) @@ -5680,7 +5684,7 @@ class PlatformWalletPersistenceHandlerTest { val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!! db.txoDao().upsert(seeded.copy(accountId = foreignAccountId)) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = emptyList()), tipHeight = reconcileTip, @@ -5723,7 +5727,7 @@ class PlatformWalletPersistenceHandlerTest { val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! assertNull("production shape: accountId is null", seeded.accountId) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = emptyList()), tipHeight = reconcileTip, @@ -5771,7 +5775,7 @@ class PlatformWalletPersistenceHandlerTest { """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + """"address":"yContactPaid","scriptHex":"51",""" + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" - val report = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) assertEquals(0L, report.insertedDuffs) @@ -5805,7 +5809,7 @@ class PlatformWalletPersistenceHandlerTest { ), ) // Deliberately NO core_addresses row for this address. - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 11, amount = 70_000L, address = "yOrphanAddr"), tipHeight = reconcileTip, @@ -5824,7 +5828,7 @@ class PlatformWalletPersistenceHandlerTest { // 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.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 12, amount = 5_000L), tipHeight = reconcileTip, @@ -5845,7 +5849,7 @@ class PlatformWalletPersistenceHandlerTest { """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + """"address":"yContactNoRow","scriptHex":"51",""" + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" - val report = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) assertEquals(1, report.skippedForeign) @@ -5866,7 +5870,7 @@ class PlatformWalletPersistenceHandlerTest { val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!! db.txoDao().upsert(seeded.copy(isSpent = true)) - val report = handler.reconcileTxos( + val report = handler.reconcileFromInventory( walletId, engineInventoryJson( unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), @@ -5881,4 +5885,232 @@ class PlatformWalletPersistenceHandlerTest { 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 ce3e52845bf..ed0b3d2670e 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -30,6 +30,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 2e4f6eaee9c..8b81df4250b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -564,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 _; @@ -591,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, @@ -611,6 +614,138 @@ 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 diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 191ffb979d6..1eaac7bad5c 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}; @@ -207,6 +208,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 { @@ -829,6 +841,128 @@ 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 @@ -1206,6 +1340,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 dfe89a8e321..d07eabc98cb 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -18,7 +18,7 @@ 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 -# (walletManagerAllUtxosJson) — already in the graph via +# (walletManagerUtxosPageJson) — already in the graph via # platform-wallet-ffi, so this adds no new build cost. dashcore = { workspace = true } log = "0.4" diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 87649ef07ae..d43ecbbe090 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3152,29 +3152,119 @@ fn core_selection_strategy( } } -/// `platform_wallet_account_utxos` swept across every account — the -/// engine-side UTXO inventory `PlatformWalletManager.reconcileTxoStore` +/// 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). -/// Returns a JSON object `{"utxos":[...],"errors":[...]}` — one `utxos` -/// row per output the engine currently holds, tagged with its owning -/// account. Accounts are enumerated with the same `get_account_balances` -/// sweep the DashPay tab uses; keys-only accounts return no UTXOs and -/// contribute nothing. `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 sweep — the reconciler must still see every -/// account that DID read, so one faulted account cannot mask the others' -/// repair. +/// +/// 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_walletManagerAllUtxosJson( +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 { @@ -3186,6 +3276,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w 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 { @@ -3200,11 +3304,32 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w return ptr::null_mut(); } let mut rows: Vec = Vec::new(); - let mut spent_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) }; - for acc in accounts { + 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, @@ -3218,13 +3343,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }; 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( + 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) { @@ -3236,120 +3369,77 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w )); continue; } - if utxos.is_null() || utxo_count == 0 { - continue; - } - 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) + 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), + ); } - }; - 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, + )); } - 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, - )); - } - unsafe { - platform_wallet_ffi::platform_wallet_account_utxos_free( - utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, - utxo_count, - ) - }; - } - // Second inventory half: the engine's spent outpoints, so the - // reconcile can classify a store row still marked unspent — - // present here means the row lost its spend update - // (dashpay/platform#4425, flip it); present in neither - // inventory means swept/abandoned residue - // (pre-rust-dashcore#971 stores, log-only). Soft-fail like the - // UTXO loop: one bad account must not mask the rest. - for acc in accounts { - 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 outpoints: *const platform_wallet_ffi::OutPointFFI = ptr::null(); - let mut spent_count: usize = 0; - let res = unsafe { - platform_wallet_ffi::platform_wallet_account_spent_outpoints( - manager_handle as Handle, - wid.as_ptr(), - &spec, - &mut outpoints, - &mut spent_count, - ) - }; - 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 outpoints.is_null() || spent_count == 0 { - continue; + remaining -= utxo_count.min(remaining); + unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_free( + utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, + utxo_count, + ) + }; } - let items = unsafe { std::slice::from_raw_parts(outpoints, spent_count) }; - for op in items { - spent_rows.push(format!( - "{{\"txid\":\"{}\",\"vout\":{}}}", - hex_lower(&op.txid), - op.vout, - )); + 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_account_spent_outpoints_free( - outpoints as *mut platform_wallet_ffi::OutPointFFI, - spent_count, - ) - }; } } unsafe { @@ -3358,11 +3448,21 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w 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\":[{}],\"spent\":[{}],\"errors\":[{}]}}", + "{{\"utxos\":[{}],\"errors\":[{}],\"cursor\":{},\"hasMore\":{}}}", rows.join(","), - spent_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()) @@ -3370,9 +3470,100 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } +/// 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_walletManagerAllUtxosJson`] +/// [`Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson`] /// reports account faults in-band so the sweep keeps going. `None` on /// success. fn pwffi_error_message(