diff --git a/Cargo.lock b/Cargo.lock index b54fd62ebd..3002a7c766 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" 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=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "dash-network", ] @@ -1750,7 +1750,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "async-trait", "chrono", @@ -1779,7 +1779,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "anyhow", "base64-compat", @@ -1805,12 +1805,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "dashcore-rpc-json", "hex", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "bincode", "dashcore", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "bincode", "dashcore-private", @@ -2905,7 +2905,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" [[package]] name = "glob" @@ -4096,7 +4096,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "aes", "async-trait", @@ -4125,7 +4125,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5877d15f26aeb3f6d43b010ed919a9a77f78815d#5877d15f26aeb3f6d43b010ed919a9a77f78815d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=9a68e6528072523eb210a5338bcb0edace9ff453#9a68e6528072523eb210a5338bcb0edace9ff453" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 5151ecff7f..053b07475c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,14 +52,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5877d15f26aeb3f6d43b010ed919a9a77f78815d" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "9a68e6528072523eb210a5338bcb0edace9ff453" } tokio-metrics = "0.5" diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 40bdef869c..d6d07ff206 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -128,6 +128,25 @@ internal object WalletManagerNative { /** Balance as `long[4]` = {confirmed, unconfirmed, immature, locked}. */ external fun walletGetBalance(walletHandle: Long): LongArray + /** + * The engine's full UTXO inventory for one wallet, every account, as + * JSON `{"utxos":[...],"errors":[...]}` — the source of truth the + * TXO-store reconciler ([PlatformWalletManager.reconcileTxoStore]) + * diffs against the Room `txos` mirror. Each `utxos` row carries the + * owning account tags, the txid hex in the same byte order the + * changeset path hands [PlatformWalletPersistenceHandler] (so + * hex→bytes reproduces the `txos.txid` blob), vout, amount (duffs), + * derived address (empty when the script has no address form), + * scriptHex, height and isLocked. Per-account read failures land in + * `errors` instead of failing the sweep. `network` is + * [org.dashfoundation.dashsdk.Network.ffiValue]. + */ + external fun walletManagerAllUtxosJson( + managerHandle: Long, + walletId: ByteArray, + network: Int, + ): String? + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index e71bce4dac..4726e5b433 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -13,6 +13,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -966,141 +972,494 @@ class PlatformWalletPersistenceHandler( isLocked: Boolean, ): Int = guarded { stage(walletId) { db -> - val outpoint = makeOutpoint(txid, vout) - val parentTx = db.transactionDao().getByTxid(txid) - // A globally-swept parent is a transaction Rust has already - // proven can never confirm — a fresh UTXO entry naming its txid - // would (re-)create exactly the phantom output - // `onWalletChangesetTransactionsSwept` deletes on every callback - // that observes the sweep. Bail rather than attach a new row to - // a transaction still excluded from restoration. - // - // This does not fight [onWalletChangesetTransaction]'s - // reinstatement path — it relies on that method running first. - // The JNI bridge (`persist_changeset_account` in - // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for - // an account's `transactions` before this method for that same - // account's `utxos_added`, so a reinstating record for this - // txid in this same round has already cleared the tombstone by - // the time this guard reads it; only a UTXO entry with no - // accompanying record this round still finds the flag set. That - // is genuinely a stale/out-of-order signal — Rust does not - // otherwise re-emit a swept loser's own outputs — and staying - // defensive here is correct: there is no record in flight to - // attribute a resurrected output to. See - // TransactionEntity.isGloballySwept. - if (parentTx?.isGloballySwept == true) return@stage - // Ensure a parent transaction row exists (stub if missing, so - // the TXO FK holds; the real tx upsert overwrites it later). - if (parentTx == null) { - db.transactionDao().upsert( - TransactionEntity(txid = txid, transactionData = ByteArray(0)), + upsertUtxoRow( + db, walletId, txid, vout, amount, address, scriptPubKey, + height, isCoinbase, isConfirmed, isInstantLocked, isLocked, + ) + } + 0 + } + + /** + * The single TXO-insert discipline, shared by the changeset callback + * ([onWalletChangesetUtxoAdded]) and the reconcile sweep + * ([reconcileTxos]): stub the parent transaction row so the FK holds, + * upsert the TXO preserving any existing spend linkage, then drain + * pending-input rows staged before this funding TXO existed — a 1:1 + * port of the Swift upsertUtxo drain + * (PlatformWalletPersistenceHandler.swift:895-953). A spend that + * arrived first was deferred (see onWalletChangesetTransaction); now + * that the funding output is here, link the newest pending spend + * (reorg/double-spend: newest wins) and clear the rows so the + * UTXO-restore path won't hand this consumed output back to Rust as + * spendable. + */ + private suspend fun upsertUtxoRow( + db: DashDatabase, + walletId: ByteArray, + txid: ByteArray, + vout: Int, + amount: Long, + address: String, + scriptPubKey: ByteArray, + height: Int, + isCoinbase: Boolean, + isConfirmed: Boolean, + isInstantLocked: Boolean, + isLocked: Boolean, + ): Boolean { + val outpoint = makeOutpoint(txid, vout) + val parentTx = db.transactionDao().getByTxid(txid) + // A globally-swept parent is a transaction Rust has already + // proven can never confirm — a fresh UTXO entry naming its txid + // would (re-)create exactly the phantom output + // `onWalletChangesetTransactionsSwept` deletes on every callback + // that observes the sweep. Bail rather than attach a new row to + // a transaction still excluded from restoration. + // + // This does not fight [onWalletChangesetTransaction]'s + // reinstatement path — it relies on that method running first. + // The JNI bridge (`persist_changeset_account` in + // rs-unified-sdk-jni) calls `onWalletChangesetTransaction` for + // an account's `transactions` before this method for that same + // account's `utxos_added`, so a reinstating record for this + // txid in this same round has already cleared the tombstone by + // the time this guard reads it; only a UTXO entry with no + // accompanying record this round still finds the flag set. That + // is genuinely a stale/out-of-order signal — Rust does not + // otherwise re-emit a swept loser's own outputs — and staying + // defensive here is correct: there is no record in flight to + // attribute a resurrected output to. See + // TransactionEntity.isGloballySwept. + if (parentTx?.isGloballySwept == true) return false + // Ensure a parent transaction row exists (stub if missing, so + // the TXO FK holds; the real tx upsert overwrites it later). + if (parentTx == null) { + db.transactionDao().upsert( + TransactionEntity(txid = txid, transactionData = ByteArray(0)), + ) + } + val existing = db.txoDao().getByOutpoint(outpoint) + val coreAddressId = if (address.isNotEmpty()) address else null + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + // The wallet is handing this outpoint over as a UTXO, so it + // holds it unspent — authoritative, and the only thing that + // lifts a mark with neither a spender nor a winner behind + // it (a pre-stamp row from before `holdSpentWithoutSpender` + // named its winner; every hold written today is stamped). A + // row whose spend is still on record keeps its flag — the + // pending drain below owns that transition — and so does a + // `supersededByTxid` hold: the winner that consumed this + // coin is known even though its row never materialized + // here, and a re-delivery cannot outrank that verdict — a + // restore-rescan re-finds the funding output precisely + // because it is blind to an unconfirmed winner no block + // carries yet. Only an explicit release + // (`releaseByOutpoint`) frees a stamped coin. + isSpent = existing?.isSpent == true && + (existing.spendingTxid != null || existing.supersededByTxid != null), + walletId = walletId, + txid = txid, + spendingTxid = existing?.spendingTxid, + spendingInputIndex = existing?.spendingInputIndex, + accountId = existing?.accountId, + coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), + createdAt = existing?.createdAt ?: java.util.Date(), + lastUpdated = now(), + supersededByTxid = existing?.supersededByTxid, + ) + db.txoDao().upsert(row) + // Drain any pending-input rows staged before this funding TXO + // existed — a 1:1 port of the Swift upsertUtxo drain + // (PlatformWalletPersistenceHandler.swift:895-953). A spend that + // arrived first was deferred (see onWalletChangesetTransaction); + // now that the funding output is here, link the newest pending + // spend (reorg/double-spend: newest wins) and clear the rows so + // the UTXO-restore path won't hand this consumed output back to + // Rust as spendable. + val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) + if (pending.isNotEmpty()) { + // A tombstone outranks every ordinary row regardless of age. + // Newest-wins arbitrates between competing *observations* + // (reorg / double-spend sightings), but a tombstone is not an + // observation — it is the sweep's settled verdict that its + // winner consumed this coin. The two coexist in exactly one + // way: records precede sweeps within a round, so the winner's + // own record can stage an ordinary pending row for this + // outpoint moments before the sweep repoints the loser's row + // — which keeps its original, older `createdAt`. Letting the + // younger ordinary row win there would take the gated branch + // below (`isSpent` false until the winner confirms), never + // stamp `supersededByTxid`, and then delete every row + // including the tombstone — the durable hold evaporates and + // the consumed coin re-enters the restore set. + val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } + ?: pending.maxByOrNull { it.createdAt }!! + val spending = db.transactionDao().getByTxid(chosen.spendingTxid) + if (chosen.isSweptTombstone) { + // `onWalletChangesetTransactionsSwept` repointed this row + // at the sweep's winner because the loser it originally + // recorded is gone. A sweep's winner is already final — + // there is no mempool state to wait out — so `isSpent` + // does not gate on `spending` the way an ordinary pending + // spend does; that lookup only succeeds when the winner + // happens to have its own materialized row, which isn't + // guaranteed (and `spendingTxid`'s FK forbids forcing the + // reference otherwise). `supersededByTxid` is what makes + // the mark durable either way — it is what the recovery + // clear above checks so this coin isn't handed back as + // spendable on a later sync. + db.txoDao().upsert( + row.copy( + isSpent = true, + spendingTxid = spending?.txid ?: row.spendingTxid, + spendingInputIndex = chosen.inputIndex, + supersededByTxid = chosen.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + db.txoDao().upsert( + row.copy( + isSpent = row.isSpent || spentInBlock, + spendingTxid = chosen.spendingTxid, + spendingInputIndex = chosen.inputIndex, + lastUpdated = now(), + ), ) } - val existing = db.txoDao().getByOutpoint(outpoint) - val coreAddressId = if (address.isNotEmpty()) address else null - val row = TxoEntity( - outpoint = outpoint, - vout = vout, - amount = amount, - address = address, - scriptPubKey = scriptPubKey, - height = height, - isCoinbase = isCoinbase, - isConfirmed = isConfirmed, - isInstantLocked = isInstantLocked, - isLocked = isLocked, - // The wallet is handing this outpoint over as a UTXO, so it - // holds it unspent — authoritative, and the only thing that - // lifts a mark with neither a spender nor a winner behind - // it (a pre-stamp row from before `holdSpentWithoutSpender` - // named its winner; every hold written today is stamped). A - // row whose spend is still on record keeps its flag — the - // pending drain below owns that transition — and so does a - // `supersededByTxid` hold: the winner that consumed this - // coin is known even though its row never materialized - // here, and a re-delivery cannot outrank that verdict — a - // restore-rescan re-finds the funding output precisely - // because it is blind to an unconfirmed winner no block - // carries yet. Only an explicit release - // (`releaseByOutpoint`) frees a stamped coin. - isSpent = existing?.isSpent == true && - (existing.spendingTxid != null || existing.supersededByTxid != null), - walletId = walletId, - txid = txid, - spendingTxid = existing?.spendingTxid, - spendingInputIndex = existing?.spendingInputIndex, - accountId = existing?.accountId, - coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), - createdAt = existing?.createdAt ?: java.util.Date(), - lastUpdated = now(), - supersededByTxid = existing?.supersededByTxid, - ) - db.txoDao().upsert(row) - // Drain any pending-input rows staged before this funding TXO - // existed — a 1:1 port of the Swift upsertUtxo drain - // (PlatformWalletPersistenceHandler.swift:895-953). A spend that - // arrived first was deferred (see onWalletChangesetTransaction); - // now that the funding output is here, link the newest pending - // spend (reorg/double-spend: newest wins) and clear the rows so - // the UTXO-restore path won't hand this consumed output back to - // Rust as spendable. - val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) - if (pending.isNotEmpty()) { - // A tombstone outranks every ordinary row regardless of age. - // Newest-wins arbitrates between competing *observations* - // (reorg / double-spend sightings), but a tombstone is not an - // observation — it is the sweep's settled verdict that its - // winner consumed this coin. The two coexist in exactly one - // way: records precede sweeps within a round, so the winner's - // own record can stage an ordinary pending row for this - // outpoint moments before the sweep repoints the loser's row - // — which keeps its original, older `createdAt`. Letting the - // younger ordinary row win there would take the gated branch - // below (`isSpent` false until the winner confirms), never - // stamp `supersededByTxid`, and then delete every row - // including the tombstone — the durable hold evaporates and - // the consumed coin re-enters the restore set. - val chosen = pending.filter { it.isSweptTombstone }.maxByOrNull { it.createdAt } - ?: pending.maxByOrNull { it.createdAt }!! - val spending = db.transactionDao().getByTxid(chosen.spendingTxid) - if (chosen.isSweptTombstone) { - // `onWalletChangesetTransactionsSwept` repointed this row - // at the sweep's winner because the loser it originally - // recorded is gone. A sweep's winner is already final — - // there is no mempool state to wait out — so `isSpent` - // does not gate on `spending` the way an ordinary pending - // spend does; that lookup only succeeds when the winner - // happens to have its own materialized row, which isn't - // guaranteed (and `spendingTxid`'s FK forbids forcing the - // reference otherwise). `supersededByTxid` is what makes - // the mark durable either way — it is what the recovery - // clear above checks so this coin isn't handed back as - // spendable on a later sync. - db.txoDao().upsert( - row.copy( - isSpent = true, - spendingTxid = spending?.txid ?: row.spendingTxid, - spendingInputIndex = chosen.inputIndex, - supersededByTxid = chosen.spendingTxid, - lastUpdated = now(), - ), - ) - } else { - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK - db.txoDao().upsert( - row.copy( - isSpent = row.isSpent || spentInBlock, - spendingTxid = chosen.spendingTxid, - spendingInputIndex = chosen.inputIndex, - lastUpdated = now(), - ), + for (p in pending) db.documentDao().deletePendingInput(p) + } + return true + } + + /** + * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] + * are the healed holes; a non-zero value after a completed sync means + * a changeset failed to deliver an owned output (the + * CoinJoin-funded-send change-drop class) and would have become a + * fund-loss on the next engine reload from this store. + */ + data class TxoReconcileReport( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + /** Healed TXOs whose pre-existing record's netAmount MAY be short by + * the healed amount. LOG-ONLY: the record can already carry the + * corrected net (a corrective callback racing this sweep), and + * blind addition double-credits. The event pipeline owns net + * correctness. */ + val netAmountSuspects: Int, + val skippedImmature: Int, + val skippedNoAddress: Int, + val accountErrors: Int, + /** Store rows marked unspent whose outpoint the engine records as + * spent — the lost-spend-update class (dashpay/platform#4425). + * LOG-ONLY: the engine's spent set includes mempool spends with no + * context, so flipping would persist an unconfirmed spend as + * settled. */ + val wouldFlipSpent: Int = 0, + val wouldFlipSpentDuffs: Long = 0, + /** Engine UTXOs whose insert the shared discipline refused (their + * parent transaction is globally swept) — excluded from + * [inserted], never netAmount-affecting. */ + val skippedSwept: Int = 0, + /** Store rows marked unspent that the engine has in NEITHER + * inventory — swept/abandoned residue (pre-rust-dashcore#971 + * stores). LOG-ONLY: counted and named in the log, never removed + * by this pass. */ + val wouldRemove: Int = 0, + val wouldRemoveDuffs: Long = 0, + /** Watch-only DIP-15 contact rows excluded from classification — + * the engine's own accounts never report them, so their absence + * from both inventories is expected, not divergence. */ + val skippedForeign: Int = 0, + /** Store rows marked spent for a coin the engine lists UNSPENT — + * either a released coin from a swept transaction whose release + * event a pre-rust-dashcore#971 build lost, or a live spend the + * store wrote moments before the engine settled. The two cannot + * be told apart safely, so this is LOG-ONLY: un-marking a coin + * mid-payment would let the wallet double-spend it. */ + val stuckSpent: Int = 0, + val stuckSpentDuffs: Long = 0, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory ([engineUtxosJson] — the + * `WalletManagerNative.walletManagerAllUtxosJson` payload). The + * mirror is write-behind with no other feedback loop: a changeset + * that fails to deliver an owned output leaves a permanent hole, and + * because the engine is REBUILT from this mirror on restart + * (buildUtxoRestoreData), the hole graduates to a fund-loss on the + * next launch. Observed in the field as the job-flower 106.43→86.33 + * restart drop: rescan nondeterministically drops the change outputs + * of sends funded from CoinJoin-account outputs. + * + * Insert-only by design: rows the engine holds and the mirror lacks + * are added; rows the mirror holds and the engine lacks are LEFT + * ALONE (the mirror may legitimately be ahead — a live spend marks + * rows spent here before the engine's map settles — and it also + * carries watch-only contact outputs the engine's own accounts never + * report). Spent-state repair is deliberately out of scope. + * + * [minConfirmations] (default 100): the engine snapshot cannot carry + * `isCoinbase`/`isInstantLocked`, so inserted rows get + * `isConfirmed=true` and both flags false — inert for any output at + * or beyond coinbase maturity, which the gate guarantees. Fresher + * holes age into a later sweep. + * + * Repairs `netAmount` alongside: a record born blind to one of its + * own outputs persisted `netAmount` short by exactly that output's + * value (verified against the job-flower dataset: -10.00010000 + * stored vs -0.11000227 true for tx 6cef55ab…). The bump applies + * only when the transaction row pre-exists with real bytes — a stub + * row created by this very insert has nothing to repair. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs a Room transaction). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + engineUtxosJson: String, + tipHeight: Int, + minConfirmations: Int = 100, + ): TxoReconcileReport { + val root = kotlinx.serialization.json.Json + .parseToJsonElement(engineUtxosJson).jsonObject + val utxos = root["utxos"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) + val spent = root["spent"]?.jsonArray ?: kotlinx.serialization.json.JsonArray(emptyList()) + val accountErrors = root["errors"]?.jsonArray?.size ?: 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var skippedSwept = 0 + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var skippedForeign = 0 + var stuckSpent = 0 + var stuckSpentDuffs = 0L + // Outpoint keys of BOTH engine inventories, for the reverse pass. + // The unspent side deliberately includes immature outputs the + // insert pass skips: a young coin present in both stores is + // consistent, not divergent. + val engineUnspentKeys = HashSet() + for (element in utxos) { + val row = element.jsonObject + val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + engineUnspentKeys.add("$txidHex:$vout") + } + val engineSpentKeys = HashSet() + for (element in spent) { + val row = element.jsonObject + val txidHex = row["txid"]?.jsonPrimitive?.content.orEmpty() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + engineSpentKeys.add("$txidHex:$vout") + } + callbackExclusion.withLock { + database.withTransaction { + for (element in utxos) { + val row = element.jsonObject + val height = row["height"]?.jsonPrimitive?.int ?: 0 + if (height <= 0 || tipHeight - height + 1 < minConfirmations) { + skippedImmature++ + continue + } + val address = row["address"]?.jsonPrimitive?.content.orEmpty() + if (address.isEmpty()) { + skippedNoAddress++ + continue + } + val txid = row["txid"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() + val vout = row["vout"]?.jsonPrimitive?.int ?: continue + if (txid.size != 32) continue + if (database.txoDao().getByOutpoint(makeOutpoint(txid, vout)) != null) { + continue + } + val amount = row["amount"]?.jsonPrimitive?.long ?: 0L + val scriptPubKey = + row["scriptHex"]?.jsonPrimitive?.content.orEmpty().hexToByteArray() + val isLocked = row["isLocked"]?.jsonPrimitive?.boolean ?: false + val wrote = upsertUtxoRow( + database, walletId, txid, vout, amount, address, scriptPubKey, + height, + isCoinbase = false, + isConfirmed = true, + isInstantLocked = false, + isLocked = isLocked, ) + if (!wrote) { + // The shared insert discipline refused (globally-swept + // parent). Counting it as healed — or repairing a + // netAmount for it — would falsify the report. + skippedSwept++ + continue + } + inserted++ + insertedDuffs += amount + // netAmount is NOT mutated here. The record's net may + // already be correct (a corrective record callback can + // land while its TXO delivery races this sweep), and + // adding the healed amount to an already-corrected net + // double-credits. The event pipeline owns net + // correctness; this pass only reports the suspicion. + val priorTx = database.transactionDao().getByTxid(txid) + if (priorTx != null && priorTx.transactionData.isNotEmpty()) { + netAmountSuspects++ + Log.w( + TAG, + "txos reconcile: healed TXO ${'$'}{txid.toHex()}:${'$'}vout " + + "(${'$'}amount duffs) has a pre-existing record whose " + + "netAmount may be short by that amount — LOG-ONLY, " + + "storedNet=${'$'}{priorTx.netAmount}", + ) + } + } + + // ── Reverse pass: classify store rows the engine disagrees + // with (the widened scope from the #4425 / pre-#971 review). + // Watch-only DIP-15 contact rows are excluded up front: the + // engine's own accounts never report them, so their absence + // from both inventories is expected. + val foreignAccountIds = database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + // Production changeset writes leave txos.accountId null and + // route ownership through coreAddressId -> core_addresses + // .accountId, so the exclusion must resolve BOTH paths — an + // accountId-only check silently classifies every contact row. + suspend fun rowIsForeign(row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity): Boolean { + if (row.accountId != null) return row.accountId in foreignAccountIds + val addr = row.coreAddressId ?: return false + val owner = database.coreAddressDao().getByAddress(addr)?.accountId + return owner != null && owner in foreignAccountIds + } + @Suppress("NAME_SHADOWING") + val storeRows = database.txoDao().observeByWallet(walletId).first() + // Case 3 (log-only): rows marked spent for coins the engine + // still lists unspent. Either lost-release residue + // (pre-#971) or a live spend racing the engine — never + // un-marked, only reported. + for (row in storeRows) { + if (!row.isSpent) continue + if (rowIsForeign(row)) continue + val key = "${row.txid?.toHex() ?: continue}:${row.vout}" + if (key in engineUnspentKeys) { + stuckSpent++ + stuckSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row spent but engine lists it " + + "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + + "(lost release, or a live spend racing the engine)", + ) + } + } + val storeUnspent = storeRows.filter { !it.isSpent } + for (row in storeUnspent) { + if (rowIsForeign(row)) { + skippedForeign++ + continue + } + val key = "${row.txid?.toHex() ?: continue}:${row.vout}" + when { + key in engineUnspentKeys -> {} + key in engineSpentKeys -> { + // Lost spend update (#4425) — PROBABLY. The + // engine's spent set records every input of every + // recorded transaction, INCLUDING mempool spends, + // and carries no context; flipping the store on + // it would persist an unconfirmed spend as + // settled, contradicting this handler's own + // in-block gating (see onWalletChangesetUtxoSpent). + // LOG-ONLY until the engine exports spends with + // their confirmation context. + wouldFlipSpent++ + wouldFlipSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row unspent but the engine " + + "records a spend (context unknown, possibly " + + "mempool) outpoint=$key amount=${row.amount} — " + + "LOG-ONLY, not flipped", + ) + } + else -> { + // In NEITHER engine inventory: swept/abandoned + // residue (pre-#971 stores) — or an engine gap. + // Deliberately LOG-ONLY: removal by reconciliation + // is the one direction where a bug destroys + // user-visible data, so it stays observable-first. + wouldRemove++ + wouldRemoveDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row in neither engine " + + "inventory outpoint=$key amount=${row.amount} — " + + "ambiguous (swept/abandoned residue, or a " + + "finalized spend whose engine record was " + + "dropped) — LOG-ONLY, not removed", + ) + } + } } - for (p in pending) db.documentDao().deletePendingInput(p) } } - 0 + val report = TxoReconcileReport( + engineUtxos = utxos.size, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + accountErrors = accountErrors, + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + skippedSwept = skippedSwept, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + skippedForeign = skippedForeign, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, + ) + if (inserted > 0 || accountErrors > 0 || wouldFlipSpent > 0 || wouldRemove > 0 || + stuckSpent > 0 || skippedSwept > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed $inserted missing TXO(s) ($insertedDuffs duffs), " + + "$netAmountSuspects netAmount suspect(s) (log-only), " + + "wouldFlipSpent=$wouldFlipSpent ($wouldFlipSpentDuffs duffs, log-only), " + + "skippedSwept=$skippedSwept, " + + "wouldRemove=$wouldRemove ($wouldRemoveDuffs duffs, log-only), " + + "stuckSpent=$stuckSpent ($stuckSpentDuffs duffs, log-only), " + + "engine=${report.engineUtxos} " + + "skipped immature=$skippedImmature noAddress=$skippedNoAddress " + + "foreign=$skippedForeign accountErrors=$accountErrors — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", + ) + } else { + Log.i(TAG, "txos reconcile: mirror consistent (${utxos.size} engine UTXOs)") + } + return report } override fun onWalletChangesetUtxoSpent( @@ -3604,6 +3963,10 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { + /** `AccountTypeTagFFI::DashpayExternalAccount` — watch-only DIP-15 + * contact accounts the engine's inventories never report. */ + internal const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 + internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 6bf67adede..3e823f9156 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -63,6 +63,16 @@ interface TransactionDao { @Upsert suspend fun upsert(transaction: TransactionEntity) + /** + * TXO-reconcile repair: credit a missed own-output back into the + * transaction's stored net amount. A record born blind to one of its + * own outputs (the CoinJoin-funded-send change-drop) persists + * `netAmount` short by exactly that output's value, so the repair is + * a plain add. Returns the number of rows updated (0 = no such tx). + */ + @Query("UPDATE transactions SET netAmount = netAmount + :delta WHERE txid = :txid") + suspend fun addToNetAmount(txid: ByteArray, delta: Long): Int + @Upsert suspend fun upsertInvolvement(involvement: TransactionAccountInvolvementEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 723c0b19e8..422b847f29 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 @@ -1207,6 +1207,37 @@ class PlatformWalletManager( mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } } + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver (and their + * transactions' `netAmount`). The mirror is write-behind with no + * other feedback loop, and the engine is REBUILT from it on restart — + * an unhealed hole becomes a fund-loss on the next launch (the + * job-flower 106.43→86.33 restart drop: rescan nondeterministically + * drops the change outputs of sends funded from CoinJoin-account + * outputs). Insert-only; never flips spent state or deletes. + * + * Call it after the L1 scan settles and again on a slow cadence; + * [tipHeight] is the synced chain height — only outputs at least + * [minConfirmations] deep are healed (immature holes age into the + * next sweep; see [PlatformWalletPersistenceHandler.reconcileTxos] + * for why). Returns null when the engine inventory read failed. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? { + val json = withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerAllUtxosJson( + managerHandle, walletId, network.ffiValue, + ) + } + } ?: return null + return persistenceHandler.reconcileTxos(walletId, json, tipHeight, minConfirmations) + } + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -1915,6 +1946,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 @@ -1928,6 +1960,46 @@ class PlatformWalletManager( } } + private var lastTxoReconcileAtMs = 0L + private var txoReconcileWasSynced = false + + /** + * SDK-internal trigger for [reconcileTxoStore] — runs on the SYNCED + * transition of the SPV progress poll and again every + * [TXO_RECONCILE_INTERVAL_MS] while synced, for every loaded wallet. + * Lives here rather than in the host apps so Android and iOS-parity + * hosts both get the heal without wiring anything: the mirror hole it + * repairs (rescan dropping change outputs of CoinJoin-funded sends) + * becomes a fund-loss on the next engine reload if any host forgets + * to call it. Failures are logged and re-tried on the next cadence + * tick — never allowed to kill the progress poll. + */ + private fun maybeReconcileTxoStores(progress: SpvSyncProgressData) { + val synced = progress.overallState == SpvSyncState.SYNCED + val transitioned = synced && !txoReconcileWasSynced + txoReconcileWasSynced = synced + if (!synced) return + val now = System.currentTimeMillis() + if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return + val tipHeight = (progress.filters?.currentHeight ?: 0L).toInt() + if (tipHeight <= 0) return + val walletIds = wallets.value.values.map { it.walletId } + if (walletIds.isEmpty()) return + lastTxoReconcileAtMs = now + scope.launch { + for (walletId in walletIds) { + runCatching { reconcileTxoStore(walletId, tipHeight) } + .onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile failed for wallet ${walletId.toHex()}", + t, + ) + } + } + } + } + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2327,6 +2399,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 5e4486e56b..a9091d46ce 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 @@ -4894,6 +4894,63 @@ class PlatformWalletPersistenceHandlerTest { ) } + // ── TXO-store reconcile (the job-flower change-drop repair) ─────── + + private val changeTxid = ByteArray(32) { 7 } + private val reconcileTip = 1_536_950 + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isLocked":false}],"errors":[]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + // A send record born blind to its own change output: netAmount + // persisted as the full input value (the job-flower 6cef55ab… + // shape) and NO txos row for the change. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + ), + ) + + val report = handler.reconcileTxos( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + assertEquals(1, report.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 @@ -4941,4 +4998,274 @@ class PlatformWalletPersistenceHandlerTest { ) } } + + @Test + fun reconcileIsIdempotentAndNeverDoubleCredits() = runTest { + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1), + netAmount = -1_000_010_000L, + ), + ) + val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) + + handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileTxos(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, second.inserted) + assertEquals(0, second.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.reconcileTxos( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3), + tipHeight = reconcileTip, + ) + assertEquals(0, fresh.inserted) + assertEquals(1, fresh.skippedImmature) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 0))) + + // A row the mirror already holds — even marked spent while the + // engine still lists it — is left untouched: reconcile is + // insert-only and never flips spend state. + assertEquals( + 0, + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 2, 42L, "yTestAddr", byteArrayOf(0x51), 1_500_000, + false, true, false, false, + ), + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileTxos( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), + tipHeight = reconcileTip, + ) + assertEquals(0, report.inserted) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) + } + + /** Engine inventory JSON with both halves: unspent rows and spent outpoints. */ + private fun engineInventoryJson(unspent: List>, spent: List>): String { + val utxoRows = unspent.joinToString(",") { (txid, vout, amount) -> + """{"typeTag":0,"standardTag":0,"index":0,"txid":"$txid","vout":$vout,""" + + """"amount":$amount,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd",""" + + """"scriptHex":"76a914000088ac","height":1400000,"isLocked":false}""" + } + val spentRows = spent.joinToString(",") { (txid, vout) -> + """{"txid":"$txid","vout":$vout}""" + } + return """{"utxos":[$utxoRows],"spent":[$spentRows],"errors":[]}""" + } + + @Test + fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { + // A store row still marked unspent for a coin the engine records as + // spent (dashpay/platform#4425). The engine's spent set includes + // MEMPOOL spends and carries no context, so persisting the flip + // would settle an unconfirmed spend — counted and logged only. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) + assertNull(row.spendingTxid) + } + + @Test + fun reconcileDoesNotCountSweptRefusalsAsHeals() = runTest { + // The engine offers a UTXO whose parent transaction the store holds + // globally swept: the shared insert discipline refuses the row, and + // the reconcile must not count a heal that did not happen — nor + // flag its netAmount. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + isGloballySwept = true, + ), + ) + val report = handler.reconcileTxos( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + assertEquals(0, report.inserted) + assertEquals(0L, report.insertedDuffs) + assertEquals(0, report.netAmountSuspects) + assertEquals(1, report.skippedSwept) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1))) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + + @Test + fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction (pre-rust-dashcore#971 + // stores). Counted and logged, NEVER removed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } + + @Test + fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // insert pass would skip as immature — are consistent, not + // divergence. Every reverse-pass counter must be zero. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isLocked":false}],"spent":[],"errors":[]}""" + val report = handler.reconcileTxos(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.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) + } + + @Test + fun reconcileResolvesContactOwnershipThroughCoreAddressId() = runTest { + // Production changeset writes leave txos.accountId null and route + // ownership through coreAddressId -> core_addresses.accountId. The + // exclusion must resolve that path, or every contact row gets + // classified as divergence. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 1, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactRouted", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/0", + isUsed = true, + accountId = foreignAccountId, + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 8, 990_000L, "yContactRouted", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! + assertNull("production shape: accountId is null", seeded.accountId) + + val report = handler.reconcileTxos( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + } + + @Test + fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { + // A row marked spent while the engine lists the coin unspent: either + // a lost release event (pre-rust-dashcore#971) or a live spend the + // store wrote before the engine settled. Un-marking a coin + // mid-payment would let the wallet double-spend it, so this is + // counted and logged but NEVER changed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 7, 77_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileTxos( + walletId, + engineInventoryJson( + unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), + spent = emptyList(), + ), + tipHeight = reconcileTip, + ) + assertEquals(1, report.stuckSpent) + assertEquals(77_000L, report.stuckSpentDuffs) + assertTrue( + "the row must stay spent — un-marking is never done by reconciliation", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!!.isSpent, + ) + } } diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc..2e4f6eaee9 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -28,8 +28,9 @@ use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutPointFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, + TrackedAssetLockEntryFFI, WalletIdentityRowFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -610,6 +611,65 @@ pub unsafe extern "C" fn platform_wallet_account_utxos_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(utxos, count)); } +/// The account's spent-outpoint inventory — the second half of the +/// store-reconcile surface (`platform_wallet_account_utxos` is the unspent +/// half). A persistence-mirror row still marked unspent whose outpoint +/// appears here lost its spend update (dashpay/platform#4425); a row in +/// NEITHER inventory is swept/abandoned residue (pre-rust-dashcore#971 +/// stores). Free with `platform_wallet_account_spent_outpoints_free`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(spec); + check_ptr!(out_outpoints); + check_ptr!(out_count); + *out_outpoints = std::ptr::null(); + *out_count = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let target = match account_type_from_spec_ref(&*spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + e, + ); + } + }; + let Some(rows) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.account_spent_outpoints_blocking(&wid, &target)) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows.iter().map(OutPointFFI::from).collect(); + let count = entries.len(); + *out_outpoints = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_account_spent_outpoints_free( + outpoints: *mut OutPointFFI, + count: usize, +) { + if outpoints.is_null() || count == 0 { + return; + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(outpoints, count)); +} + // --------------------------------------------------------------------------- // Phase 6 — Per-account transactions // --------------------------------------------------------------------------- diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index efb7e3d20d..06ee52f0bf 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3891,6 +3891,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; @@ -4077,11 +4078,102 @@ unsafe fn restore_core_address_pools( } } } + // Resolve the pool's key source BEFORE taking the mutable pool + // borrow, for the hole-repair pass below. Degrades to NoKeySource + // for anything unresolvable (no signing wallet handle, provider + // pools without public derivation, etc.) — repair is then skipped. + let key_source = signing_wallet + .and_then(|wallet| { + key_wallet::transaction_checking::transaction_router::AccountTypeToCheck::try_from( + &*managed_type, + ) + .ok() + .map(|check_type| { + let account_index = match &account_type { + AccountType::Standard { + index, .. + } + | AccountType::CoinJoin { + index, + } + | AccountType::DashpayReceivingFunds { + index, .. + } + | AccountType::DashpayExternalAccount { + index, .. + } => Some(*index), + AccountType::IdentityTopUp { + registration_index, + } => Some(*registration_index), + _ => None, + }; + wallet.key_source_for_account_type(&check_type, account_index) + }) + }) + .unwrap_or(key_wallet::KeySource::NoKeySource); + let mut managed_pools = managed_type.address_pools_mut(); match managed_pools.iter_mut().find(|p| p.pool_type == pool_type) { Some(pool) => { pools_routed += infos.len(); restore_address_pool(pool, infos); + // Hole repair: mirrors have been observed dropping address + // rows (2026-08-19 field wallet: BIP44-change rows 875..=890 + // absent between surviving rows), and ingesting the sparse + // list as-is makes outputs paying the missing addresses + // permanently unrecognizable — a rescan-proof fund loss — + // while the row-derived `highest_generated` suppresses the + // gap-limit re-derivation that would repair it. Derivation + // is pure key arithmetic, so re-derive every missing index + // up to the persisted watermark. Never fatal: a failed + // repair restores exactly what the rows carried (the + // pre-repair behavior). + let repairable = !matches!(key_source, key_wallet::KeySource::NoKeySource) + && !matches!(pool_type, AddressPoolType::AbsentHardened); + if !repairable { + // Announce the skip instead of silently claiming full + // coverage. DashPay contact pools land here by design — + // `key_source_for_account_type` returns NoKeySource for + // both DashPay variants (their keys derive from identity + // material, not an account xpub) — and their pools are + // re-derived by DashPay contact sync at runtime, so a + // sparse restore self-heals through that path instead. + // Hardened pools cannot be publicly derived at all. + tracing::info!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + "load: address-pool hole repair skipped (no public key source); pool restored as persisted" + ); + } + if repairable { + if let Some(max_idx) = pool.highest_generated { + match pool.ensure_contiguous_to(max_idx, &key_source) { + Ok(0) => {} + Ok(filled) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + filled, + "load: repaired address-pool holes left by dropped \ + persisted rows; outputs paying these addresses are \ + recognizable again" + ); + } + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + ?account_type, + ?pool_type, + error = %e, + "load: address-pool hole repair failed; pool restored \ + as persisted (sparse)" + ); + } + } + } + } } None => { pools_dropped += 1; @@ -4544,7 +4636,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), + )?; } } @@ -7412,7 +7510,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/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 72e7b62299..2543ddc724 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -794,16 +794,24 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Inserted records bring fresh UTXOs and may consume previous + // ones. Updated records CAN change UTXO topology too: a gap-limit + // rescan re-processing a block can newly attribute an output the + // first processing recorded as Sent (its address was beyond the + // watch window then) — key-wallet corrects the record in place + // and re-emits it here (rust-dashcore fix/key-wallet-rescan- + // changeset). Deriving from `updated` as well forwards that + // correction; for ordinary re-confirmations it re-emits the same + // UTXOs, which the persisters absorb idempotently (the Kotlin + // handler's upsertUtxoRow preserves spend linkage, and a + // spend-first output is flipped spent by its deferred-input + // drain). + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. + // Matured coinbase records: no UTXO topology change, just a + // status update for the persister. // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the @@ -1970,6 +1978,56 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); } + /// A gap-limit rescan correction arrives as an `updated` record whose + /// output roles flipped from Sent to Received/Change (key-wallet + /// fix/key-wallet-rescan-changeset). The bridge must derive its UTXOs — + /// with `inserted`-only derivation the corrected record row lands but + /// the store's TXO set keeps the hole, which is the reload fund-loss + /// this whole chain exists to prevent. + #[tokio::test] + async fn updated_record_correction_contributes_its_utxos() { + let tx = tx_with(&[ + (&our_receive_address(), PAID_TO_CONTACT), + (&our_change_address(), CHANGE), + ]); + let corrected = record( + &tx, + bip44_account_0(), + TransactionDirection::Internal, + vec![our_input()], + vec![ + output( + 0, + OutputRole::Received, + &our_receive_address(), + PAID_TO_CONTACT, + ), + output(1, OutputRole::Change, &our_change_address(), CHANGE), + ], + (PAID_TO_CONTACT + CHANGE) as i64 - FUNDING as i64, + ); + let event = WalletEvent::BlockProcessed { + wallet_id: WALLET_ID, + height: 1_001, + chain_lock: None, + inserted: vec![], + updated: vec![corrected], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&test_manager(), &event).await; + + assert_eq!( + cs.new_utxos.len(), + 2, + "an updated (corrected) record's owned outputs must reach the store" + ); + assert_eq!(cs.spent_utxos.len(), 1, "its spent input is re-marked idempotently"); + assert_eq!(cs.records.len(), 1, "and the corrected row itself is re-emitted"); + } + /// A contact spending an output that a *pre-fix* build already /// persisted must still clear that stale row, so `derive_spent_utxos` /// stays deliberately unfiltered. Only the transaction row and the diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 7b9c642282..c4d16ebaf9 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -817,6 +817,35 @@ impl PlatformWalletManager

