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/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..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 @@ -128,6 +128,61 @@ internal object WalletManagerNative { /** Balance as `long[4]` = {confirmed, unconfirmed, immature, locked}. */ external fun walletGetBalance(walletHandle: Long): LongArray + /** + * 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 f75bb977531..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 @@ -13,6 +13,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -1034,141 +1042,677 @@ 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, + // 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) + // 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 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) { + 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 ?: resolvedAccountId, + 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) + } + return true + } + + /** + * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] + * are the healed holes; a non-zero value after a completed sync means + * a changeset failed to deliver an owned output (the + * CoinJoin-funded-send change-drop class) and would have become a + * fund-loss on the next engine reload from this store. + */ + data class TxoReconcileReport( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + /** Healed TXOs whose pre-existing record's netAmount MAY be short by + * the healed amount. LOG-ONLY: the record can already carry the + * corrected net (a corrective callback racing this sweep), and + * blind addition double-credits. The event pipeline owns net + * correctness. */ + val netAmountSuspects: Int, + /** Healed rows whose owning Room account could not be resolved from + * the inventory's account tuple — ownership rides on the address + * projection alone, and if that row is also missing the healed TXO + * will not survive the next mirror-reload. */ + val healedUnowned: Int = 0, + val skippedImmature: Int, + val skippedNoAddress: Int, + val accountErrors: Int, + /** Store rows marked unspent whose outpoint the engine records as + * spent — the lost-spend-update class (dashpay/platform#4425). + * LOG-ONLY: the engine's spent set includes mempool spends with no + * context, so flipping would persist an unconfirmed spend as + * settled. */ + val wouldFlipSpent: Int = 0, + val wouldFlipSpentDuffs: Long = 0, + /** 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 + * by this pass. */ + val wouldRemove: Int = 0, + val wouldRemoveDuffs: Long = 0, + /** Watch-only DIP-15 contact rows excluded from classification — + * the engine's own accounts never report them, so their absence + * from both inventories is expected, not divergence. */ + val skippedForeign: Int = 0, + /** Store rows marked spent for a coin the engine lists UNSPENT — + * either a released coin from a swept transaction whose release + * event a pre-rust-dashcore#971 build lost, or a live spend the + * store wrote moments before the engine settled. The two cannot + * be told apart safely, so this is LOG-ONLY: un-marking a coin + * mid-payment would let the wallet double-spend it. */ + val stuckSpent: Int = 0, + val stuckSpentDuffs: Long = 0, + /** Transport reads that failed mid-sweep — an engine inventory page + * or an outpoint-classification batch that came back empty. The + * pass stops at the first one; whatever it already applied stands + * (insert-only, idempotent) and the rest waits for the next + * cadence tick. A persistently non-zero value means the sweep + * never finishes, so the report's other counters are a partial + * view. */ + val transportFailures: Int = 0, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop: a changeset that fails to + * deliver an owned output leaves a permanent hole, and because the + * engine is REBUILT from this mirror on restart (buildUtxoRestoreData), + * the hole graduates to a fund-loss on the next launch. Observed in the + * field as the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs. + * + * Both directions are BOUNDED, and deliberately so. A wallet's UTXO + * count is chain-controlled — anyone who knows a watched address can + * keep sending dust to it — so a pass that materialized the whole + * inventory would hand a remote party control over how much this + * process allocates on every SYNCED transition and every cadence tick. + * Instead: + * + * * [engineUtxoPage] hands back one bounded page of the engine's + * inventory at a time (`cursor` null to start, then the `cursor` the + * previous page returned while its `hasMore` is true), and each page + * is applied in its own Room transaction. A sweep is therefore many + * small commits rather than one giant one; that is the point, and it + * is safe because the pass is insert-only and idempotent. + * * The reverse direction pages the STORE's own rows and asks + * [classifyOutpoints] about one page at a time (0 unknown, 1 + * unspent, 2 spent), instead of pulling both engine inventories over + * and holding them as sets. + * + * Insert-only by design: rows the engine holds and the mirror lacks are + * added; rows the mirror holds and the engine lacks are LEFT ALONE (the + * mirror may legitimately be ahead — a live spend marks rows spent here + * before the engine's map settles — and it also carries watch-only + * contact outputs the engine's own accounts never report). Spent-state + * repair is deliberately out of scope; the reverse pass only classifies + * and logs. + * + * [minConfirmations] (default 100): the engine snapshot cannot carry + * `isCoinbase`/`isInstantLocked`, so inserted rows get + * `isConfirmed=true` and both flags false — inert for any output at or + * beyond coinbase maturity, which the gate guarantees. Fresher holes + * age into a later sweep. + * + * `netAmount` is reported, never repaired: a record born blind to one + * of its own outputs persisted a net short by exactly that output's + * value, but the record may equally have been corrected already by a + * callback racing this sweep, and blind addition double-credits. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs Room transactions). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, + ): TxoReconcileReport { + var engineUtxos = 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var accountErrors = 0 + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var skippedSwept = 0 + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var skippedForeign = 0 + var healedUnowned = 0 + var stuckSpent = 0 + var stuckSpentDuffs = 0L + var transportFailures = 0 + val limit = pageSize.coerceAtLeast(1) + + // Watch-only DIP-15 contact (external) accounts, resolved once up + // front because BOTH passes need the exclusion and the account set + // does not move under a sweep. The engine's UTXO inventory export + // includes these accounts' coins — it tracks them to show payments + // TO contacts — but they are the CONTACT's money and must never be + // healed into the store as ours. Before this check lived on the + // insert pass, a fresh restore's post-backfill reconcile healed + // every contact-payment coin into the store (12 rows / 0.05692493 + // tDASH on the large-wallet validation run of 2026-08-25) while the + // reverse pass — the only place the exclusion existed — dutifully + // counted the same rows as foreign. + val foreignAccountIds = database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + + // ── Insert pass: one engine page at a time, one Room transaction + // each. The page is fetched OUTSIDE the exclusion lock — the fetch + // is a native call into the engine, and the lock exists to keep + // changeset callbacks out of our writes, not out of the engine. + var cursor: String? = null + while (true) { + val pageJson = engineUtxoPage(cursor, limit) + if (pageJson == null) { + // The transport died mid-sweep. Everything already applied + // stands (insert-only, idempotent); the rest waits for the + // next cadence tick. + transportFailures++ + break + } + val page = kotlinx.serialization.json.Json + .parseToJsonElement(pageJson).jsonObject + val utxos = page["utxos"]?.jsonArray + ?: kotlinx.serialization.json.JsonArray(emptyList()) + accountErrors += page["errors"]?.jsonArray?.size ?: 0 + engineUtxos += utxos.size + val nextCursor = page["cursor"]?.jsonPrimitive?.contentOrNull + val hasMore = page["hasMore"]?.jsonPrimitive?.booleanOrNull ?: false + + if (utxos.isNotEmpty()) { + callbackExclusion.withLock { + database.withTransaction { + // Engine-side entries carry only an address; + // ownership resolves through core_addresses + // .accountId (the same second path rowIsForeign uses + // for store rows). An unresolvable address is NOT + // provably foreign — those proceed, keeping this + // pass's provable-only discipline symmetric: it + // neither mutates nor suppresses on guesswork. + suspend fun addressIsForeign(address: String): Boolean { + val owner = + database.coreAddressDao().getByAddress(address)?.accountId + return owner != null && owner in foreignAccountIds + } + for (element in utxos) { + val row = element.jsonObject + val height = row["height"]?.jsonPrimitive?.int ?: 0 + if (height <= 0 || tipHeight - height + 1 < minConfirmations) { + skippedImmature++ + continue + } + val address = row["address"]?.jsonPrimitive?.content.orEmpty() + if (address.isEmpty()) { + skippedNoAddress++ + continue + } + // The inventory tags every UTXO with its owning + // account tuple. The tag is the authoritative + // foreign check — a watch-only external + // account's coin is the CONTACT's money whether + // or not its address row survived persistence. + // The address-based check stays as a fallback + // for inventories predating the tagged export. + val typeTag = row["typeTag"]?.jsonPrimitive?.int ?: -1 + if (typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL || + addressIsForeign(address) + ) { + skippedForeign++ + continue + } + val txid = + row["txid"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + if (txid.size != 32) continue + if (database.txoDao() + .getByOutpoint(makeOutpoint(txid, vout)) != null + ) { + continue + } + val amount = row["amount"]?.jsonPrimitive?.long ?: 0L + val scriptPubKey = + row["scriptHex"]?.jsonPrimitive?.content.orEmpty() + .hexToByteArray() + val isLocked = row["isLocked"]?.jsonPrimitive?.boolean ?: false + // Resolve the Room account from the tuple and + // stamp it on the healed row. Ownership must not + // depend on the address projection: the two + // things persistence loses together are the TXO + // and its address row, and a healed row with + // neither link is skipped by the restore loader + // at the next mirror-reload — recreating the + // fund loss the heal repaired. + val ownerAccountId = if (typeTag >= 0) { + fetchAccount( + database, walletId, typeTag, + row["index"]?.jsonPrimitive?.int ?: 0, + row["standardTag"]?.jsonPrimitive?.int ?: 0, + row["registrationIndex"]?.jsonPrimitive?.int ?: 0, + row["keyClass"]?.jsonPrimitive?.int ?: 0, + row["userIdentityId"]?.jsonPrimitive?.content + ?.hexToByteArray() ?: ByteArray(32), + row["friendIdentityId"]?.jsonPrimitive?.content + ?.hexToByteArray() ?: ByteArray(32), + )?.id + } else { + null + } + if (ownerAccountId == null) { + // Heal anyway — the address projection may + // still attribute it — but surface the + // unresolved owner: if the address row is + // also gone, this row will not survive the + // next mirror-reload. + healedUnowned++ + Log.w( + TAG, + "txos reconcile: healing TXO with UNRESOLVED account " + + "(typeTag=$typeTag " + + "index=${row["index"]?.jsonPrimitive?.int} " + + "address=$address) — ownership rides on the " + + "address projection alone", + ) + } + 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}", + ) + } + } + } } - for (p in pending) db.documentDao().deletePendingInput(p) } + if (!hasMore || nextCursor == null) break + cursor = nextCursor } - 0 + + // ── Reverse pass: classify store rows the engine disagrees with + // (the widened scope from the #4425 / pre-#971 review). Inverted + // relative to the insert pass — it pages the STORE and asks the + // engine about each page — so that neither side has to hold a set + // over a whole inventory. + // + // Read-only by construction: it writes nothing, so it runs outside + // both the exclusion lock and any transaction. A row a concurrent + // callback moves under it is at worst a stale log line, and every + // verdict here is log-only anyway. + // + // Watch-only DIP-15 contact rows are excluded via the same + // `foreignAccountIds` the insert pass resolved above. Production + // changeset writes leave txos.accountId null and route ownership + // through coreAddressId -> core_addresses.accountId, so the + // exclusion must resolve BOTH paths — an accountId-only check + // silently classifies every contact row. + suspend fun rowIsForeign( + row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity, + ): Boolean { + if (row.accountId != null) return row.accountId in foreignAccountIds + val addr = row.coreAddressId ?: return false + val owner = database.coreAddressDao().getByAddress(addr)?.accountId + return owner != null && owner in foreignAccountIds + } + // An empty BLOB sorts before every real outpoint, so this starts at + // the first row. + var after = ByteArray(0) + while (true) { + val storeRows = database.txoDao().pageByWallet(walletId, after, limit) + if (storeRows.isEmpty()) break + after = storeRows.last().outpoint + + // Rows worth asking the engine about. A row still mid-insert + // (no txid yet) is not classifiable, and a foreign row's + // absence from the engine is expected rather than divergence — + // counted, as before, only when it is unspent. + val classifiable = + ArrayList( + storeRows.size, + ) + for (row in storeRows) { + if (row.txid == null || row.outpoint.size != OUTPOINT_BYTES) continue + if (rowIsForeign(row)) { + if (!row.isSpent) skippedForeign++ + continue + } + classifiable.add(row) + } + if (classifiable.isEmpty()) continue + + val blob = ByteArray(classifiable.size * OUTPOINT_BYTES) + for ((i, row) in classifiable.withIndex()) { + System.arraycopy(row.outpoint, 0, blob, i * OUTPOINT_BYTES, OUTPOINT_BYTES) + } + val verdicts = classifyOutpoints(blob) + if (verdicts == null || verdicts.size != classifiable.size) { + // No verdicts, no classification. Log-only either way, so + // the sweep stops rather than guessing. + transportFailures++ + break + } + for ((i, row) in classifiable.withIndex()) { + val verdict = verdicts[i] + val key = "${row.txid?.toHex()}:${row.vout}" + if (row.isSpent) { + // Rows marked spent for coins the engine still lists + // unspent. Either lost-release residue (pre-#971) or a + // live spend racing the engine — never un-marked, only + // reported: un-marking a coin mid-payment would let the + // wallet double-spend it. + if (verdict == OUTPOINT_CLASS_UNSPENT) { + stuckSpent++ + stuckSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row spent but engine lists it " + + "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + + "(lost release, or a live spend racing the engine)", + ) + } + continue + } + when (verdict) { + OUTPOINT_CLASS_UNSPENT -> {} + OUTPOINT_CLASS_SPENT -> { + // Lost spend update (#4425) — PROBABLY. The engine's + // spent set records every input of every recorded + // transaction, INCLUDING mempool spends, and carries + // no context; flipping the store on it would persist + // an unconfirmed spend as settled, contradicting + // this handler's own in-block gating (see + // onWalletChangesetUtxoSpent). LOG-ONLY until the + // engine exports spends with their confirmation + // context. + wouldFlipSpent++ + wouldFlipSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row unspent but the engine " + + "records a spend (context unknown, possibly " + + "mempool) outpoint=$key amount=${row.amount} — " + + "LOG-ONLY, not flipped", + ) + } + else -> { + // In NEITHER engine inventory: swept/abandoned + // residue (pre-#971 stores) — or an engine gap. + // Deliberately LOG-ONLY: removal by reconciliation + // is the one direction where a bug destroys + // user-visible data, so it stays observable-first. + wouldRemove++ + wouldRemoveDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row in neither engine " + + "inventory outpoint=$key amount=${row.amount} — " + + "ambiguous (swept/abandoned residue, or a " + + "finalized spend whose engine record was " + + "dropped) — LOG-ONLY, not removed", + ) + } + } + } + } + + val report = TxoReconcileReport( + engineUtxos = engineUtxos, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + accountErrors = accountErrors, + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + skippedSwept = skippedSwept, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + skippedForeign = skippedForeign, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, + transportFailures = transportFailures, + ) + if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || + stuckSpent > 0 || skippedSwept > 0 || transportFailures > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + + "$netAmountSuspects netAmount suspect(s) (log-only), " + + "healedUnowned=$healedUnowned, " + + "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + + "skippedSwept=$skippedSwept, " + + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + + "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + + "engine=${report.engineUtxos} " + + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + + "foreign=$skippedForeign accountErrors=$accountErrors " + + "transportFailures=$transportFailures — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", + ) + } else { + Log.i(TAG, "txos reconcile: mirror consistent ($engineUtxos engine UTXOs)") + } + return report } override fun onWalletChangesetUtxoSpent( @@ -3779,6 +4323,32 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { + /** `AccountTypeTagFFI::DashpayExternalAccount` — watch-only DIP-15 + * contact accounts the engine's inventories never report. */ + internal const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 + + /** Bytes of a `txos.outpoint` key: 32-byte txid (wire order) plus + * the vout as a little-endian `Int`. Also the wire format of the + * reconcile's outpoint-classification batch. */ + internal const val OUTPOINT_BYTES = 36 + + /** + * Rows per page in both directions of [reconcileTxos] — engine + * UTXOs coming in, store rows going out for classification. + * + * The number itself is not delicate; that there IS one is the + * point. Inventory size is chain-controlled, so an unpaged sweep + * would let anyone who knows a watched address decide how much a + * phone allocates at every SYNCED transition and cadence tick. + */ + const val TXO_RECONCILE_PAGE_SIZE = 512 + + /** [reconcileTxos] classification verdicts, mirroring + * `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`. */ + internal const val OUTPOINT_CLASS_UNKNOWN: Byte = 0 + internal const val OUTPOINT_CLASS_UNSPENT: Byte = 1 + internal const val OUTPOINT_CLASS_SPENT: Byte = 2 + internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 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/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 04bbcc2a314..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 @@ -1224,6 +1224,69 @@ class PlatformWalletManager( mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } } + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop, and the engine is REBUILT + * from it on restart — an unhealed hole becomes a fund-loss on the next + * launch (the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs). Insert-only; never flips spent state or + * deletes. + * + * Both directions of the sweep are paged, so neither this process nor + * the engine ever holds a whole wallet's inventory: UTXO counts are + * chain-controlled, and a periodic full-inventory read would let anyone + * who knows a watched address decide how much a phone allocates. See + * [PlatformWalletPersistenceHandler.reconcileTxos]. + * + * Call it after the L1 scan settles and again on a slow cadence; + * [tipHeight] is the synced chain height — only outputs at least + * [minConfirmations] deep are healed (immature holes age into the next + * sweep). Returns null when the engine inventory read failed at the + * FIRST page: there is nothing to reconcile against, so there is no + * report to make. A page or classification batch failing later + * truncates the sweep instead, which the report's + * `transportFailures` records. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? { + suspend fun page(cursor: String?, limit: Int): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, network.ffiValue, cursor, limit, + ) + } + } + val pageSize = PlatformWalletPersistenceHandler.TXO_RECONCILE_PAGE_SIZE + // Read the first page before entering the reconcile so a dead + // transport still means "no report", the contract callers had + // before the sweep was paged. The handler asks for the null cursor + // exactly once, so this page is spent, not re-read. + val firstPage = page(null, pageSize) ?: return null + return persistenceHandler.reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = { cursor, limit -> + if (cursor == null) firstPage else page(cursor, limit) + }, + classifyOutpoints = { outpoints -> + withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, outpoints, + ) + } + } + }, + ) + } + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -1932,6 +1995,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 +2009,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 +2448,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..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 @@ -5038,6 +5042,63 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isSweptTombstone) } + // ── TXO-store reconcile (the job-flower change-drop repair) ─────── + + private val changeTxid = ByteArray(32) { 7 } + private val reconcileTip = 1_536_950 + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isLocked":false}],"errors":[]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + // A send record born blind to its own change output: netAmount + // persisted as the full input value (the job-flower 6cef55ab… + // shape) and NO txos row for the change. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + ), + ) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + assertEquals(1, report.netAmountSuspects) + + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) + assertNotNull(row) + assertFalse(row!!.isSpent) + assertEquals(989_009_773L, row.amount) + assertTrue(row.isConfirmed) + + // The stored netAmount is NOT mutated: the record may already carry + // the corrected net (a corrective callback racing this sweep), and + // blind addition double-credits. The suspicion is logged; the event + // pipeline owns net correctness. + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + @Test fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { // A chained sweep that re-points a still-unfunded claim to a new @@ -5434,4 +5495,622 @@ class PlatformWalletPersistenceHandlerTest { chainLockHeightRound(handler, 600) assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } + + @Test + fun reconcileIsIdempotentAndNeverDoubleCredits() = runTest { + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1), + netAmount = -1_000_010_000L, + ), + ) + val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) + + handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, second.inserted) + assertEquals(0, second.netAmountSuspects) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + + @Test + fun reconcileSkipsImmatureOutputsAndPreservesSpentRows() = runTest { + // Immature: inside the 100-conf gate (flags on the engine snapshot + // can't carry coinbase/IS-lock, so fresh rows wait for a later + // sweep) — nothing inserted. + val fresh = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3), + tipHeight = reconcileTip, + ) + assertEquals(0, fresh.inserted) + assertEquals(1, fresh.skippedImmature) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 0))) + + // A row the mirror already holds — even marked spent while the + // engine still lists it — is left untouched: reconcile is + // insert-only and never flips spend state. + assertEquals( + 0, + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 2, 42L, "yTestAddr", byteArrayOf(0x51), 1_500_000, + false, true, false, false, + ), + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), + tipHeight = reconcileTip, + ) + assertEquals(0, report.inserted) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) + } + + /** Engine inventory JSON with both halves: unspent rows and spent outpoints. */ + private fun engineInventoryJson(unspent: List>, spent: List>): String { + val utxoRows = unspent.joinToString(",") { (txid, vout, amount) -> + """{"typeTag":0,"standardTag":0,"index":0,"txid":"$txid","vout":$vout,""" + + """"amount":$amount,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd",""" + + """"scriptHex":"76a914000088ac","height":1400000,"isLocked":false}""" + } + val spentRows = spent.joinToString(",") { (txid, vout) -> + """{"txid":"$txid","vout":$vout}""" + } + return """{"utxos":[$utxoRows],"spent":[$spentRows],"errors":[]}""" + } + + @Test + fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { + // A store row still marked unspent for a coin the engine records as + // spent (dashpay/platform#4425). The engine's spent set includes + // MEMPOOL spends and carries no context, so persisting the flip + // would settle an unconfirmed spend — counted and logged only. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) + assertNull(row.spendingTxid) + } + + @Test + fun 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.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + 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 + fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction (pre-rust-dashcore#971 + // stores). Counted and logged, NEVER removed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } + + @Test + fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // insert pass would skip as immature — are consistent, not + // divergence. Every reverse-pass counter must be zero. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + assertEquals(0, report.wouldFlipSpent) + assertEquals(0, report.wouldRemove) + assertEquals(1, report.skippedImmature) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 5))!!.isSpent) + } + + @Test + fun reconcileExcludesWatchOnlyContactRowsFromReversePass() = runTest { + // Watch-only DIP-15 contact rows are never in the engine's + // inventories; flagging them would be a false positive on every + // wallet with contact payments. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 0, + accountTypeName = "DashpayExternalAccount", + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 6, 1_230_000L, "yContactAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!! + db.txoDao().upsert(seeded.copy(accountId = foreignAccountId)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) + } + + @Test + fun reconcileResolvesContactOwnershipThroughCoreAddressId() = runTest { + // Production changeset writes leave txos.accountId null and route + // ownership through coreAddressId -> core_addresses.accountId. The + // exclusion must resolve that path, or every contact row gets + // classified as divergence. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 1, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactRouted", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/0", + isUsed = true, + accountId = foreignAccountId, + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 8, 990_000L, "yContactRouted", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! + assertNull("production shape: accountId is null", seeded.accountId) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + } + + @Test + fun reconcileInsertPassNeverHealsContactAccountCoins() = runTest { + // The engine's UTXO inventory export includes the watch-only DIP-15 + // external accounts' coins — it tracks them to show payments TO + // contacts, but they are the CONTACT's money. With the foreign + // exclusion living only on the reverse pass, a fresh restore's + // post-backfill reconcile healed every contact-payment coin into the + // store as an ownerless row (12 rows / 5,692,493 duffs on the + // 2026-08-25 large-wallet validation run) while the reverse pass + // counted the very same rows as foreign — and the mirror-reload path + // hands such rows back to the engine on the next launch. The insert + // pass must skip any engine UTXO whose address resolves to an + // external account, and count it as foreign, not healed. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 2, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactPaid", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/1", + isUsed = true, + accountId = foreignAccountId, + ), + ) + + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + + """"address":"yContactPaid","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(0L, report.insertedDuffs) + assertEquals(1, report.skippedForeign) + assertNull( + "the contact's coin must not enter the store", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 9)), + ) + } + + @Test + fun reconcileStampsResolvedAccountOnHealedRows() = runTest { + // The blocking scenario from review: persistence lost BOTH the TXO + // and its address row. The heal must resolve the owning Room account + // from the inventory's account tuple and stamp it on the inserted + // row — a healed row with neither accountId nor a resolvable address + // is skipped by the restore loader at the next mirror-reload, + // recreating the fund loss the heal repaired. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + // Production shape: onPersistAccountRegistration stores the FFI's + // 32-zero-byte identity ids verbatim (the entity ctor default of an + // EMPTY array never occurs on persisted rows). + val bip44Id = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 0, + accountIndex = 0, + accountTypeName = "standardBip44", + userIdentityId = ByteArray(32), + friendIdentityId = ByteArray(32), + ), + ) + // Deliberately NO core_addresses row for this address. + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 11, amount = 70_000L, address = "yOrphanAddr"), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(0, report.healedUnowned) + assertEquals( + "the healed row must carry the account resolved from the inventory tuple", + bip44Id, db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 11))!!.accountId, + ) + } + + @Test + fun reconcileCountsHealsWhoseAccountCannotBeResolved() = runTest { + // No matching Room account row at all (a store damaged past the + // account registrations): the heal proceeds — the address projection + // may still attribute it — but the unresolved owner is surfaced. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 12, amount = 5_000L), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(1, report.healedUnowned) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))!!.accountId) + } + + @Test + fun reconcileForeignSkipKeysOffTheInventoryTagWithoutAddressRow() = runTest { + // The tag is the authoritative foreign check: a contact's coin must + // be skipped even when its address row never survived persistence + // (the case the address-based fallback cannot see). + val json = + """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,""" + + """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + + """"address":"yContactNoRow","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(1, report.skippedForeign) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 13))) + } + + @Test + fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { + // A row marked spent while the engine lists the coin unspent: either + // a lost release event (pre-rust-dashcore#971) or a live spend the + // store wrote before the engine settled. Un-marking a coin + // mid-payment would let the wallet double-spend it, so this is + // counted and logged but NEVER changed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 7, 77_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson( + unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), + spent = emptyList(), + ), + tipHeight = reconcileTip, + ) + assertEquals(1, report.stuckSpent) + assertEquals(77_000L, report.stuckSpentDuffs) + assertTrue( + "the row must stay spent — un-marking is never done by reconciliation", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!!.isSpent, + ) + } + + // ── Paged reconcile transport ───────────────────────────────────── + + @Test + fun reconcileWalksEveryPageOfTheEngineInventory() = runTest { + // The engine inventory is chain-controlled in size, so the sweep + // reads it a page at a time. Every page must be applied — a sweep + // that healed only the first one would leave most of a damaged + // mirror unrepaired, and silently. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 5).map { Triple(changeTxid.toHexLower(), 20 + it, 1_000L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + + assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) + assertEquals(5, report.engineUtxos) + assertEquals(5, report.inserted) + assertEquals(5_000L, report.insertedDuffs) + for (vout in 20 until 25) { + assertNotNull( + "the row at vout=$vout must be healed whichever page carried it", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, vout)), + ) + } + } + + @Test + fun reconcileStopsAtAFailedPageAndKeepsWhatItAlreadyHealed() = runTest { + // A transport that dies mid-sweep must not discard the pages that + // already landed — the pass is insert-only and idempotent, so they + // are already correct — and must not report a clean run either. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 4).map { Triple(changeTxid.toHexLower(), 30 + it, 500L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = { cursor, limit -> + if (cursor == null) engine.page(cursor, limit) else null + }, + classifyOutpoints = engine::classify, + ) + + assertEquals(1, report.transportFailures) + assertEquals("only the page that arrived", 2, report.inserted) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 30))) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 32))) + } + + @Test + fun reconcileClassifiesStoreRowsInBoundedBatches() = runTest { + // The reverse direction is inverted: the STORE is paged and the + // engine is asked about one page at a time, so neither side builds + // a set over a whole inventory. Every batch must still be answered + // and counted. + for (vout in 40 until 43) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + + assertEquals("one classification batch per store page", 3, engine.batches) + assertEquals("every store row reached the classifier", 3, engine.classified) + assertEquals(3, report.wouldRemove) + assertEquals(300L, report.wouldRemoveDuffs) + } + + @Test + fun reconcileStopsWhenAClassificationBatchFails() = runTest { + // No verdicts, no classification: the reverse pass stops rather + // than guessing at rows it could not ask the engine about. + for (vout in 50 until 53) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = { null }, + ) + + assertEquals(1, report.transportFailures) + assertEquals(0, report.wouldRemove) + } + + /** + * Drive the paged reconcile from one whole-inventory JSON blob — the + * shape these tests describe an engine in, and the shape the native + * side used to hand over in a single unbounded call. + * + * The blob is served the way the transport now serves it: sliced into + * bounded pages behind an opaque cursor, with a separate positional + * classifier for the outpoints the store asks about. [pageSize] + * defaults to 2, so a test describing more than a couple of rows walks + * the real cursor loop rather than a single page. + */ + private suspend fun PlatformWalletPersistenceHandler.reconcileFromInventory( + walletId: ByteArray, + inventoryJson: String, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = 2, + ): PlatformWalletPersistenceHandler.TxoReconcileReport { + val engine = FakeEngine(inventoryJson) + return reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + } + + /** + * A stand-in for the engine's paged inventory transport, built from the + * whole-inventory JSON a test writes out. Pages come back behind an + * opaque ordinal cursor — the real cursor is opaque too, the handler + * only ever hands back what it was given — and classification answers + * positionally out of the same two inventories: 1 unspent, 2 spent, 0 + * neither. + */ + private class FakeEngine(inventoryJson: String) { + private val utxos: List + private val errors: List + private val unspentKeys: Set + private val spentKeys: Set + + /** Inventory pages served, classification batches answered, and + * outpoints classified across those batches. */ + var pages = 0 + private set + var batches = 0 + private set + var classified = 0 + private set + + init { + val root = kotlinx.serialization.json.Json + .parseToJsonElement(inventoryJson).jsonObject + utxos = root["utxos"]?.jsonArray?.map { it.jsonObject } ?: emptyList() + errors = root["errors"]?.jsonArray?.toList() ?: emptyList() + unspentKeys = utxos.map { + key( + it["txid"]!!.jsonPrimitive.content, + it["vout"]!!.jsonPrimitive.int, + ) + }.toSet() + spentKeys = (root["spent"]?.jsonArray?.toList() ?: emptyList()).map { + key( + it.jsonObject["txid"]!!.jsonPrimitive.content, + it.jsonObject["vout"]!!.jsonPrimitive.int, + ) + }.toSet() + } + + fun page(cursor: String?, limit: Int): String { + pages++ + val start = cursor?.toInt() ?: 0 + val slice = utxos.drop(start).take(limit) + val next = start + slice.size + val hasMore = next < utxos.size + // Account read failures belong to the sweep, not to a page: the + // native side reports each faulted account once, so the fake + // puts them all on the first page. + val faults = if (start == 0) errors.joinToString(",") { it.toString() } else "" + return """{"utxos":[${slice.joinToString(",") { it.toString() }}],""" + + """"errors":[$faults],""" + + """"cursor":${if (hasMore) "\"$next\"" else "null"},"hasMore":$hasMore}""" + } + + fun classify(outpoints: ByteArray): ByteArray { + batches++ + val count = outpoints.size / OUTPOINT_SIZE + classified += count + val verdicts = ByteArray(count) + for (i in 0 until count) { + val base = i * OUTPOINT_SIZE + val txidHex = outpoints.copyOfRange(base, base + 32) + .joinToString("") { "%02x".format(it) } + var vout = 0 + for (b in 3 downTo 0) { + vout = (vout shl 8) or (outpoints[base + 32 + b].toInt() and 0xFF) + } + val k = key(txidHex, vout) + verdicts[i] = when { + k in unspentKeys -> 1 + k in spentKeys -> 2 + else -> 0 + } + } + return verdicts + } + + private fun key(txidHex: String, vout: Int) = "$txidHex:$vout" + + private companion object { + /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ + const val OUTPOINT_SIZE = 36 + } + } } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 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/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-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..8b81df4250b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -28,8 +28,9 @@ use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, + TrackedAssetLockEntryFFI, WalletIdentityRowFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -563,26 +564,7 @@ pub unsafe extern "C" fn platform_wallet_account_utxos( if rows.is_empty() { return PlatformWalletFFIResult::ok(); } - let entries: Vec = rows - .into_iter() - .map(|s| { - let script_len = s.script_pubkey.len(); - let script_ptr = if script_len == 0 { - std::ptr::null_mut() - } else { - Box::into_raw(s.script_pubkey.into_boxed_slice()) as *mut u8 - }; - AccountUtxoEntryFFI { - outpoint_txid: txid_to_array(&s.outpoint.txid), - outpoint_vout: s.outpoint.vout, - value_duffs: s.value_duffs, - script_pubkey: script_ptr, - script_pubkey_len: script_len, - height: s.height, - is_locked: s.is_locked, - } - }) - .collect(); + let entries: Vec = rows.into_iter().map(utxo_entry_ffi).collect(); let count = entries.len(); let boxed = entries.into_boxed_slice(); *out_utxos = Box::into_raw(boxed) as *const _; @@ -590,6 +572,28 @@ pub unsafe extern "C" fn platform_wallet_account_utxos( PlatformWalletFFIResult::ok() } +/// One snapshot row to its FFI entry, heap-owning the script bytes. Shared +/// by the paged and unpaged exports so their row shape — and the +/// `platform_wallet_account_utxos_free` contract both rely on — stays one +/// thing. +fn utxo_entry_ffi(s: AccountUtxoSnapshot) -> AccountUtxoEntryFFI { + let script_len = s.script_pubkey.len(); + let script_ptr = if script_len == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(s.script_pubkey.into_boxed_slice()) as *mut u8 + }; + AccountUtxoEntryFFI { + outpoint_txid: txid_to_array(&s.outpoint.txid), + outpoint_vout: s.outpoint.vout, + value_duffs: s.value_duffs, + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height: s.height, + is_locked: s.is_locked, + } +} + #[no_mangle] pub unsafe extern "C" fn platform_wallet_account_utxos_free( utxos: *mut AccountUtxoEntryFFI, @@ -610,6 +614,197 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); } +/// One outpoint-ordered page of an account's UTXO inventory — the bounded +/// form of `platform_wallet_account_utxos`. +/// +/// A wallet's UTXO count is chain-controlled (anyone who knows a watched +/// address can keep sending dust to it), so a periodic host-side audit +/// must never materialize the whole inventory at once. `after_txid` + +/// `after_vout` name the last outpoint of the previous page; pass a NULL +/// `after_txid` to start at the beginning. `limit` caps the rows returned +/// (0 means "no limit" — a paging caller should always pass a real cap), +/// and `out_has_more` reports whether further pages remain. +/// +/// Rows are freed with `platform_wallet_account_utxos_free`, the same +/// entry type and the same deallocator as the unpaged call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_utxos_page( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + after_txid: *const u8, + after_vout: u32, + limit: usize, + out_utxos: *mut *const AccountUtxoEntryFFI, + out_count: *mut usize, + out_has_more: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_utxos); + check_ptr!(out_count); + check_ptr!(out_has_more); + *out_utxos = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + // A NULL cursor is "from the beginning" — the only way to say it, since + // the all-zero txid is a legal (if unreachable) outpoint. + let after = if after_txid.is_null() { + None + } else { + let raw: [u8; 32] = std::ptr::read(after_txid as *const [u8; 32]); + Some(dashcore::OutPoint::from(&OutPointFFI { + txid: raw, + vout: after_vout, + })) + }; + let Some((rows, has_more)) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.account_utxos_page_blocking(&wid, &target, after, limit) + }) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows.into_iter().map(utxo_entry_ffi).collect(); + let count = entries.len(); + *out_utxos = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +/// Classify `count` outpoints against the wallet's live engine state, in +/// one pass under one read lock. `out_classes` receives a `count`-byte +/// buffer, positionally aligned with the input: 0 unknown, 1 unspent, 2 +/// spent (see `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`). +/// +/// The inverse direction of `platform_wallet_account_utxos_page`: a host +/// that mirrors the wallet's TXOs pages its OWN rows and asks about them +/// in batches, so neither side ever holds a full engine inventory. Cost is +/// the batch size times the account count, never the inventory size. +/// +/// `2` means some recorded transaction spends the outpoint — including one +/// still in the mempool. It is not proof of a settled spend. +/// +/// Free the verdicts with `platform_wallet_classify_outpoints_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_classify_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + outpoints: *const OutPointFFI, + count: usize, + out_classes: *mut *const u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_classes); + check_ptr!(out_count); + *out_classes = std::ptr::null(); + *out_count = 0; + if count == 0 { + return PlatformWalletFFIResult::ok(); + } + check_ptr!(outpoints); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let requested: Vec = std::slice::from_raw_parts(outpoints, count) + .iter() + .map(dashcore::OutPoint::from) + .collect(); + let Some(classes) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.classify_outpoints_blocking(&wid, &requested) + }) else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let len = classes.len(); + *out_classes = Box::into_raw(classes.into_boxed_slice()) as *const u8; + *out_count = len; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_classify_outpoints_free(classes: *mut u8, count: usize) { + if classes.is_null() || count == 0 { + return; + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(classes, count)); +} + +/// The account's spent-outpoint inventory — the second half of the +/// store-reconcile surface (`platform_wallet_account_utxos` is the unspent +/// half). A persistence-mirror row still marked unspent whose outpoint +/// appears here lost its spend update (dashpay/platform#4425); a row in +/// NEITHER inventory is swept/abandoned residue (pre-rust-dashcore#971 +/// stores). Free with `platform_wallet_account_spent_outpoints_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_outpoints); + check_ptr!(out_count); + *out_outpoints = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(rows) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.account_spent_outpoints_blocking(&wid, &target)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows.iter().map(OutPointFFI::from).collect(); + let count = entries.len(); + *out_outpoints = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints_free( + outpoints: *mut OutPointFFI, + count: usize, +) { + if outpoints.is_null() || count == 0 { + return; + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(outpoints, count)); +} + // --------------------------------------------------------------------------- // Phase 6 — Per-account transactions // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 02031fbea7d..ff9fd3e45ed 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,102 @@ unsafe fn restore_core_address_pools( } } } + // Resolve the pool's key source BEFORE taking the mutable pool + // borrow, for the hole-repair pass below. Degrades to NoKeySource + // for anything unresolvable (no signing wallet handle, provider + // pools without public derivation, etc.) — repair is then skipped. + let key_source = signing_wallet + .and_then(|wallet| { + key_wallet::transaction_checking::transaction_router::AccountTypeToCheck::try_from( + &*managed_type, + ) + .ok() + .map(|check_type| { + let account_index = match &account_type { + AccountType::Standard { + index, .. + } + | AccountType::CoinJoin { + index, + } + | AccountType::DashpayReceivingFunds { + index, .. + } + | AccountType::DashpayExternalAccount { + index, .. + } => Some(*index), + AccountType::IdentityTopUp { + registration_index, + } => Some(*registration_index), + _ => None, + }; + wallet.key_source_for_account_type(&check_type, account_index) + }) + }) + .unwrap_or(key_wallet::KeySource::NoKeySource); + let mut managed_pools = managed_type.address_pools_mut(); match managed_pools.iter_mut().find(|p| p.pool_type == pool_type) { Some(pool) => { pools_routed += infos.len(); restore_address_pool(pool, infos); + // Hole repair: mirrors have been observed dropping address + // rows (2026-08-19 field wallet: BIP44-change rows 875..=890 + // absent between surviving rows), and ingesting the sparse + // list as-is makes outputs paying the missing addresses + // permanently unrecognizable — a rescan-proof fund loss — + // while the row-derived `highest_generated` suppresses the + // gap-limit re-derivation that would repair it. Derivation + // is pure key arithmetic, so re-derive every missing index + // up to the persisted watermark. Never fatal: a failed + // repair restores exactly what the rows carried (the + // pre-repair behavior). + let repairable = !matches!(key_source, key_wallet::KeySource::NoKeySource) + && !matches!(pool_type, AddressPoolType::AbsentHardened); + if !repairable { + // Announce the skip instead of silently claiming full + // coverage. DashPay contact pools land here by design — + // `key_source_for_account_type` returns NoKeySource for + // both DashPay variants (their keys derive from identity + // material, not an account xpub) — and their pools are + // re-derived by DashPay contact sync at runtime, so a + // sparse restore self-heals through that path instead. + // Hardened pools cannot be publicly derived at all. + tracing::info!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + "load: address-pool hole repair skipped (no public key source); pool restored as persisted" + ); + } + if repairable { + if let Some(max_idx) = pool.highest_generated { + match pool.ensure_contiguous_to(max_idx, &key_source) { + Ok(0) => {} + Ok(filled) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + filled, + "load: repaired address-pool holes left by dropped \ + persisted rows; outputs paying these addresses are \ + recognizable again" + ); + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + error = %e, + "load: address-pool hole repair failed; pool restored \ + as persisted (sparse)" + ); + } + } + } + } } None => { pools_dropped += 1; @@ -4875,7 +4967,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), + )?; } } @@ -7738,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 @@ -7887,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(""); @@ -7921,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(""); @@ -8037,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(""); @@ -8112,7 +8207,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!( @@ -8480,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(""); 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 diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 34281333598..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,157 @@ impl PlatformWalletManager

{ .collect() } + /// One outpoint-ordered page of an account's UTXO inventory — the + /// bounded form of [`Self::account_utxos_blocking`], for callers that + /// must not hold a whole wallet's inventory at once. + /// + /// A wallet's UTXO count is chain-controlled: anyone who knows a + /// watched address can keep sending dust to it, so the full-inventory + /// read has no upper bound a mobile process can rely on. Pages solve + /// that: `after` is the last outpoint of the previous page (`None` + /// starts at the beginning), `limit` caps the rows returned, and the + /// returned flag says whether more rows follow. Because the account's + /// UTXOs live in a `BTreeMap` keyed by outpoint, a page is a partial + /// select over the keys — no intermediate copy of the rows the caller + /// skipped. + /// + /// `limit == 0` means "no limit", matching + /// [`Self::account_transactions_blocking`]; a paging caller should + /// always pass a real cap. + /// + /// The order is `OutPoint`'s own — deterministic, but neither + /// chronological nor the display order of a txid. Callers only need it + /// to be stable, which it is for as long as the account's UTXO set + /// does not change. A concurrent change between pages can drop a row + /// out of the sweep or repeat one; both are benign for the reconcile + /// this serves (insert-only, idempotent, and re-run on a cadence). + pub fn account_utxos_page_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + after: Option, + limit: usize, + ) -> (Vec, bool) { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return (Vec::new(), false); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return (Vec::new(), false); + }; + // Keys-only accounts (identity / asset-lock / provider) never + // carry UTXOs by construction — an empty page, never a partial one. + let Some(funds) = account.as_funds() else { + return (Vec::new(), false); + }; + let cursor = match after { + Some(outpoint) => (Bound::Excluded(outpoint), Bound::Unbounded), + None => (Bound::Unbounded, Bound::Unbounded), + }; + let mut iter = funds.utxos.range(cursor); + let take = if limit == 0 { usize::MAX } else { limit }; + let mut rows: Vec = Vec::new(); + for (_, utxo) in iter.by_ref().take(take) { + rows.push(AccountUtxoSnapshot { + outpoint: utxo.outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + height: utxo.height, + is_locked: utxo.is_locked, + }); + } + // One probe past the page rather than an over-fetch-and-truncate: + // `range` is lazy, so this costs a single tree step. + let has_more = iter.next().is_some(); + (rows, has_more) + } + + /// Classify each of `outpoints` against the wallet's live engine + /// state: `0` unknown, `1` unspent, `2` spent. The reverse half of the + /// store-reconcile transport — a persistence mirror pages its own rows + /// and asks about them in batches, instead of pulling both engine + /// inventories over and holding them as sets. + /// + /// The answer is per outpoint, looked up in each account's UTXO map + /// and spent set, so the cost is the batch size times the account + /// count — never the size of either inventory. Every account is + /// consulted under ONE read lock. + /// + /// Unspent wins a tie: an outpoint the engine still holds as a UTXO is + /// unspent whatever else references it. `2` means only that some + /// recorded transaction spends it — the spend may be in the mempool, + /// which is why callers must not treat it as settled. + /// + /// The returned vector is positional and always the same length as + /// `outpoints`; an unknown wallet classifies everything as `0`. + pub fn classify_outpoints_blocking( + &self, + wallet_id: &WalletId, + outpoints: &[OutPoint], + ) -> Vec { + let mut classes = vec![OUTPOINT_CLASS_UNKNOWN; outpoints.len()]; + if outpoints.is_empty() { + return classes; + } + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return classes; + }; + let accounts = info.core_wallet.accounts.all_accounts(); + for account in accounts.iter() { + let Some(funds) = account.as_funds() else { + continue; + }; + let spent = funds.spent_outpoints(); + for (slot, outpoint) in classes.iter_mut().zip(outpoints.iter()) { + if *slot == OUTPOINT_CLASS_UNSPENT { + // Already settled by an earlier account, and unspent is + // the strongest answer there is. + continue; + } + if funds.utxos.contains_key(outpoint) { + *slot = OUTPOINT_CLASS_UNSPENT; + } else if spent.contains(outpoint) { + *slot = OUTPOINT_CLASS_SPENT; + } + } + } + classes + } + + /// The outpoints this account knows were spent by recorded + /// transactions — the second half of the store-reconcile inventory + /// ([`Self::account_utxos_blocking`] is the unspent half). Lets a + /// persistence-mirror audit classify a store row marked unspent: + /// present here → the row lost its spend update (flip it, + /// dashpay/platform#4425); present in neither inventory → residue of a + /// swept/abandoned transaction (pre-rust-dashcore#971 stores). + pub fn account_spent_outpoints_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + let Some(funds) = account.as_funds() else { + return Vec::new(); + }; + funds.spent_outpoints().iter().copied().collect() + } + // ----------------------------------------------------------------- // Phase 6 — Per-account transactions // ----------------------------------------------------------------- @@ -1177,11 +1340,188 @@ 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; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1229,7 +1569,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. diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..d07eabc98cb 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -17,6 +17,10 @@ rs-sdk-ffi = { path = "../rs-sdk-ffi" } platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } key-wallet-ffi = { workspace = true } dash-network = { workspace = true, features = ["ffi"] } +# Address encoding for the reconcile sweep's engine-UTXO export +# (walletManagerUtxosPageJson) — already in the graph via +# platform-wallet-ffi, so this adds no new build cost. +dashcore = { workspace = true } log = "0.4" zeroize = "1" @@ -24,7 +28,6 @@ zeroize = "1" android_logger = "0.14" [dev-dependencies] -dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1cc5801db3e..d43ecbbe090 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3152,6 +3152,470 @@ fn core_selection_strategy( } } +/// Default page size for `walletManagerUtxosPageJson` when the caller +/// passes a non-positive `limit`, and the hard cap it clamps to. The +/// point of the paged transport is that neither side ever holds a whole +/// wallet's inventory, so the cap is enforced here rather than trusted +/// from the host. +const UTXO_PAGE_DEFAULT: usize = 512; +const UTXO_PAGE_MAX: usize = 4096; + +/// The account tuple, packed into one comparable key. Accounts are swept +/// in the order of this key rather than in the order +/// `get_account_balances` happens to return them: the sweep is resumable +/// across calls, so it needs an order that a concurrently registered or +/// removed account cannot shift underneath it. A new account sorting +/// before the cursor is missed by THIS sweep and picked up by the next; +/// one sorting after it is included. Neither can make the sweep skip or +/// repeat data it has already paged — which an ordinal cursor would. +fn account_sort_key(acc: &platform_wallet_ffi::AccountBalanceEntryFFI) -> [u8; 78] { + let mut key = [0u8; 78]; + key[0] = acc.type_tag as u8; + key[1] = acc.standard_tag as u8; + key[2..6].copy_from_slice(&acc.index.to_be_bytes()); + key[6..10].copy_from_slice(&acc.registration_index.to_be_bytes()); + key[10..14].copy_from_slice(&acc.key_class.to_be_bytes()); + key[14..46].copy_from_slice(&acc.user_identity_id); + key[46..78].copy_from_slice(&acc.friend_identity_id); + key +} + +/// Where a paged inventory sweep left off: the account it was inside and +/// the last outpoint it emitted from that account. +struct UtxoPageCursor { + account_key: [u8; 78], + txid: [u8; 32], + vout: u32, +} + +/// Parse `::`. The cursor is opaque to the +/// host — it only ever hands back what a previous page returned — so an +/// unparseable one restarts the sweep rather than failing it. +fn parse_utxo_page_cursor(raw: &str) -> Option { + let mut parts = raw.split(':'); + let key_hex = parts.next()?; + let txid_hex = parts.next()?; + let vout: u32 = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + let key_bytes = hex_bytes(key_hex)?; + let txid_bytes = hex_bytes(txid_hex)?; + let mut cursor = UtxoPageCursor { + account_key: [0u8; 78], + txid: [0u8; 32], + vout, + }; + if key_bytes.len() != cursor.account_key.len() || txid_bytes.len() != cursor.txid.len() { + return None; + } + cursor.account_key.copy_from_slice(&key_bytes); + cursor.txid.copy_from_slice(&txid_bytes); + Some(cursor) +} + +/// Lower-hex → bytes; `None` on odd length or a non-hex digit. +fn hex_bytes(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + let raw = hex.as_bytes(); + let mut out = Vec::with_capacity(raw.len() / 2); + for pair in raw.chunks(2) { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + out.push(((hi << 4) | lo) as u8); + } + Some(out) +} + +/// One bounded page of the engine's UTXO inventory across every account of +/// one wallet — the source of truth `PlatformWalletManager.reconcileTxoStore` +/// diffs against the Room `txos` mirror (dropped change outputs of +/// CoinJoin-funded sends leave the mirror short; the engine reloads from +/// that mirror on restart, so an un-reconciled hole becomes a fund-loss). +/// +/// Paged rather than swept whole because inventory size is +/// chain-controlled: anyone who knows a watched address can keep sending +/// dust outputs to it, and a periodic full-inventory read would let them +/// decide how much a phone allocates at every SYNCED transition and every +/// 30-minute pass. Here nothing bigger than one page is ever formatted, +/// copied across JNI, or parsed. +/// +/// Returns a JSON object +/// `{"utxos":[...],"errors":[...],"cursor":,"hasMore":}`. +/// Each `utxos` row is one output the engine currently holds, tagged with +/// its owning account. `cursor` is opaque: hand it back verbatim on the +/// next call (`null`/absent starts from the beginning) and keep going while +/// `hasMore` is true. `limit` caps the rows in one page — non-positive +/// means the default, and anything larger than the cap is clamped. +/// +/// `network` follows `Network.ffiValue` (0 mainnet, 2 devnet, 3 regtest, +/// else testnet) and selects the address encoding; an output whose script +/// has no address form carries an empty `address` for the caller to skip. +/// A per-account read failure lands in `errors` instead of failing the +/// page — the reconciler must still see every account that DID read, so +/// one faulted account cannot mask the others' repair. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + network: jni::sys::jint, + cursor: JString, + limit: jni::sys::jint, +) -> jni::sys::jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + let net = match network { + 0 => dashcore::Network::Mainnet, + 2 => dashcore::Network::Devnet, + 3 => dashcore::Network::Regtest, + _ => dashcore::Network::Testnet, + }; + let page_limit = if limit <= 0 { + UTXO_PAGE_DEFAULT + } else { + (limit as usize).min(UTXO_PAGE_MAX) + }; + let resume = if cursor.is_null() { + None + } else { + match env.get_string(&cursor) { + Ok(s) => parse_utxo_page_cursor(&String::from(s)), + Err(_) => None, + } + }; + + let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_get_account_balances( + manager_handle as Handle, + wid.as_ptr(), + &mut entries, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + let mut next_cursor: Option = None; + let mut has_more = false; + if !entries.is_null() && count > 0 { + let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; + let keys: Vec<[u8; 78]> = accounts.iter().map(account_sort_key).collect(); + let mut order: Vec = (0..accounts.len()).collect(); + order.sort_by(|a, b| keys[*a].cmp(&keys[*b])); + let mut remaining = page_limit; + for &i in &order { + let acc = &accounts[i]; + let key = keys[i]; + // Resume: accounts before the cursor's are already swept, + // the cursor's own continues after its last outpoint, and + // every later account starts from the beginning. + let after = match &resume { + Some(c) if key < c.account_key => continue, + Some(c) if key == c.account_key => Some((c.txid, c.vout)), + _ => None, + }; + if remaining == 0 { + // The page filled on an earlier account and this one is + // still unswept — resume from the cursor already set. + has_more = true; + break; + } + let spec = platform_wallet_ffi::AccountSpecFFI { + type_tag: acc.type_tag as u8, + standard_tag: acc.standard_tag as u8, + index: acc.index, + registration_index: acc.registration_index, + key_class: acc.key_class, + user_identity_id: acc.user_identity_id, + friend_identity_id: acc.friend_identity_id, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }; + let mut utxos: *const platform_wallet_ffi::AccountUtxoEntryFFI = ptr::null(); + let mut utxo_count: usize = 0; + let mut account_has_more = false; + // The cursor txid has to outlive the call — a pointer taken + // from a temporary inside the argument list would dangle. + let after_txid: Option<[u8; 32]> = after.map(|(txid, _)| txid); + let res = unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_page( + manager_handle as Handle, + wid.as_ptr(), + &spec, + after_txid.as_ref().map_or(ptr::null(), |t| t.as_ptr()), + after.map_or(0, |(_, vout)| vout), + remaining, + &mut utxos, + &mut utxo_count, + &mut account_has_more, + ) + }; + if let Some(msg) = pwffi_error_message(res) { + errors.push(format!( + "{{\"typeTag\":{},\"index\":{},\"message\":{}}}", + acc.type_tag as u8, + acc.index, + json_escape(&msg), + )); + continue; + } + if !utxos.is_null() && utxo_count > 0 { + let items = unsafe { std::slice::from_raw_parts(utxos, utxo_count) }; + for u in items { + let script: &[u8] = if u.script_pubkey.is_null() || u.script_pubkey_len == 0 + { + &[] + } else { + unsafe { + std::slice::from_raw_parts(u.script_pubkey, u.script_pubkey_len) + } + }; + let script_buf = dashcore::ScriptBuf::from(script.to_vec()); + let address = dashcore::Address::from_script(&script_buf, net) + .map(|a| a.to_string()) + .unwrap_or_default(); + // The DashPay identity halves of the account tuple are + // emitted only when set (all-zero on every non-DashPay + // account) — the reconcile needs the COMPLETE tuple to + // resolve the owning Room account and stamp it on healed + // rows, so ownership survives even when the address + // projection is absent. + let mut identity_suffix = String::new(); + if acc.user_identity_id != [0u8; 32] || acc.friend_identity_id != [0u8; 32] + { + identity_suffix = format!( + ",\"userIdentityId\":\"{}\",\"friendIdentityId\":\"{}\"", + hex_lower(&acc.user_identity_id), + hex_lower(&acc.friend_identity_id), + ); + } + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"registrationIndex\":{},\"keyClass\":{},\ + \"txid\":\"{}\",\"vout\":{},\"amount\":{},\ + \"address\":{},\"scriptHex\":\"{}\",\ + \"height\":{},\"isLocked\":{}{}}}", + acc.type_tag as u8, + acc.standard_tag as u8, + acc.index, + acc.registration_index, + acc.key_class, + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + u.value_duffs, + json_escape(&address), + hex_lower(script), + u.height, + u.is_locked, + identity_suffix, + )); + next_cursor = Some(format!( + "{}:{}:{}", + hex_lower(&key), + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + )); + } + remaining -= utxo_count.min(remaining); + unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_free( + utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, + utxo_count, + ) + }; + } + if account_has_more { + // Stopped inside this account: the cursor already names + // its last emitted outpoint. + has_more = true; + break; + } + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + // Without a cursor there is nowhere to resume, so a "more" claim + // would loop the caller forever. Cannot happen — a page only stops + // early after emitting a row — but the loop's termination should not + // rest on that reasoning alone. + if next_cursor.is_none() { + has_more = false; + } + let json = format!( + "{{\"utxos\":[{}],\"errors\":[{}],\"cursor\":{},\"hasMore\":{}}}", + rows.join(","), + errors.join(","), + next_cursor + .map(|c| json_escape(&c)) + .unwrap_or_else(|| "null".to_string()), + has_more, + ); + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Classify a batch of outpoints against the engine's live state — the +/// reverse half of the reconcile transport, and the reason +/// `walletManagerUtxosPageJson` no longer exports a spent-outpoint list. +/// The host pages its OWN mirror rows and asks about them a batch at a +/// time, so neither side builds a set over the whole engine inventory. +/// +/// `outpoints` is a flat `n * 36` byte blob in the store's own outpoint +/// encoding — 32-byte txid in wire order followed by the vout as +/// little-endian `u32`, which is exactly the `txos.outpoint` primary key, +/// so a caller concatenates the column and reads the answers back +/// positionally. Returns `n` bytes: 0 unknown, 1 unspent, 2 spent. +/// +/// A 2 means some recorded transaction spends the outpoint — possibly one +/// still in the mempool. It is not proof of a settled spend, and the +/// reconcile treats it as a signal to log, never to write. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerClassifyOutpoints( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + outpoints: JByteArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + let blob = match env.convert_byte_array(&outpoints) { + Ok(b) => b, + Err(_) => { + throw_sdk_exception(env, 1, "outpoints must be a byte[]"); + return ptr::null_mut(); + } + }; + if !blob.len().is_multiple_of(36) { + throw_sdk_exception( + env, + 1, + "outpoints must be a multiple of 36 bytes (txid || vout LE)", + ); + return ptr::null_mut(); + } + let requested: Vec = blob + .chunks_exact(36) + .map(|chunk| { + let mut txid = [0u8; 32]; + txid.copy_from_slice(&chunk[..32]); + platform_wallet_ffi::OutPointFFI { + txid, + vout: u32::from_le_bytes([chunk[32], chunk[33], chunk[34], chunk[35]]), + } + }) + .collect(); + if requested.is_empty() { + return env + .byte_array_from_slice(&[]) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()); + } + let mut classes: *const u8 = ptr::null(); + let mut class_count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_classify_outpoints( + manager_handle as Handle, + wid.as_ptr(), + requested.as_ptr(), + requested.len(), + &mut classes, + &mut class_count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let verdicts: Vec = if classes.is_null() || class_count == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(classes, class_count) }.to_vec() + }; + unsafe { + platform_wallet_ffi::platform_wallet_classify_outpoints_free( + classes as *mut u8, + class_count, + ) + }; + env.byte_array_from_slice(&verdicts) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Extract-and-free a `PlatformWalletFFIResult`'s error message WITHOUT +/// throwing — the per-account soft-fail path of +/// [`Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerUtxosPageJson`] +/// reports account faults in-band so the sweep keeps going. `None` on +/// success. +fn pwffi_error_message( + mut result: platform_wallet_ffi::PlatformWalletFFIResult, +) -> Option { + if result.code == platform_wallet_ffi::PlatformWalletFFIResultCode::Success { + return None; + } + let message = if result.message.is_null() { + format!("platform-wallet error (code {})", result.code as i32) + } else { + // SAFETY: non-null message is a valid CString produced by the FFI. + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. + unsafe { platform_wallet_ffi::platform_wallet_ffi_result_free(&mut result) }; + Some(message) +} + +/// Lower-hex of a byte slice (txid bytes are emitted in the same order +/// the changeset path hands Kotlin, so hex→bytes on the Kotlin side +/// reproduces the exact `txos.txid` blob). +fn hex_lower(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Minimal JSON string escape (quotes, backslash, control chars) — the +/// values here are base58/bech32 addresses and FFI error strings. +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the /// wrong length or a JNI error. fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> {