diff --git a/Cargo.lock b/Cargo.lock index dfcf96c827e..cee3304a021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1229,7 +1229,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "bincode", "dashcore-private", @@ -2495,7 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2556,7 +2556,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" [[package]] name = "glob" @@ -3630,7 +3630,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3881,7 +3881,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/HashEngineering/rust-dashcore?rev=e5cbb13bea811a65f3a3193defc7168a9054c951#e5cbb13bea811a65f3a3193defc7168a9054c951" dependencies = [ "async-trait", "bincode", @@ -5753,7 +5753,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5791,9 +5791,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6489,11 +6489,14 @@ dependencies = [ "dash-network", "dashcore", "dashpay-contract", + "hex", "jni 0.21.1", "key-wallet-ffi", "log", "platform-wallet-ffi", "rs-sdk-ffi", + "serde", + "serde_json", "zeroize", ] @@ -6601,7 +6604,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6614,7 +6617,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6673,7 +6676,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7534,7 +7537,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8985,7 +8988,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fc7a2a957c..966072a0912 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-network-seeds = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-spv = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet-ffi = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +key-wallet-manager = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dash-network = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } +dashcore-rpc = { git = "https://github.com/HashEngineering/rust-dashcore", rev = "e5cbb13bea811a65f3a3193defc7168a9054c951" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index d0c4e9f2f53..421a5be66a6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -145,6 +145,63 @@ internal object WalletManagerNative { gapLimit: Int, ) + /** + * One bounded page of the engine's UTXO inventory for one wallet, + * across every account, as JSON + * `{"utxos":[...],"cursor":,"hasMore":}` — the + * source of truth the TXO-store reconciler + * ([PlatformWalletManager.reconcileTxoStore]) diffs against the Room + * `txos` mirror. A thin shim over one Rust call + * (`platform_wallet_wallet_utxos_page`): account ordering, cursor + * semantics and the page bound live in `platform-wallet`. + * + * Paged, not swept whole: a wallet's UTXO count is chain-controlled + * (anyone who knows a watched address can keep sending dust to it), so + * a full-inventory read would let a remote party decide how much this + * process allocates on every SYNCED transition and every 30-minute + * pass. Pass [cursor] `null` to start, then hand back the returned + * `cursor` verbatim while `hasMore` is true — a cursor this export did + * not produce throws. [limit] caps the rows in one page; non-positive + * means the native default, and oversized values are clamped natively. + * + * Each `utxos` row is a + * [org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler.EngineUtxoRow]: + * the owning account tuple, the txid hex in the same byte order the + * changeset path hands the handler (so hex→bytes reproduces the + * `txos.txid` blob), vout, amount (duffs), derived address (empty when + * the script has no address form), scriptHex, height and isLocked. + * `network` is [org.dashfoundation.dashsdk.Network.ffiValue]. + */ + external fun walletManagerUtxosPageJson( + managerHandle: Long, + walletId: ByteArray, + network: Int, + cursor: String?, + limit: Int, + ): String? + + /** + * Classify a batch of outpoints against the engine's live state: the + * reverse half of the reconcile transport, and the reason the paged + * inventory above carries no spent-outpoint list. The caller pages its + * OWN mirror rows and asks about them a batch at a time, so neither + * side ever builds a set over the whole engine inventory. + * + * [outpoints] is a flat `n * 36` byte blob in the store's own encoding + * — 32-byte txid in wire order then vout as little-endian `Int`, which + * is exactly the `txos.outpoint` primary key, so callers concatenate + * the column and read the answers back positionally. Returns `n` + * bytes: 0 unknown, 1 unspent, 2 spent. + * + * A 2 means SOME recorded transaction spends the outpoint — possibly + * one still in the mempool. It is not proof of a settled spend. + */ + external fun walletManagerClassifyOutpoints( + managerHandle: Long, + walletId: ByteArray, + outpoints: ByteArray, + ): ByteArray? + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d68d1758f93..7d1e8a44338 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -13,6 +13,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -1029,111 +1039,763 @@ class PlatformWalletPersistenceHandler( isLocked: Boolean, ): Int = guarded { stage(walletId) { db -> - val outpoint = makeOutpoint(txid, vout) - // Ensure a parent transaction row exists (stub if missing, so - // the TXO FK holds; the real tx upsert overwrites it later). - if (db.transactionDao().getByTxid(txid) == null) { - db.transactionDao().upsert( - TransactionEntity(txid = txid, transactionData = ByteArray(0)), + upsertUtxoRow( + db, walletId, txid, vout, amount, address, scriptPubKey, + height, isCoinbase, isConfirmed, isInstantLocked, isLocked, + ) + } + 0 + } + + /** + * The single TXO-insert discipline, shared by the changeset callback + * ([onWalletChangesetUtxoAdded]) and the reconcile sweep + * ([reconcileTxos]): stub the parent transaction row so the FK holds, + * upsert the TXO preserving any existing spend linkage, then drain + * pending-input rows staged before this funding TXO existed — a 1:1 + * port of the Swift upsertUtxo drain + * (PlatformWalletPersistenceHandler.swift). A spend that arrived first + * was deferred (see onWalletChangesetTransaction); now that the funding + * output is here, resolve the claim and clear the rows so the + * UTXO-restore path won't hand this consumed output back to Rust as + * spendable. + */ + private suspend fun upsertUtxoRow( + db: DashDatabase, + walletId: ByteArray, + txid: ByteArray, + vout: Int, + amount: Long, + address: String, + scriptPubKey: ByteArray, + height: Int, + isCoinbase: Boolean, + isConfirmed: Boolean, + isInstantLocked: Boolean, + isLocked: Boolean, + // The Room account this output belongs to, when the CALLER could + // resolve it (the reconcile resolves it from the engine inventory's + // account tags). Stamped on the row so ownership survives even when + // the address projection is absent — a heal into a store that lost + // BOTH the TXO and its address row must not produce a row the + // restore loader cannot attribute (it would be skipped at the next + // mirror-reload, recreating the fund loss the heal repaired). An + // existing row's accountId always wins; changeset callbacks pass + // null and keep their address-projection behavior. + resolvedAccountId: Long? = null, + ) { + val outpoint = makeOutpoint(txid, vout) + // Ensure a parent transaction row exists (stub if missing, so + // the TXO FK holds; the real tx upsert overwrites it later). + if (db.transactionDao().getByTxid(txid) == null) { + db.transactionDao().upsert( + TransactionEntity(txid = txid, transactionData = ByteArray(0)), + ) + } + val existing = db.txoDao().getByOutpoint(outpoint) + val coreAddressId = if (address.isNotEmpty()) address else null + // A materialised coin the wallet re-delivers unspent follows the + // wallet — the mirror of the SQLite store's upsert valve, which + // holds only never-materialised placeholders. The wallet knows + // this coin, and any network-final spender of a coin it knows is + // wallet-relevant by BIP158 prevout matching, so its own scan + // re-discovers the spend; refusing the re-delivery would lock a + // real coin out forever after a reorg of the winner, and on this + // side of the FFI a row at `isSpent = true` is never restored to + // Rust again. So an UNLINKED row — a sweep hold with its stamp, or + // a legacy flag with nothing behind it — is cleared, stamp + // included. A LINKED row keeps its flag and stamp: the link is + // this store's recorded spend attribution, the pending drain + // below and the sweep pass own that transition, and a spender + // that reached a block is confirmed evidence a re-delivery never + // displaces. + val linked = existing?.spendingTxid != null + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + isSpent = linked && existing!!.isSpent, + walletId = walletId, + txid = txid, + spendingTxid = existing?.spendingTxid, + spendingInputIndex = existing?.spendingInputIndex, + accountId = existing?.accountId ?: resolvedAccountId, + coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), + createdAt = existing?.createdAt ?: java.util.Date(), + lastUpdated = now(), + supersededByTxid = if (linked) existing!!.supersededByTxid else null, + ) + db.txoDao().upsert(row) + // Drain any pending-input rows staged before this funding TXO + // existed — a port of the Swift `upsertUtxo` drain + // (PlatformWalletPersistenceHandler.swift). A spend that arrived + // first was deferred (see onWalletChangesetTransaction); now that + // the funding output is here, resolve the claim and clear the rows + // so the UTXO-restore path won't hand this consumed output back to + // Rust as spendable. + val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) + if (pending.isNotEmpty()) { + // A tombstone outranks every ordinary row regardless of age: + // ordinary rows are competing *observations*, a tombstone is + // the sweep's settled verdict that its winner consumed this + // coin. Prefer the tombstone tagged with the delivering + // wallet; failing that any tombstone on the outpoint still + // holds — the stamp is a txid fact, not a per-wallet one. + val tombstones = pending.filter { it.isSweptTombstone } + val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } + .maxByOrNull { it.createdAt } + ?: tombstones.maxByOrNull { it.createdAt } + if (tombstone != null) { + // A drained tombstone STAMPS, it never mints a spender + // link: the winner need not have its own `transactions` + // row, and a link would make the coin non-releasable + // (the release pass frees stamped, unlinked rows) when a + // later sweep proves the winner never took it. The + // existing link, if any, is carried as it was. + db.txoDao().upsert( + row.copy( + isSpent = true, + supersededByTxid = tombstone.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + // Competing ordinary observations: a network-final spender + // outranks a newer mempool one (its row is the settled + // claim the link guard protects); among equals the newest + // wins, as before (reorg / double-spend: newest wins). + val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } + val (chosen, spending) = ranked.maxWithOrNull( + compareBy>( + { it.second?.context ?: 0 }, + { it.first.createdAt }, + ), + )!! + val spendingContext = spending?.context ?: 0 + val keepExistingLink = + keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) + db.txoDao().upsert( + linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), ) } - val existing = db.txoDao().getByOutpoint(outpoint) - val coreAddressId = if (address.isNotEmpty()) address else null - // A materialised coin the wallet re-delivers unspent follows the - // wallet — the mirror of the SQLite store's upsert valve, which - // holds only never-materialised placeholders. The wallet knows - // this coin, and any network-final spender of a coin it knows is - // wallet-relevant by BIP158 prevout matching, so its own scan - // re-discovers the spend; refusing the re-delivery would lock a - // real coin out forever after a reorg of the winner, and on this - // side of the FFI a row at `isSpent = true` is never restored to - // Rust again. So an UNLINKED row — a sweep hold with its stamp, or - // a legacy flag with nothing behind it — is cleared, stamp - // included. A LINKED row keeps its flag and stamp: the link is - // this store's recorded spend attribution, the pending drain - // below and the sweep pass own that transition, and a spender - // that reached a block is confirmed evidence a re-delivery never - // displaces. - val linked = existing?.spendingTxid != null - val row = TxoEntity( - outpoint = outpoint, - vout = vout, - amount = amount, - address = address, - scriptPubKey = scriptPubKey, - height = height, - isCoinbase = isCoinbase, - isConfirmed = isConfirmed, - isInstantLocked = isInstantLocked, - isLocked = isLocked, - isSpent = linked && existing!!.isSpent, - walletId = walletId, - txid = txid, - spendingTxid = existing?.spendingTxid, - spendingInputIndex = existing?.spendingInputIndex, - accountId = existing?.accountId, - coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), - createdAt = existing?.createdAt ?: java.util.Date(), - lastUpdated = now(), - supersededByTxid = if (linked) existing!!.supersededByTxid else null, + for (p in pending) db.documentDao().deletePendingInput(p) + } + } + + /** + * One row of the engine's paged UTXO inventory, exactly as + * `walletManagerUtxosPageJson` serializes it (rs-unified-sdk-jni + * `UtxoPageRow`, serde on that side, kotlinx on this one). The contract + * is typed on BOTH ends so a renamed or missing key fails the decode + * loudly instead of healing a row with a defaulted owner or a zero + * amount into the mirror the engine reloads from. The account tuple + * (`typeTag`…`friendIdentityId`) is the routing context [fetchAccount] + * resolves the owning Room account by; the DashPay identity halves are + * absent on every non-DashPay account. + */ + @Serializable + data class EngineUtxoRow( + val typeTag: Int, + val standardTag: Int = 0, + val index: Int = 0, + val registrationIndex: Int = 0, + val keyClass: Int = 0, + val userIdentityId: String? = null, + val friendIdentityId: String? = null, + val txid: String, + val vout: Int, + val amount: Long, + val address: String = "", + val scriptHex: String = "", + val height: Int, + val isLocked: Boolean = false, + ) + + /** + * One page of the engine's UTXO inventory: the rows, the opaque resume + * cursor (absent on the last page) and whether more pages follow. + */ + @Serializable + data class EngineUtxoPage( + val utxos: List = emptyList(), + val cursor: String? = null, + val hasMore: Boolean = false, + ) + + /** + * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] + * are the healed holes; a non-zero value after a completed sync means + * 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, + /** Store rows marked unspent whose outpoint the engine records as + * spent — the lost-spend-update class (dashpay/platform#4425). + * LOG-ONLY: the engine's spent set includes mempool spends with no + * context, so flipping would persist an unconfirmed spend as + * settled. */ + val wouldFlipSpent: Int = 0, + val wouldFlipSpentDuffs: Long = 0, + /** Store rows marked unspent that the engine has in NEITHER + * inventory — swept/abandoned residue (pre-rust-dashcore#971 + * stores). LOG-ONLY: counted and named in the log, never removed + * by this pass. */ + val wouldRemove: Int = 0, + val wouldRemoveDuffs: Long = 0, + /** Watch-only DIP-15 contact coins excluded on both sides — engine + * rows the insert pass refused to heal as ours, and unspent store + * rows the reverse pass did not classify (the engine's own accounts + * never report them, so their absence is expected, not + * divergence). */ + val skippedForeign: Int = 0, + /** Store rows marked spent for a coin the engine lists UNSPENT — + * either a released coin from a swept transaction whose release + * 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 + * after the first, or an outpoint-classification batch, that came + * back null. The pass stops at the first one; whatever it already + * applied stands (insert-only, idempotent) and the rest waits for + * the next cadence tick. A persistently non-zero value means the + * sweep never finishes, so the report's other counters are a + * partial view. */ + val transportFailures: Int = 0, + ) + + /** The insert pass's half of a [TxoReconcileReport]. */ + private data class HealPass( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + val netAmountSuspects: Int, + val healedUnowned: Int, + val skippedImmature: Int, + val skippedNoAddress: Int, + val skippedForeign: Int, + val transportFailures: Int, + ) + + /** The classification pass's half of a [TxoReconcileReport]. */ + private data class ClassifyPass( + val wouldFlipSpent: Int, + val wouldFlipSpentDuffs: Long, + val wouldRemove: Int, + val wouldRemoveDuffs: Long, + val stuckSpent: Int, + val stuckSpentDuffs: Long, + val skippedForeign: Int, + val transportFailures: Int, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * 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. + * + * Two passes, each bounded, sharing only the set of watch-only contact + * accounts they both exclude: + * + * * [healMissingTxos] pages the ENGINE ([engineUtxoPage]: `cursor` null + * to start, then the cursor the previous page returned while its + * `hasMore` is true) and inserts the rows the store lacks, one Room + * transaction per page. Insert-only and idempotent, so a sweep is + * many small commits rather than one giant one — that is the point. + * * [classifyStoreRows] pages the STORE and asks [classifyOutpoints] + * about one page at a time (0 unknown, 1 unspent, 2 spent). It + * writes nothing; every verdict is log-only. + * + * Neither side ever holds a set over a whole inventory: a wallet's UTXO + * count is chain-controlled (anyone who knows a watched address can + * keep sending dust to it), so a pass that materialized it would hand a + * remote party control over how much this process allocates on every + * SYNCED transition and every cadence tick. + * + * Rows the mirror holds and the engine lacks are LEFT ALONE (the mirror + * may legitimately be ahead — a live spend marks rows spent here before + * the engine's map settles — and it also carries watch-only contact + * outputs the engine's own accounts never report). Spent-state repair + * is deliberately out of scope. + * + * [minConfirmations] (default 100): the engine snapshot cannot carry + * `isCoinbase`/`isInstantLocked`, so inserted rows get + * `isConfirmed=true` and both flags false — inert for any output at or + * beyond coinbase maturity, which the gate guarantees. Fresher holes + * age into a later sweep. + * + * `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. + * + * Returns null when the FIRST engine page is unavailable — there is + * nothing to reconcile against, so there is no report to make. A page + * or classification batch failing later truncates the sweep instead, + * which the report's `transportFailures` records. A page that does not + * decode as an [EngineUtxoPage] is a contract violation between the JNI + * emitter and this reader, and it throws rather than being absorbed. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs Room transactions). + */ + 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? { + 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() + + val heal = healMissingTxos( + walletId, tipHeight, minConfirmations, limit, foreignAccountIds, engineUtxoPage, + ) ?: return null + val classify = classifyStoreRows(walletId, limit, foreignAccountIds, classifyOutpoints) + + val report = TxoReconcileReport( + engineUtxos = heal.engineUtxos, + inserted = heal.inserted, + insertedDuffs = heal.insertedDuffs, + netAmountSuspects = heal.netAmountSuspects, + healedUnowned = heal.healedUnowned, + skippedImmature = heal.skippedImmature, + skippedNoAddress = heal.skippedNoAddress, + wouldFlipSpent = classify.wouldFlipSpent, + wouldFlipSpentDuffs = classify.wouldFlipSpentDuffs, + wouldRemove = classify.wouldRemove, + wouldRemoveDuffs = classify.wouldRemoveDuffs, + skippedForeign = heal.skippedForeign + classify.skippedForeign, + stuckSpent = classify.stuckSpent, + stuckSpentDuffs = classify.stuckSpentDuffs, + transportFailures = heal.transportFailures + classify.transportFailures, + ) + if (report.inserted > 0 || report.wouldFlipSpent > 0 || report.wouldRemove > 0 || + report.stuckSpent > 0 || report.transportFailures > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed ${report.inserted} missing TXO(s) " + + "(${report.insertedDuffs} duffs), " + + "${report.netAmountSuspects} netAmount suspect(s) (log-only), " + + "healedUnowned=${report.healedUnowned}, " + + "wouldFlipSpent=${report.wouldFlipSpent} " + + "(${report.wouldFlipSpentDuffs} duffs, log-only), " + + "wouldRemove=${report.wouldRemove} " + + "(${report.wouldRemoveDuffs} duffs, log-only), " + + "stuckSpent=${report.stuckSpent} " + + "(${report.stuckSpentDuffs} duffs, log-only), " + + "engine=${report.engineUtxos} " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} " + + "foreign=${report.skippedForeign} " + + "transportFailures=${report.transportFailures} — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", ) - db.txoDao().upsert(row) - // Drain any pending-input rows staged before this funding TXO - // existed — a port of the Swift `upsertUtxo` drain - // (PlatformWalletPersistenceHandler.swift). A spend that arrived - // first was deferred (see onWalletChangesetTransaction); now that - // the funding output is here, resolve the claim and clear the rows - // so the UTXO-restore path won't hand this consumed output back to - // Rust as spendable. - val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) - if (pending.isNotEmpty()) { - // A tombstone outranks every ordinary row regardless of age: - // ordinary rows are competing *observations*, a tombstone is - // the sweep's settled verdict that its winner consumed this - // coin. Prefer the tombstone tagged with the delivering - // wallet; failing that any tombstone on the outpoint still - // holds — the stamp is a txid fact, not a per-wallet one. - val tombstones = pending.filter { it.isSweptTombstone } - val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } - .maxByOrNull { it.createdAt } - ?: tombstones.maxByOrNull { it.createdAt } - if (tombstone != null) { - // A drained tombstone STAMPS, it never mints a spender - // link: the winner need not have its own `transactions` - // row, and a link would make the coin non-releasable - // (the release pass frees stamped, unlinked rows) when a - // later sweep proves the winner never took it. The - // existing link, if any, is carried as it was. - db.txoDao().upsert( - row.copy( - isSpent = true, - supersededByTxid = tombstone.spendingTxid, - lastUpdated = now(), - ), - ) - } else { - // Competing ordinary observations: a network-final spender - // outranks a newer mempool one (its row is the settled - // claim the link guard protects); among equals the newest - // wins, as before (reorg / double-spend: newest wins). - val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } - val (chosen, spending) = ranked.maxWithOrNull( - compareBy>( - { it.second?.context ?: 0 }, - { it.first.createdAt }, - ), - )!! - val spendingContext = spending?.context ?: 0 - val keepExistingLink = - keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) - db.txoDao().upsert( - linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), - ) + } else { + Log.i( + TAG, + "txos reconcile: mirror consistent (${report.engineUtxos} engine UTXOs, " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} foreign=${report.skippedForeign})", + ) + } + return report + } + + /** + * Insert pass of [reconcileTxos]: one engine page at a time, one Room + * transaction each. Each page is fetched OUTSIDE the exclusion lock — + * the fetch is a native call into the engine, and the lock exists to + * keep changeset callbacks out of our writes, not out of the engine. + * Returns null when the first page is unavailable (nothing to reconcile + * against); a later null page truncates the pass and is counted. + */ + private suspend fun healMissingTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int, + limit: Int, + foreignAccountIds: Set, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + ): HealPass? { + var engineUtxos = 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var healedUnowned = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var skippedForeign = 0 + var transportFailures = 0 + + var cursor: String? = null + while (true) { + val pageJson = engineUtxoPage(cursor, limit) + if (pageJson == null) { + // No first page: nothing to reconcile against, no report. + if (cursor == null) return null + // The transport died mid-sweep. Everything already applied + // stands (insert-only, idempotent); the rest waits for the + // next cadence tick. + transportFailures++ + break + } + // Strict decode: an unknown key, a missing required field or a + // mistyped value throws — the row would otherwise be healed with + // a defaulted owner or a zero amount. + val page = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + engineUtxos += page.utxos.size + + if (page.utxos.isNotEmpty()) { + callbackExclusion.withLock { + database.withTransaction { + for (row in page.utxos) { + when (healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds)) { + HealOutcome.SKIPPED_IMMATURE -> skippedImmature++ + HealOutcome.SKIPPED_NO_ADDRESS -> skippedNoAddress++ + HealOutcome.SKIPPED_FOREIGN -> skippedForeign++ + HealOutcome.ALREADY_PRESENT -> {} + HealOutcome.HEALED -> { + inserted++ + insertedDuffs += row.amount + } + HealOutcome.HEALED_UNOWNED -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + } + HealOutcome.HEALED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + netAmountSuspects++ + } + HealOutcome.HEALED_UNOWNED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + netAmountSuspects++ + } + } + } + } } - for (p in pending) db.documentDao().deletePendingInput(p) } + if (!page.hasMore || page.cursor == null) break + cursor = page.cursor } - 0 + return HealPass( + engineUtxos = engineUtxos, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + skippedForeign = skippedForeign, + transportFailures = transportFailures, + ) + } + + private enum class HealOutcome { + SKIPPED_IMMATURE, + SKIPPED_NO_ADDRESS, + SKIPPED_FOREIGN, + ALREADY_PRESENT, + HEALED, + HEALED_UNOWNED, + HEALED_NET_SUSPECT, + HEALED_UNOWNED_NET_SUSPECT, + } + + /** + * Apply one engine inventory row to the store (inside the caller's + * exclusion lock and Room transaction). Provable-only discipline: it + * neither mutates nor suppresses on guesswork. + */ + private suspend fun healEngineRow( + walletId: ByteArray, + row: EngineUtxoRow, + tipHeight: Int, + minConfirmations: Int, + foreignAccountIds: Set, + ): HealOutcome { + if (row.height <= 0 || tipHeight - row.height + 1 < minConfirmations) { + return HealOutcome.SKIPPED_IMMATURE + } + if (row.address.isEmpty()) return HealOutcome.SKIPPED_NO_ADDRESS + // The inventory tags every UTXO with its owning account tuple. The + // tag is the authoritative foreign check — a watch-only external + // account's coin is the CONTACT's money whether or not its address + // row survived persistence. The address-based check (through + // core_addresses.accountId, the same second path rowIsForeign uses + // for store rows) stays as a fallback. An unresolvable address is + // NOT provably foreign — those proceed. + if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) return HealOutcome.SKIPPED_FOREIGN + val addressOwner = database.coreAddressDao().getByAddress(row.address)?.accountId + if (addressOwner != null && addressOwner in foreignAccountIds) { + return HealOutcome.SKIPPED_FOREIGN + } + val txid = row.txid.hexToByteArray() + require(txid.size == 32) { "engine inventory row carries a ${txid.size}-byte txid" } + if (database.txoDao().getByOutpoint(makeOutpoint(txid, row.vout)) != null) { + return HealOutcome.ALREADY_PRESENT + } + // Resolve the Room account from the tuple and stamp it on the + // healed row. Ownership must not depend on the address projection: + // the two things persistence loses together are the TXO and its + // address row, and a healed row with neither link is skipped by + // the restore loader at the next mirror-reload — recreating the + // fund loss the heal repaired. + val ownerAccountId = fetchAccount( + database, walletId, row.typeTag, row.index, row.standardTag, + row.registrationIndex, row.keyClass, + row.userIdentityId?.hexToByteArray() ?: ByteArray(32), + row.friendIdentityId?.hexToByteArray() ?: ByteArray(32), + )?.id + if (ownerAccountId == null) { + // Heal anyway — the address projection may still attribute it — + // but surface the unresolved owner: if the address row is also + // gone, this row will not survive the next mirror-reload. + Log.w( + TAG, + "txos reconcile: healing TXO with UNRESOLVED account " + + "(typeTag=${row.typeTag} index=${row.index} address=${row.address}) — " + + "ownership rides on the address projection alone", + ) + } + upsertUtxoRow( + database, walletId, txid, row.vout, row.amount, row.address, + row.scriptHex.hexToByteArray(), row.height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = row.isLocked, + resolvedAccountId = ownerAccountId, + ) + // netAmount is NOT mutated here. The record's net may already be + // correct (a corrective record callback can land while its TXO + // delivery races this sweep), and adding the healed amount to an + // already-corrected net double-credits. The event pipeline owns + // net correctness; this pass only reports the suspicion. + val priorTx = database.transactionDao().getByTxid(txid) + val netSuspect = priorTx != null && priorTx.transactionData.isNotEmpty() + if (netSuspect) { + Log.w( + TAG, + "txos reconcile: healed TXO ${row.txid}:${row.vout} (${row.amount} duffs) " + + "has a pre-existing record whose netAmount may be short by that " + + "amount — LOG-ONLY, storedNet=${priorTx?.netAmount}", + ) + } + return when { + ownerAccountId == null && netSuspect -> HealOutcome.HEALED_UNOWNED_NET_SUSPECT + ownerAccountId == null -> HealOutcome.HEALED_UNOWNED + netSuspect -> HealOutcome.HEALED_NET_SUSPECT + else -> HealOutcome.HEALED + } + } + + /** + * Classification pass of [reconcileTxos] (the widened scope from the + * #4425 / pre-#971 review). Inverted relative to the insert pass — it + * pages the STORE and asks the engine about each page — so that neither + * side has to hold a set over a whole inventory. + * + * Read-only by construction: it writes nothing, so it runs outside both + * the exclusion lock and any transaction. A row a concurrent callback + * moves under it is at worst a stale log line, and every verdict here + * is log-only anyway. + * + * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. + * Production changeset writes leave txos.accountId null and route + * ownership through coreAddressId -> core_addresses.accountId, so the + * exclusion resolves BOTH paths — an accountId-only check silently + * classifies every contact row. + */ + private suspend fun classifyStoreRows( + walletId: ByteArray, + limit: Int, + foreignAccountIds: Set, + classifyOutpoints: suspend (outpoints: ByteArray) -> ByteArray?, + ): ClassifyPass { + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var stuckSpent = 0 + var stuckSpentDuffs = 0L + var skippedForeign = 0 + var transportFailures = 0 + + 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", + ) + } + } + } + } + return ClassifyPass( + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, + skippedForeign = skippedForeign, + transportFailures = transportFailures, + ) } override fun onWalletChangesetUtxoSpent( @@ -3960,6 +4622,40 @@ 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 + + /** + * Decoder for the engine inventory transport. Deliberately the + * strict default (`ignoreUnknownKeys = false`, no coercion): the JNI + * emitter and [EngineUtxoPage] are one contract, and a drift between + * them must fail the decode, not heal a defaulted row. + */ + private val engineJson = Json + + /** [reconcileTxos] classification verdicts, mirroring + * `platform_wallet::manager::accessors::OUTPOINT_CLASS_*`. */ + internal const val OUTPOINT_CLASS_UNKNOWN: Byte = 0 + internal const val OUTPOINT_CLASS_UNSPENT: Byte = 1 + internal const val OUTPOINT_CLASS_SPENT: Byte = 2 + internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index ac484917836..67b9b6de722 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -21,6 +21,23 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE walletId = :walletId") fun observeByWallet(walletId: ByteArray): Flow> + /** + * One outpoint-ordered page of a wallet's TXOs, for a pass that must + * not hold the whole table at once (the store reconcile's reverse + * half). Pass an empty [after] to start — an empty BLOB sorts before + * every real 36-byte outpoint — then the previous page's last + * `outpoint` to continue. + * + * `outpoint` is the primary key, so the order is an index walk and the + * cursor is exact: no row can be visited twice or skipped because + * another one was inserted or deleted mid-sweep. + */ + @Query( + "SELECT * FROM txos WHERE walletId = :walletId AND outpoint > :after " + + "ORDER BY outpoint LIMIT :limit", + ) + suspend fun pageByWallet(walletId: ByteArray, after: ByteArray, limit: Int): List + /** WalletMemoryExplorer: `txo.walletId == walletId && txo.isSpent == false`. */ @Query("SELECT * FROM txos WHERE walletId = :walletId AND isSpent = 0") fun observeUnspentByWallet(walletId: ByteArray): Flow> diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index aba0c4ceb2d..f3b6c250295 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -1240,6 +1240,81 @@ 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. Native failures surface through [mapNativeErrors] as + * exceptions; both transport lambdas turn them into the null the + * handler's truncate-and-report contract is written against (and log + * them), so a mid-sweep fault yields a partial report rather than + * discarding the sweep. A malformed page is NOT absorbed: the handler's + * strict decode throws, and that propagates. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? = + persistenceHandler.reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + engineUtxoPage = { cursor, limit -> + withContext(Dispatchers.IO) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, network.ffiValue, cursor, limit, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: engine inventory page failed (cursor=$cursor)", + t, + ) + }.getOrNull() + } + }, + classifyOutpoints = { outpoints -> + withContext(Dispatchers.IO) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, outpoints, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: outpoint classification failed", + t, + ) + }.getOrNull() + } + }, + ) + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -2017,6 +2092,7 @@ class PlatformWalletManager( if (running) { runCatching { spvSyncProgress() }.getOrNull()?.let { next -> if (next != _spvProgress.value) _spvProgress.value = next + maybeReconcileTxoStores(next) } runCatching { spvTipUnixSeconds() }.getOrNull()?.let { tip -> if (tip != _spvTipUnixSeconds.value) _spvTipUnixSeconds.value = tip @@ -2030,6 +2106,61 @@ 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 + // The filter sub-phase is the wallet-relevant height; if dash-spv + // reports SYNCED without one (filter sync disabled, or the phase + // dropped after completion) fall back to the header tip rather than + // silently skipping the heal this exists to deliver. + val tipHeight = ( + progress.filters?.currentHeight?.takeIf { it > 0L } + ?: progress.headers?.currentHeight + ?: 0L + ).toInt() + if (tipHeight <= 0) { + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile skipped: SYNCED progress carries no tip height " + + "(filters=${progress.filters?.currentHeight} headers=${progress.headers?.currentHeight})", + ) + return + } + val walletIds = wallets.value.values.map { it.walletId } + if (walletIds.isEmpty()) return + lastTxoReconcileAtMs = now + scope.launch { + for (walletId in walletIds) { + runCatching { reconcileTxoStore(walletId, tipHeight) } + .onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile failed for wallet ${walletId.toHex()}", + t, + ) + } + } + } + } + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2443,6 +2574,14 @@ class PlatformWalletManager( /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L + /** + * Cadence of the steady-state TXO-store reconcile + * ([maybeReconcileTxoStores]) while SPV reports SYNCED. The + * SYNCED transition itself always triggers a pass regardless of + * this interval. + */ + const val TXO_RECONCILE_INTERVAL_MS = 30 * 60 * 1_000L + /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ const val PWFFI_INVALID_PARAMETER = 2 } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index e9f941bcfd1..dc030bc087e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -13,6 +13,10 @@ import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge @@ -5990,6 +5994,63 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isSweptTombstone) } + // ── TXO-store reconcile (the job-flower change-drop repair) ─────── + + private val changeTxid = ByteArray(32) { 7 } + private val reconcileTip = 1_536_950 + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isLocked":false}]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + // A send record born blind to its own change output: netAmount + // persisted as the full input value (the job-flower 6cef55ab… + // shape) and NO txos row for the change. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + ), + ) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + assertEquals(1, report.netAmountSuspects) + + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) + assertNotNull(row) + assertFalse(row!!.isSpent) + assertEquals(989_009_773L, row.amount) + assertTrue(row.isConfirmed) + + // The stored netAmount is NOT mutated: the record may already carry + // the corrected net (a corrective callback racing this sweep), and + // blind addition double-credits. The suspicion is logged; the event + // pipeline owns net correctness. + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + @Test fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { // A chained sweep that re-points a still-unfunded claim to a new @@ -6494,6 +6555,632 @@ 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]}""" + } + + @Test + fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { + // A store row still marked unspent for a coin the engine records as + // spent (dashpay/platform#4425). The engine's spent set includes + // MEMPOOL spends and carries no context, so persisting the flip + // would settle an unconfirmed spend — counted and logged only. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) + assertNull(row.spendingTxid) + } + + @Test + fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction (pre-rust-dashcore#971 + // stores). Counted and logged, NEVER removed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } + + @Test + fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // insert pass would skip as immature — are consistent, not + // divergence. Every reverse-pass counter must be zero. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[]}""" + 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":[]}""" + 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":[]}""" + 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) + } + + @Test + fun reconcileReturnsNoReportWhenTheFirstPageIsUnavailable() = runTest { + // No first page means nothing to reconcile against — the contract + // callers had before the sweep was paged, now enforced inside the + // handler rather than by a prefetch in the caller. + var classifyCalls = 0 + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> null }, + classifyOutpoints = { outpoints -> classifyCalls++; ByteArray(outpoints.size / 36) }, + ) + assertNull("a dead transport yields no report", report) + assertEquals("and the classification pass never runs", 0, classifyCalls) + } + + @Test + fun reconcileRejectsAMalformedInventoryRowInsteadOfHealingIt() = runTest { + // The transport is a typed contract on both ends. A row missing its + // amount (or carrying a key this reader does not know) must fail the + // decode loudly — the alternative is a defaulted row written into + // the mirror the engine reloads from. + val malformed = """{"utxos":[{"typeTag":0,"txid":"${changeTxid.toHexLower()}",""" + + """"vout":40,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd","height":1400000}],""" + + """"cursor":null,"hasMore":false}""" + val thrown = runCatching { + handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> malformed }, + classifyOutpoints = { outpoints -> ByteArray(outpoints.size / 36) }, + ) + }.exceptionOrNull() + assertTrue( + "strict decode must throw, got $thrown", + thrown is kotlinx.serialization.SerializationException, + ) + assertNull( + "nothing was healed from the malformed page", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 40)), + ) + } + + /** + * Drive the paged reconcile from one whole-inventory JSON blob — the + * shape these tests describe an engine in, and the shape the native + * 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 checkNotNull( + reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ), + ) { "the fake engine always serves a first page" } + } + + /** + * 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 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() + 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 + return """{"utxos":[${slice.joinToString(",") { it.toString() }}],""" + + """"cursor":${if (hasMore) "\"$next\"" else "null"},"hasMore":$hasMore}""" + } + + fun classify(outpoints: ByteArray): ByteArray { + batches++ + val count = outpoints.size / OUTPOINT_SIZE + classified += count + val verdicts = ByteArray(count) + for (i in 0 until count) { + val base = i * OUTPOINT_SIZE + val txidHex = outpoints.copyOfRange(base, base + 32) + .joinToString("") { "%02x".format(it) } + var vout = 0 + for (b in 3 downTo 0) { + vout = (vout shl 8) or (outpoints[base + 32 + b].toInt() and 0xFF) + } + val k = key(txidHex, vout) + verdicts[i] = when { + k in unspentKeys -> 1 + k in spentKeys -> 2 + else -> 0 + } + } + return verdicts + } + + private fun key(txidHex: String, vout: Int) = "$txidHex:$vout" + + private companion object { + /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ + const val OUTPOINT_SIZE = 36 + } + } } /** diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e8e717d0a18..b2355e0477a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -41,6 +41,20 @@ impl From<&dashcore::OutPoint> for OutPointFFI { } } +impl From<&OutPointFFI> for dashcore::OutPoint { + /// The inverse of the conversion above, and the one authority for it. + /// Hosts hand outpoints BACK across the boundary when they ask the + /// engine about rows they already hold (the store-reconcile + /// classification batch), so the round trip has to land on exactly the + /// bytes that went out. + fn from(ffi: &OutPointFFI) -> Self { + dashcore::OutPoint { + txid: ::from_byte_array(ffi.txid), + vout: ffi.vout, + } + } +} + /// Outpoint of a TXO that was spent, paired with the spending /// transaction's txid. Replaces the bare `OutPointFFI` on /// `AccountChangeSetFFI.utxos_spent` so the Swift persister can @@ -879,6 +893,32 @@ pub struct AccountUtxoEntryFFI { pub is_locked: bool, } +/// One row of a WALLET-wide UTXO inventory page +/// (`platform_wallet_wallet_utxos_page`): the coin plus the complete account +/// tuple that owns it — the same tag layout as [`AccountBalanceEntryFFI`] / +/// `AccountSpecFFI`, so a host can resolve the owning account row and stamp +/// ownership on a healed mirror row without an address projection. The last +/// row's tuple + outpoint is also the resume cursor for the next page. +/// `script_pubkey` is heap-owned and freed by +/// `platform_wallet_wallet_utxos_free`. +#[repr(C)] +pub struct WalletUtxoEntryFFI { + pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, + pub standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub outpoint_txid: [u8; 32], + pub outpoint_vout: u32, + pub value_duffs: u64, + pub script_pubkey: *mut u8, + pub script_pubkey_len: usize, + pub height: u32, + pub is_locked: bool, +} + /// One transaction row in the per-account paginated drill-down. #[repr(C)] #[derive(Debug, Clone, Copy)] diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..ea72efe392b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -21,15 +21,16 @@ use platform_wallet::manager::accessors::{ AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, WalletUtxoCursor, WalletUtxoRow, }; use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + WalletUtxoEntryFFI, }; 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,189 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); } +/// One page of the wallet's UTXO inventory across EVERY funds account, in +/// `(account, outpoint)` order under one read lock — the bounded, host-neutral +/// form of `platform_wallet_account_utxos`. +/// +/// A wallet's UTXO count is chain-controlled (anyone who knows a watched +/// address can keep sending dust to it), so a periodic host-side audit must +/// never materialize the whole inventory at once. The page is wallet-wide so +/// the host neither enumerates accounts nor stitches per-account cursors: +/// the ordering invariant lives in `platform-wallet` once +/// (`wallet_utxos_page_blocking`) and every host walks it the same way. +/// +/// Resume with the LAST ROW of the previous page: `after_spec` (its account +/// tuple, xpub ignored) + `after_txid`/`after_vout` (its outpoint). Pass a +/// NULL `after_spec` to start at the beginning. `limit` caps the rows in one +/// page (0 means "no limit" — a paging caller should always pass a real cap); +/// `out_has_more` reports whether further pages remain. An unknown wallet is +/// an empty terminal page. +/// +/// Rows are freed with `platform_wallet_wallet_utxos_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( + manager_handle: Handle, + wallet_id: *const u8, + after_spec: *const AccountSpecFFI, + after_txid: *const u8, + after_vout: u32, + limit: usize, + out_utxos: *mut *const WalletUtxoEntryFFI, + out_count: *mut usize, + out_has_more: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + 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]); + // A NULL spec is "from the beginning" — the only way to say it, since + // every tuple/outpoint pair is a legal cursor. + let after: Option = if after_spec.is_null() { + None + } else { + check_ptr!(after_txid); + let account_type = match account_type_from_spec_ref(&*after_spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let raw: [u8; 32] = std::ptr::read(after_txid as *const [u8; 32]); + Some(( + account_type, + dashcore::OutPoint::from(&OutPointFFI { + txid: raw, + vout: after_vout, + }), + )) + }; + let Some((rows, has_more)) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.wallet_utxos_page_blocking(&wid, after.as_ref(), 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(wallet_utxo_entry_ffi).collect(); + let count = entries.len(); + *out_utxos = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +fn wallet_utxo_entry_ffi(row: WalletUtxoRow) -> WalletUtxoEntryFFI { + let tags = crate::core_wallet_types::account_type_to_tags(&row.account_type); + let coin = utxo_entry_ffi(row.utxo); + WalletUtxoEntryFFI { + type_tag: tags.type_tag, + standard_tag: tags.standard_tag, + index: tags.index, + registration_index: tags.registration_index, + key_class: tags.key_class, + user_identity_id: tags.user_identity_id, + friend_identity_id: tags.friend_identity_id, + outpoint_txid: coin.outpoint_txid, + outpoint_vout: coin.outpoint_vout, + value_duffs: coin.value_duffs, + script_pubkey: coin.script_pubkey, + script_pubkey_len: coin.script_pubkey_len, + height: coin.height, + is_locked: coin.is_locked, + } +} + +/// Free a page returned by `platform_wallet_wallet_utxos_page`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_wallet_utxos_free( + utxos: *mut WalletUtxoEntryFFI, + count: usize, +) { + if utxos.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts(utxos, count); + for entry in slice { + if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.script_pubkey, + entry.script_pubkey_len, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); +} + +/// Classify `count` outpoints against the wallet's live engine state, in +/// one pass under one read lock. `out_classes` receives a `count`-byte +/// buffer, positionally aligned with the input: 0 unknown, 1 unspent, 2 +/// 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)); +} + // --------------------------------------------------------------------------- // Phase 6 — Per-account transactions // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2b0b23dbd49..10eb01f048d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4232,6 +4232,7 @@ unsafe fn restore_core_address_pools( pool_entries: &[AccountAddressPoolFFI], network: Network, wallet_id: &[u8; 32], + signing_wallet: Option<&Wallet>, ) -> Result { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; let mut pools_routed = 0usize; @@ -4418,11 +4419,94 @@ 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. An + // expected condition on every contact pool of every load, + // so it is a debug line, not an info line per pool. + tracing::debug!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + "load: address-pool hole repair skipped (no public key source); \ + pool restored as persisted" + ); + } else if let Some(max_idx) = pool.highest_generated { + match pool.ensure_contiguous_to(max_idx, &key_source) { + Ok(0) => {} + Ok(filled) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + filled, + "load: repaired address-pool holes left by dropped \ + persisted rows; outputs paying these addresses are \ + recognizable again" + ); + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + error = %e, + "load: address-pool hole repair failed; pool restored \ + as persisted (sparse)" + ); + } + } + } } None => { pools_dropped += 1; @@ -4885,7 +4969,13 @@ fn build_wallet_start_state( // SAFETY: `pool_entries` is a valid slice (checked above) and each // row's `addresses_ptr` follows the load-callback contract. unsafe { - restore_core_address_pools(&mut wallet_info, pool_entries, network, &entry.wallet_id)?; + restore_core_address_pools( + &mut wallet_info, + pool_entries, + network, + &entry.wallet_id, + Some(&wallet), + )?; } } @@ -8145,7 +8235,7 @@ mod tests { // SAFETY: `row` / `addr_c` / `path_c` outlive the call below. let stats = unsafe { - restore_core_address_pools(&mut wallet_info, &pools, Network::Testnet, &[0u8; 32]) + restore_core_address_pools(&mut wallet_info, &pools, Network::Testnet, &[0u8; 32], None) } .expect("restore must succeed for a well-formed provider pool"); assert_eq!( diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ace055e60aa..e91e75c7749 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1,5 +1,6 @@ //! Read-only accessors on [`PlatformWalletManager`]. +use std::ops::Bound; use std::sync::Arc; use dashcore::{OutPoint, Txid}; @@ -206,6 +207,17 @@ pub struct AccountAddressInfoSnapshot { pub public_key_bytes: Vec, } +/// [`PlatformWalletManager::classify_outpoints_blocking`] verdicts. The +/// engine has no record of this outpoint on any account — for a store row +/// still marked unspent, that is residue of a swept or abandoned +/// transaction, not proof the coin is gone. +pub const OUTPOINT_CLASS_UNKNOWN: u8 = 0; +/// The engine holds this outpoint as a live UTXO. +pub const OUTPOINT_CLASS_UNSPENT: u8 = 1; +/// Some recorded transaction spends this outpoint. Says nothing about +/// confirmation: the engine's spent set includes mempool spends. +pub const OUTPOINT_CLASS_SPENT: u8 = 2; + /// Snapshot of one UTXO row inside an account. #[derive(Debug, Clone)] pub struct AccountUtxoSnapshot { @@ -216,6 +228,21 @@ pub struct AccountUtxoSnapshot { pub is_locked: bool, } +/// One row of a wallet-wide UTXO inventory page +/// ([`PlatformWalletManager::wallet_utxos_page_blocking`]): the coin and +/// the account that owns it. The account is the routing context a +/// persistence mirror needs to stamp ownership on a healed row. +#[derive(Debug, Clone)] +pub struct WalletUtxoRow { + pub account_type: AccountType, + pub utxo: AccountUtxoSnapshot, +} + +/// Where a wallet-wide inventory page left off: the account and outpoint of +/// the last row emitted. Hand the last row's `(account_type, +/// utxo.outpoint)` back to resume. +pub type WalletUtxoCursor = (AccountType, OutPoint); + /// Snapshot of one transaction row inside an account. #[derive(Debug, Clone, Copy)] pub struct AccountTransactionSnapshot { @@ -845,6 +872,164 @@ impl PlatformWalletManager

{ .collect() } + /// One page of the wallet's UTXO inventory across EVERY funds account, + /// under one read lock — the bounded form of + /// [`Self::account_utxos_blocking`] for callers that must never hold a + /// whole wallet's inventory at once. + /// + /// A wallet's UTXO count is chain-controlled: anyone who knows a watched + /// address can keep sending dust to it, so a full-inventory read has no + /// upper bound a mobile process can rely on. Pages solve that, and a + /// WALLET-wide page (rather than one per account) means the host does + /// not enumerate accounts, order them, or stitch cursors itself — the + /// ordering invariant lives here once, and every host (JNI, Swift) gets + /// the same walk. + /// + /// Rows are ordered by `(AccountType, OutPoint)` — `AccountType`'s own + /// `Ord`, then the outpoint key of the account's UTXO map — which is + /// deterministic and stable for as long as the inventory does not + /// change. `after` is the last row of the previous page (`None` starts + /// at the beginning); `limit` caps the rows returned (`0` means "no + /// limit", a paging caller should always pass a real cap); the returned + /// flag says whether more rows follow. Because each account's UTXOs live + /// in a `BTreeMap`, a page is a partial select over the keys — no + /// intermediate copy of the rows the caller skipped. + /// + /// A concurrently registered account, or a UTXO set that moves between + /// pages, can drop a row out of ONE sweep or repeat one; both are benign + /// for the store reconcile this serves (insert-only, idempotent, re-run + /// on a cadence). An unknown wallet is an empty terminal page. + pub fn wallet_utxos_page_blocking( + &self, + wallet_id: &WalletId, + after: Option<&WalletUtxoCursor>, + 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); + }; + // Funds accounts only — keys-only accounts (identity / asset-lock / + // provider) never carry UTXOs by construction — in cursor order. + let accounts = info.core_wallet.accounts.all_accounts(); + let mut funds: Vec<( + AccountType, + &key_wallet::managed_account::ManagedCoreFundsAccount, + )> = accounts + .iter() + .filter_map(|a| { + a.as_funds() + .map(|f| (a.managed_account_type().to_account_type(), f)) + }) + .collect(); + funds.sort_by(|a, b| a.0.cmp(&b.0)); + + let take = if limit == 0 { usize::MAX } else { limit }; + let mut rows: Vec = Vec::new(); + let mut has_more = false; + for (position, (account_type, account)) in funds.iter().enumerate() { + // Resume: accounts before the cursor's are already swept, the + // cursor's own continues after its last outpoint, every later + // account starts from the beginning. + let start = match after { + Some((cursor_account, _)) if account_type < cursor_account => continue, + Some((cursor_account, cursor_outpoint)) if account_type == cursor_account => { + Bound::Excluded(*cursor_outpoint) + } + _ => Bound::Unbounded, + }; + let mut iter = account.utxos.range((start, Bound::Unbounded)); + let remaining = take - rows.len(); + if remaining == 0 { + // The page filled on an earlier account; one probe decides + // whether anything is left to page — a single tree step. + if iter.next().is_some() { + has_more = true; + break; + } + continue; + } + for (_, utxo) in iter.by_ref().take(remaining) { + rows.push(WalletUtxoRow { + account_type: *account_type, + utxo: AccountUtxoSnapshot { + outpoint: utxo.outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + height: utxo.height, + is_locked: utxo.is_locked, + }, + }); + } + if rows.len() == take { + // Full page: more follows if this account has another row, + // or any later account has any row at all. + if iter.next().is_some() + || funds[position + 1..] + .iter() + .any(|(_, a)| !a.utxos.is_empty()) + { + has_more = true; + } + break; + } + } + (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 + } + // ----------------------------------------------------------------- // Phase 6 — Per-account transactions // ----------------------------------------------------------------- @@ -1193,6 +1378,238 @@ 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 UTXOs on the wallet's BIP44 account. The engine normally + /// fills this map from block processing; a test only needs the map's + /// contents, and the accessors read nothing else. + async fn seed_utxos( + manager: &Arc>, + wallet_id: &WalletId, + outpoints: &[OutPoint], + ) { + seed_account_utxos(manager, wallet_id, bip44(), outpoints).await + } + + /// Put UTXOs on one specific funds account of the wallet. + async fn seed_account_utxos( + manager: &Arc>, + wallet_id: &WalletId, + target: AccountType, + outpoints: &[OutPoint], + ) { + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(wallet_id).expect("known wallet"); + let mut accounts = info.core_wallet.accounts.all_accounts_mut(); + let account = accounts + .iter_mut() + .find(|a| a.managed_account_type().to_account_type() == target) + .expect("target funds account"); + // `Utxo` carries an address; the accessors never read it, so any + // address the account already derived will do. + let address = account + .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, + } + } + + /// Every funds account of the test wallet other than BIP44 #0, in the + /// order the page walks them. + async fn other_funds_accounts( + manager: &Arc>, + wallet_id: &WalletId, + ) -> Vec { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("known wallet"); + let mut types: Vec = info + .core_wallet + .accounts + .all_accounts() + .iter() + .filter(|a| a.as_funds().is_some()) + .map(|a| a.managed_account_type().to_account_type()) + .filter(|t| *t != bip44()) + .collect(); + types.sort(); + types + } + + #[tokio::test] + async fn utxo_pages_cover_the_wallet_exactly_once_and_stop() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + // Five outpoints across two txids on BIP44 #0, so a page boundary + // lands inside a txid as well as between them — plus two more on a + // second funds account, so the walk has to cross an account + // boundary and order the accounts itself. + let seeded: Vec = vec![ + outpoint(1, 0), + outpoint(1, 1), + outpoint(1, 2), + outpoint(2, 0), + outpoint(2, 1), + ]; + seed_utxos(&manager, &wallet_id, &seeded).await; + let others = other_funds_accounts(&manager, &wallet_id).await; + let second = *others + .first() + .expect("the test wallet has a second funds account"); + let second_seeded = vec![outpoint(0, 5), outpoint(0, 6)]; + seed_account_utxos(&manager, &wallet_id, second, &second_seeded).await; + let total = seeded.len() + second_seeded.len(); + + tokio::task::spawn_blocking(move || { + let mut seen: Vec<(AccountType, OutPoint)> = Vec::new(); + let mut after: Option<(AccountType, OutPoint)> = None; + let mut pages = 0; + loop { + let (rows, has_more) = + manager.wallet_utxos_page_blocking(&wallet_id, after.as_ref(), 2); + pages += 1; + assert!(rows.len() <= 2, "a page must never exceed its limit"); + if let Some(last) = rows.last() { + after = Some((last.account_type, last.utxo.outpoint)); + } + seen.extend(rows.iter().map(|r| (r.account_type, r.utxo.outpoint))); + if !has_more { + break; + } + assert!(pages < 10, "paging must terminate"); + } + assert_eq!(4, pages, "7 rows at 2 per page"); + assert_eq!( + total, + seen.len(), + "every UTXO of every account is delivered" + ); + let mut unique = seen.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(total, unique.len(), "and none of them twice"); + let mut sorted = seen.clone(); + sorted.sort(); + assert_eq!(sorted, seen, "pages walk the (account, outpoint) order"); + // The lower-sorting account comes out first in its entirety: the + // outpoints seeded on `second` (txid byte 0) sort below BIP44's + // by outpoint, so account order, not outpoint order, must win. + let (first_account, _) = seen[0]; + let first_run = seen.iter().take_while(|(a, _)| *a == first_account).count(); + let expected_first = if first_account == bip44() { + seeded.len() + } else { + second_seeded.len() + }; + assert_eq!( + expected_first, first_run, + "an account is exhausted before the next starts" + ); + + // The unpaged per-account accessor is the same inventory — the + // page cursor is a transport detail, not a different view. + let whole = manager.account_utxos_blocking(&wallet_id, &bip44()); + assert_eq!(seeded.len(), whole.len()); + + // An exhausted cursor is an empty terminal page, not a loop. + let (rows, has_more) = manager.wallet_utxos_page_blocking(&wallet_id, seen.last(), 2); + assert!(rows.is_empty()); + assert!(!has_more); + + // A page that fills exactly on the last row reports no more. + let (rows, has_more) = manager.wallet_utxos_page_blocking(&wallet_id, None, total); + assert_eq!(total, rows.len()); + assert!(!has_more); + + // An unknown wallet is an empty terminal page. + let (rows, has_more) = manager.wallet_utxos_page_blocking(&[0xFF; 32], None, 2); + assert!(rows.is_empty()); + assert!(!has_more); + }) + .await + .expect("blocking accessor task"); + } + + #[tokio::test] + async fn classification_is_positional_and_covers_unknown_outpoints() { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let held = outpoint(3, 7); + seed_utxos(&manager, &wallet_id, &[held]).await; + + tokio::task::spawn_blocking(move || { + let asked = vec![outpoint(9, 0), held, outpoint(9, 1)]; + let classes = manager.classify_outpoints_blocking(&wallet_id, &asked); + assert_eq!( + vec![ + OUTPOINT_CLASS_UNKNOWN, + OUTPOINT_CLASS_UNSPENT, + OUTPOINT_CLASS_UNKNOWN + ], + classes, + "verdicts line up with the outpoints that were asked about", + ); + + // The length contract holds at both edges: an empty batch, and + // an unknown wallet, still answer positionally. + assert!(manager + .classify_outpoints_blocking(&wallet_id, &[]) + .is_empty()); + assert_eq!( + vec![OUTPOINT_CLASS_UNKNOWN; 3], + manager.classify_outpoints_blocking(&[0xFF; 32], &asked), + ); + }) + .await + .expect("blocking accessor task"); + } +} + #[cfg(test)] mod spv_rescan_tests { use std::sync::Arc; diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..cf930cbf03b 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -17,14 +17,20 @@ 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" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +hex = "0.4" zeroize = "1" [target.'cfg(target_os = "android")'.dependencies] android_logger = "0.14" [dev-dependencies] -dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c1ac4b1d871..0d566114c3b 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3201,6 +3201,391 @@ 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 that owns one inventory row — `AccountSpecFFI` minus +/// the xpub, in the field names the Kotlin `EngineUtxoRow` / +/// `PlatformWalletPersistenceHandler.fetchAccount` resolve a Room account +/// by. The DashPay identity halves are emitted only when set (all-zero on +/// every non-DashPay account); the Kotlin side defaults them. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UtxoAccountTuple { + type_tag: u8, + standard_tag: u8, + index: u32, + registration_index: u32, + key_class: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_identity_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + friend_identity_id: Option, +} + +impl UtxoAccountTuple { + fn from_entry(e: &platform_wallet_ffi::WalletUtxoEntryFFI) -> Self { + let identity = |id: &[u8; 32]| (*id != [0u8; 32]).then(|| hex::encode(id)); + UtxoAccountTuple { + type_tag: e.type_tag as u8, + standard_tag: e.standard_tag as u8, + index: e.index, + registration_index: e.registration_index, + key_class: e.key_class, + user_identity_id: identity(&e.user_identity_id), + friend_identity_id: identity(&e.friend_identity_id), + } + } + + /// Back to the FFI spec for the resume cursor. `None` when an identity + /// hex is malformed — a cursor the host did not get from us. + fn to_spec(&self) -> Option { + fn id32(hex_id: &Option) -> Option<[u8; 32]> { + match hex_id { + None => Some([0u8; 32]), + Some(h) => hex::decode(h).ok()?.try_into().ok(), + } + } + Some(platform_wallet_ffi::AccountSpecFFI { + type_tag: self.type_tag, + standard_tag: self.standard_tag, + index: self.index, + registration_index: self.registration_index, + key_class: self.key_class, + user_identity_id: id32(&self.user_identity_id)?, + friend_identity_id: id32(&self.friend_identity_id)?, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }) + } +} + +/// One row of an inventory page — the typed contract the Kotlin +/// `EngineUtxoRow` decodes. `txid` is lower hex in the same byte order the +/// changeset path hands Kotlin, so hex→bytes reproduces the `txos.txid` +/// blob; `address` is empty when the script has no address form. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPageRow { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, + vout: u32, + amount: u64, + address: String, + script_hex: String, + height: u32, + is_locked: bool, +} + +/// One page: the rows, the opaque resume cursor (absent on the last page) +/// and whether more pages follow. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPage { + utxos: Vec, + cursor: Option, + has_more: bool, +} + +/// Where a paged inventory sweep left off — the last row's account tuple +/// and outpoint, exactly what `platform_wallet_wallet_utxos_page` resumes +/// from. Serialized as JSON and handed to the host as an opaque string; the +/// host only ever gives back what a previous page returned. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPageCursor { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, + vout: u32, +} + +/// One bounded page of the engine's UTXO inventory across every account of +/// 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. Nothing bigger than one page is ever formatted, copied +/// across JNI, or parsed. +/// +/// This export is a thin shim over ONE Rust call +/// (`platform_wallet_wallet_utxos_page`): the account ordering, the cursor +/// semantics and the page bound all live in `platform-wallet`, so the Swift +/// host walks the identical inventory. Here the rows are only serialized — +/// with serde, against the same field names the Kotlin `EngineUtxoPage` +/// deserializes, so a renamed key fails the Kotlin decode loudly instead of +/// healing a defaulted row. +/// +/// Returns a JSON object `{"utxos":[...],"cursor":,"hasMore":}`. +/// `cursor` is opaque: hand it back verbatim on the next call (`null`/absent +/// starts from the beginning) and keep going while `hasMore` is true. A +/// cursor this export did not produce is rejected with an SDK exception +/// rather than silently restarting the sweep. `limit` caps the rows in one +/// page — non-positive means the default, and anything larger than the cap +/// is clamped. +/// +/// `network` follows `Network.ffiValue` (0 mainnet, 2 devnet, 3 regtest, +/// else testnet) and selects the address encoding. +#[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) + }; + // Resume point, decoded from the opaque cursor. Kept alive across + // the FFI call — the spec and txid are passed by pointer. + let resume: Option<(platform_wallet_ffi::AccountSpecFFI, [u8; 32], u32)> = if cursor + .is_null() + { + None + } else { + let raw = match env.get_string(&cursor) { + Ok(s) => String::from(s), + Err(_) => { + throw_sdk_exception(env, 1, "inventory cursor must be a String"); + return ptr::null_mut(); + } + }; + let parsed = serde_json::from_str::(&raw) + .ok() + .and_then(|c| { + let spec = c.account.to_spec()?; + let txid: [u8; 32] = hex::decode(&c.txid).ok()?.try_into().ok()?; + Some((spec, txid, c.vout)) + }); + match parsed { + Some(r) => Some(r), + None => { + throw_sdk_exception( + env, + 1, + "malformed inventory cursor: only a cursor returned by a previous page may be handed back", + ); + return ptr::null_mut(); + } + } + }; + + let mut entries: *const platform_wallet_ffi::WalletUtxoEntryFFI = ptr::null(); + let mut count: usize = 0; + let mut has_more = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_wallet_utxos_page( + manager_handle as Handle, + wid.as_ptr(), + resume + .as_ref() + .map_or(ptr::null(), |(spec, _, _)| spec as *const _), + resume + .as_ref() + .map_or(ptr::null(), |(_, txid, _)| txid.as_ptr()), + resume.as_ref().map_or(0, |(_, _, vout)| *vout), + page_limit, + &mut entries, + &mut count, + &mut has_more, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !entries.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(entries, count) }; + for e in items { + let script: &[u8] = if e.script_pubkey.is_null() || e.script_pubkey_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(e.script_pubkey, e.script_pubkey_len) } + }; + let script_buf = dashcore::ScriptBuf::from(script.to_vec()); + let address = dashcore::Address::from_script(&script_buf, net) + .map(|a| a.to_string()) + .unwrap_or_default(); + rows.push(UtxoPageRow { + account: UtxoAccountTuple::from_entry(e), + txid: hex::encode(e.outpoint_txid), + vout: e.outpoint_vout, + amount: e.value_duffs, + address, + script_hex: hex::encode(script), + height: e.height, + is_locked: e.is_locked, + }); + } + unsafe { + platform_wallet_ffi::platform_wallet_wallet_utxos_free( + entries as *mut platform_wallet_ffi::WalletUtxoEntryFFI, + count, + ) + }; + } + // The cursor is the last row itself; a page with no rows has nowhere + // to resume from and the accessor reports no more in that case. + let next_cursor = if has_more { + rows.last().map(|last| UtxoPageCursor { + account: UtxoAccountTuple { + type_tag: last.account.type_tag, + standard_tag: last.account.standard_tag, + index: last.account.index, + registration_index: last.account.registration_index, + key_class: last.account.key_class, + user_identity_id: last.account.user_identity_id.clone(), + friend_identity_id: last.account.friend_identity_id.clone(), + }, + txid: last.txid.clone(), + vout: last.vout, + }) + } else { + None + }; + let cursor_json = match next_cursor.as_ref().map(serde_json::to_string) { + Some(Ok(c)) => Some(c), + Some(Err(e)) => { + throw_sdk_exception(env, 1, &format!("inventory cursor encode failed: {e}")); + return ptr::null_mut(); + } + None => None, + }; + let page = UtxoPage { + utxos: rows, + cursor: cursor_json, + has_more: has_more && next_cursor.is_some(), + }; + match serde_json::to_string(&page) { + Ok(json) => env + .new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + Err(e) => { + throw_sdk_exception(env, 1, &format!("inventory page encode failed: {e}")); + ptr::null_mut() + } + } + }) +} + +/// 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()) + }) +} + /// 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]> {