{ .collect() } + /// The outpoints this account knows were spent by recorded + /// transactions — the second half of the store-reconcile inventory + /// ([`Self::account_utxos_blocking`] is the unspent half). Lets a + /// persistence-mirror audit classify a store row marked unspent: + /// present here → the row lost its spend update (flip it, + /// dashpay/platform#4425); present in neither inventory → residue of a + /// swept/abandoned transaction (pre-rust-dashcore#971 stores). + pub fn account_spent_outpoints_blocking( + &self, + wallet_id: &WalletId, + target: &AccountType, + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + let Some(info) = wm.get_wallet_info(wallet_id) else { + return Vec::new(); + }; + let accounts = info.core_wallet.accounts.all_accounts(); + let Some(account) = accounts + .iter() + .find(|a| &a.managed_account_type().to_account_type() == target) + else { + return Vec::new(); + }; + let Some(funds) = account.as_funds() else { + return Vec::new(); + }; + funds.spent_outpoints().iter().copied().collect() + } + // ----------------------------------------------------------------- // Phase 6 — Per-account transactions // ----------------------------------------------------------------- diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2..dfe89a8e32 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -17,6 +17,10 @@ rs-sdk-ffi = { path = "../rs-sdk-ffi" } platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } key-wallet-ffi = { workspace = true } dash-network = { workspace = true, features = ["ffi"] } +# Address encoding for the reconcile sweep's engine-UTXO export +# (walletManagerAllUtxosJson) — already in the graph via +# platform-wallet-ffi, so this adds no new build cost. +dashcore = { workspace = true } log = "0.4" zeroize = "1" @@ -24,7 +28,6 @@ zeroize = "1" android_logger = "0.14" [dev-dependencies] -dashcore = { workspace = true } # Anchors the cross-language golden-fixture test to the canonical DashPay # contract id, so the mirrored Kotlin constant can't drift undetected. dashpay-contract = { path = "../dashpay-contract" } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1cc5801db3..6274233d95 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3152,6 +3152,261 @@ fn core_selection_strategy( } } +/// `platform_wallet_account_utxos` swept across every account — the +/// engine-side UTXO inventory `PlatformWalletManager.reconcileTxoStore` +/// diffs against the Room `txos` mirror (dropped change outputs of +/// CoinJoin-funded sends leave the mirror short; the engine reloads from +/// that mirror on restart, so an un-reconciled hole becomes a fund-loss). +/// Returns a JSON object `{"utxos":[...],"errors":[...]}` — one `utxos` +/// row per output the engine currently holds, tagged with its owning +/// account. Accounts are enumerated with the same `get_account_balances` +/// sweep the DashPay tab uses; keys-only accounts return no UTXOs and +/// contribute nothing. `network` follows `Network.ffiValue` (0 mainnet, +/// 2 devnet, 3 regtest, else testnet) and selects the address encoding; +/// an output whose script has no address form carries an empty `address` +/// for the caller to skip. A per-account read failure lands in `errors` +/// instead of failing the sweep — the reconciler must still see every +/// account that DID read, so one faulted account cannot mask the others' +/// repair. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerAllUtxosJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + network: jni::sys::jint, +) -> jni::sys::jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + let net = match network { + 0 => dashcore::Network::Mainnet, + 2 => dashcore::Network::Devnet, + 3 => dashcore::Network::Regtest, + _ => dashcore::Network::Testnet, + }; + let mut entries: *const platform_wallet_ffi::AccountBalanceEntryFFI = ptr::null(); + let mut count: usize = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_get_account_balances( + manager_handle as Handle, + wid.as_ptr(), + &mut entries, + &mut count, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::new(); + let mut spent_rows: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + if !entries.is_null() && count > 0 { + let accounts = unsafe { std::slice::from_raw_parts(entries, count) }; + for acc in accounts { + let spec = platform_wallet_ffi::AccountSpecFFI { + type_tag: acc.type_tag as u8, + standard_tag: acc.standard_tag as u8, + index: acc.index, + registration_index: acc.registration_index, + key_class: acc.key_class, + user_identity_id: acc.user_identity_id, + friend_identity_id: acc.friend_identity_id, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }; + let mut utxos: *const platform_wallet_ffi::AccountUtxoEntryFFI = ptr::null(); + let mut utxo_count: usize = 0; + let res = unsafe { + platform_wallet_ffi::platform_wallet_account_utxos( + manager_handle as Handle, + wid.as_ptr(), + &spec, + &mut utxos, + &mut utxo_count, + ) + }; + if let Some(msg) = pwffi_error_message(res) { + errors.push(format!( + "{{\"typeTag\":{},\"index\":{},\"message\":{}}}", + acc.type_tag as u8, + acc.index, + json_escape(&msg), + )); + continue; + } + if utxos.is_null() || utxo_count == 0 { + continue; + } + let items = unsafe { std::slice::from_raw_parts(utxos, utxo_count) }; + for u in items { + let script: &[u8] = if u.script_pubkey.is_null() || u.script_pubkey_len == 0 { + &[] + } else { + unsafe { + std::slice::from_raw_parts(u.script_pubkey, u.script_pubkey_len) + } + }; + let script_buf = dashcore::ScriptBuf::from(script.to_vec()); + let address = dashcore::Address::from_script(&script_buf, net) + .map(|a| a.to_string()) + .unwrap_or_default(); + rows.push(format!( + "{{\"typeTag\":{},\"standardTag\":{},\"index\":{},\ + \"txid\":\"{}\",\"vout\":{},\"amount\":{},\ + \"address\":{},\"scriptHex\":\"{}\",\ + \"height\":{},\"isLocked\":{}}}", + acc.type_tag as u8, + acc.standard_tag as u8, + acc.index, + hex_lower(&u.outpoint_txid), + u.outpoint_vout, + u.value_duffs, + json_escape(&address), + hex_lower(script), + u.height, + u.is_locked, + )); + } + unsafe { + platform_wallet_ffi::platform_wallet_account_utxos_free( + utxos as *mut platform_wallet_ffi::AccountUtxoEntryFFI, + utxo_count, + ) + }; + } + // Second inventory half: the engine's spent outpoints, so the + // reconcile can classify a store row still marked unspent — + // present here means the row lost its spend update + // (dashpay/platform#4425, flip it); present in neither + // inventory means swept/abandoned residue + // (pre-rust-dashcore#971 stores, log-only). Soft-fail like the + // UTXO loop: one bad account must not mask the rest. + for acc in accounts { + let spec = platform_wallet_ffi::AccountSpecFFI { + type_tag: acc.type_tag as u8, + standard_tag: acc.standard_tag as u8, + index: acc.index, + registration_index: acc.registration_index, + key_class: acc.key_class, + user_identity_id: acc.user_identity_id, + friend_identity_id: acc.friend_identity_id, + account_xpub_bytes: ptr::null(), + account_xpub_bytes_len: 0, + }; + let mut outpoints: *const platform_wallet_ffi::OutPointFFI = ptr::null(); + let mut spent_count: usize = 0; + let res = unsafe { + platform_wallet_ffi::platform_wallet_account_spent_outpoints( + manager_handle as Handle, + wid.as_ptr(), + &spec, + &mut outpoints, + &mut spent_count, + ) + }; + if let Some(msg) = pwffi_error_message(res) { + errors.push(format!( + "{{\"typeTag\":{},\"index\":{},\"message\":{}}}", + acc.type_tag as u8, + acc.index, + json_escape(&msg), + )); + continue; + } + if outpoints.is_null() || spent_count == 0 { + continue; + } + let items = unsafe { std::slice::from_raw_parts(outpoints, spent_count) }; + for op in items { + spent_rows.push(format!( + "{{\"txid\":\"{}\",\"vout\":{}}}", + hex_lower(&op.txid), + op.vout, + )); + } + unsafe { + platform_wallet_ffi::platform_wallet_account_spent_outpoints_free( + outpoints as *mut platform_wallet_ffi::OutPointFFI, + spent_count, + ) + }; + } + } + unsafe { + platform_wallet_ffi::platform_wallet_manager_free_account_balances( + entries as *mut platform_wallet_ffi::AccountBalanceEntryFFI, + count, + ) + }; + let json = format!( + "{{\"utxos\":[{}],\"spent\":[{}],\"errors\":[{}]}}", + rows.join(","), + spent_rows.join(","), + errors.join(","), + ); + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Extract-and-free a `PlatformWalletFFIResult`'s error message WITHOUT +/// throwing — the per-account soft-fail path of +/// [`Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerAllUtxosJson`] +/// reports account faults in-band so the sweep keeps going. `None` on +/// success. +fn pwffi_error_message( + mut result: platform_wallet_ffi::PlatformWalletFFIResult, +) -> Option { + if result.code == platform_wallet_ffi::PlatformWalletFFIResultCode::Success { + return None; + } + let message = if result.message.is_null() { + format!("platform-wallet error (code {})", result.code as i32) + } else { + // SAFETY: non-null message is a valid CString produced by the FFI. + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + // SAFETY: `result` is a fresh PlatformWalletFFIResult; free its message. + unsafe { platform_wallet_ffi::platform_wallet_ffi_result_free(&mut result) }; + Some(message) +} + +/// Lower-hex of a byte slice (txid bytes are emitted in the same order +/// the changeset path hands Kotlin, so hex→bytes on the Kotlin side +/// reproduces the exact `txos.txid` blob). +fn hex_lower(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Minimal JSON string escape (quotes, backslash, control chars) — the +/// values here are base58/bech32 addresses and FFI error strings. +fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the /// wrong length or a JNI error. fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> {