diff --git a/Cargo.lock b/Cargo.lock index 9494d1ec34a..f9f3486fe43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6643,11 +6643,14 @@ dependencies = [ "dashcore", "dashpay-contract", "dpp", + "hex", "jni 0.21.1", "key-wallet-ffi", "log", "platform-wallet-ffi", "rs-sdk-ffi", + "serde", + "serde_json", "zeroize", ] diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index f662cfae2fe..04ffd754f7f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -437,6 +437,31 @@ abstract class NativePersistenceBridge { */ open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0 + /** + * The engine's credit verdicts for the round (dashpay/platform#4638): + * every `Received` / `Change` output of the round's records that the + * engine did NOT credit to the owning account. Fired inside the + * begin/end bracket BEFORE the changeset callback, and only on rounds + * that carry at least one verdict, so an implementation has them in + * hand when the round's [onWalletChangesetUtxoAdded] entries arrive. + * Descriptor `([B[B[B[II)I`. + * + * [outpoints] is [count] 36-byte keys (32-byte wire-order txid plus a + * little-endian vout) packed into one array; [verdicts] holds one + * `UTXO_CREDIT_VERDICT_*` code per outpoint (1 observed spent, 2 + * doomed, 3 uncredited) and [spentAtHeights] the observed spending + * block's height for code 1, 0 otherwise. Purely additive: a host that + * ignores the verdicts writes exactly what it wrote before the slot + * existed. + */ + open fun onWalletChangesetUtxoVerdicts( + walletId: ByteArray, + outpoints: ByteArray, + verdicts: ByteArray, + spentAtHeights: IntArray, + count: Int, + ): Int = 0 + /** * The wallet's DashPay coreHeight backfill record — the durable half of * the contact rescan guard (dashpay/platform#4302). Fired inside the 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 58351c59fbb..1bff52c612d 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,150 @@ internal object WalletManagerNative { gapLimit: Int, ) + // ── TXO-store reconcile (dashpay/platform#4638) ───────────────────── + // + // One export = one `platform-wallet` call. The schedule, the start + // gates, the heal verdicts and the stale-page retry limit all live in + // `platform_wallet::manager::txo_reconcile`; Kotlin pages its own Room + // rows, resolves owners and writes. + + /** [walletManagerTxoReconcileBegin]: the run may start + * (`TXO_RECONCILE_GATE_RUN`). */ + const val TXO_RECONCILE_GATE_RUN = 0 + + /** + * The wallets whose reconcile is due now, as concatenated 32-byte ids + * (`platform_wallet_manager_txo_reconcile_due_wallets`). Called on every + * SPV progress poll, running or not: the poll is the schedule's clock, + * and the native side detects the steady-state edge and applies the + * cadence. Each returned wallet counts as scheduled now. + */ + external fun walletManagerTxoReconcileDueWallets(managerHandle: Long): ByteArray? + + /** + * Claim [walletId] for one reconcile run and check that it may start + * (`platform_wallet_manager_txo_reconcile_begin`). Returns + * [TXO_RECONCILE_GATE_RUN] or a `TXO_RECONCILE_SKIP_*` reason (see + * [org.dashfoundation.dashsdk.wallet.TxoReconcileSkipReason.fromGateCode]); + * on [TXO_RECONCILE_GATE_RUN] `outTipHeight[0]` is the scan tip to pass + * to [walletManagerTxoHealPageJson], and the claim holds until + * [walletManagerTxoReconcileFinish]. [outTipHeight] is an `IntArray(1)`. + */ + external fun walletManagerTxoReconcileBegin( + managerHandle: Long, + walletId: ByteArray, + outTipHeight: IntArray, + ): Int + + /** + * Release the claim a [TXO_RECONCILE_GATE_RUN] from + * [walletManagerTxoReconcileBegin] granted; [ran] is whether the run went + * ahead, which restarts the wallet's cadence + * (`platform_wallet_manager_txo_reconcile_finish`). + */ + external fun walletManagerTxoReconcileFinish(managerHandle: Long, walletId: ByteArray, ran: Boolean) + + /** + * Whether a reconcile run classifies a page again after it came back + * stale for the [staleInARow]-th time in a row, or stops incomplete + * (`platform_wallet_txo_reconcile_retry_stale_page`, which holds the + * limit). + */ + external fun txoReconcileRetryStalePage(staleInARow: Int): Boolean + + /** + * One bounded page of the engine's UTXO inventory for one wallet, + * across every funds account, each row with the engine's heal verdict + * at [tipHeight] (the tip [walletManagerTxoReconcileBegin] returned), as + * JSON `{"utxos":[...],"cursor":,"hasMore":}`. A + * serialization shim over one Rust call + * (`platform_wallet_txo_reconcile_heal_page`): account ordering, cursor + * semantics, the page bound, the address rendering and the verdicts all + * 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 steady-state transition and every cadence + * 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 + * asks for the engine's own default, and the engine clamps its own + * maximum, so no bound is mirrored on this side. + * + * Contact watch-only chains (`DashpayExternalAccount`) are absent by + * construction — those coins are the contact's money, not this + * wallet's — so the inventory never offers one to heal. + * + * 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), the engine's own + * Base58Check address (empty when the script has no address form), + * scriptHex, height, the engine's own `isConfirmed` / + * `isInstantLocked` / `isCoinbase` / `isLocked` flags, and its + * `healVerdict` (`TXO_HEAL_VERDICT_*`). + */ + external fun walletManagerTxoHealPageJson( + managerHandle: Long, + walletId: ByteArray, + cursor: String?, + limit: Int, + tipHeight: Int, + ): String? + + /** + * Classify a batch of store rows 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. + * + * [queriesJson] is a JSON array of + * [org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler.OutpointQuery]: + * for each row, the account tuple the STORE files the coin under, its + * txid hex, vout, and the scriptHex the store recorded. The account + * and script are the store's claim — the engine checks both against + * its own pools rather than trusting them, which is why a bare + * outpoint is not enough to ask the question. + * + * Returns one verdict byte per query, positionally — see + * `OUTPOINT_CLASS_*` on + * [org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler]: + * 0 unknown, 1 unspent, 2 known-uncredited, 3 not-owned. A query whose + * account tag this build cannot map keeps `unknown`; the rest are + * still answered. + * + * Only for the wallet's claimed reconcile run (between + * [walletManagerTxoReconcileBegin] and [walletManagerTxoReconcileFinish]). + * The answers come back with the engine's wallet lock still held for + * reading, so no fork can drop a spender they name before the page is + * applied. The guard is written to [outHeld] (a `LongArray(1)`; 0 when + * nothing is held) only together with the answers. Release it with + * [txoReconcileHeldVerdictsFree] once the page is applied and before + * the next call that takes [managerHandle]: those resolve the handle in + * the native registry, which must never be waited for while the guard + * is held. Finish releases a guard left behind, as a backstop. Call it + * outside the persistence handler's callback exclusion and never from + * inside a persistence callback: the engine calls those callbacks while + * it holds its wallet lock for writing. + */ + external fun walletManagerTxoReconcileClassifyOutpoints( + managerHandle: Long, + walletId: ByteArray, + queriesJson: String, + outHeld: LongArray, + ): ByteArray? + + /** + * Release the engine guard [walletManagerTxoReconcileClassifyOutpoints] + * handed out. Takes no manager handle and touches no native registry, + * so it completes whatever is queued there. 0 is a no-op; release each + * value once. + */ + external fun txoReconcileHeldVerdictsFree(held: Long) + // ── 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 62dfdf82093..d1149268322 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 @@ -4,6 +4,7 @@ import android.util.Log import androidx.room.withTransaction import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,6 +14,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +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 @@ -230,6 +242,47 @@ class PlatformWalletPersistenceHandler( * [collectFinalizedSweptTombstones] once, last. */ var finalityAdvanced: Boolean = false + + /** + * The engine's credit verdicts for this round (dashpay/platform#4638) + * — every `Received` / `Change` output the round's records carry that + * the engine did NOT credit to the owning account — keyed by the hex + * of the 36-byte outpoint, valued by the `UTXO_CREDIT_VERDICT_*` + * code. Delivered by [onWalletChangesetUtxoVerdicts] BEFORE the + * changeset callback, so [onWalletChangesetUtxoAdded] can hand each + * row its verdict as it stages it: a row for a coin the engine never + * held would otherwise be written unspent, restored into the engine + * at the next launch, and show as a phantom balance + * (rust-dashcore#992). An outpoint absent here is credited — the + * ordinary case. Dies with the buffer, so a rolled-back or abandoned + * round leaves nothing behind. + */ + val utxoCreditVerdicts: HashMap = HashMap() + + /** How [upsertUtxoRow] applied [utxoCreditVerdicts] — logged once, as + * counts, by [onChangesetEnd]. */ + val utxoCreditTally = UtxoCreditVerdictTally() + } + + /** + * Per-round tally of how [upsertUtxoRow] applied the engine's credit + * verdicts. Logged as counts only: the outpoints themselves are wallet + * history and never leave the store. + */ + internal class UtxoCreditVerdictTally { + /** Rows written spent on an observed-spent verdict. */ + var observedSpent = 0 + + /** Rows written spent on a doomed verdict. */ + var doomed = 0 + + /** Rows that were already spent when their verdict arrived. */ + var alreadySpent = 0 + + /** Context-free verdicts, which only vetoed the recovery clear. */ + var uncredited = 0 + + val total: Int get() = observedSpent + doomed + alreadySpent + uncredited } /** Open rounds keyed by walletId hex (a round is per-walletId). */ @@ -417,9 +470,25 @@ class PlatformWalletPersistenceHandler( runBlockingCatching { database.withTransaction { op(database) } } + // A standalone write commits store state exactly like a round + // does, so it stales a reconcile verdict read before it too. + committedRoundGeneration++ } } + /** + * Writes committed by persistence callbacks since this handler was + * built: every successful [onChangesetEnd] commit, and every standalone + * [stage] write. The TXO reconcile reads it together with each store + * page and asks the engine about that page OUTSIDE [callbackExclusion]; + * a round that commits in that gap may have re-credited one of the + * page's coins (a reorg of its spender hands it back in `utxos_added`), + * so the verdicts are applied only while the count is unchanged and no + * round is open for the wallet — see [classifyStoreRows]. Read and + * written only under [callbackExclusion]. + */ + private var committedRoundGeneration = 0L + /** The open round for [walletId], or null on the standalone-callback path. */ private fun openRound(walletId: ByteArray): ChangesetBuffer? = buffers[walletId.toHex()] @@ -473,18 +542,37 @@ class PlatformWalletPersistenceHandler( } // Rows committed — the aliases are discoverable the normal way, // and the round's pending-key state changes are now true. + committedRoundGeneration++ pendingRoundAliases.remove(key) publishPendingKeyDeltas(buffer) + logUtxoCreditTally(buffer.utxoCreditTally, roundSuccess = true) } catch (t: Throwable) { // Commit failed: the staged rows never landed, so the round's // aliases are orphans exactly like the !success branch — and its // pending-key deltas are equally void (discarded with the buffer). scrubPendingAliases(key) + logUtxoCreditTally(buffer.utxoCreditTally, roundSuccess = false) throw t } 0 } + /** + * One line per round that applied a credit verdict — the Kotlin twin of + * Swift's `persistence_txo_credit_verdicts` event. Counts only: no + * txid, outpoint, address or script. A rolled-back round never ran its + * staged writes, so it has nothing to report. + */ + private fun logUtxoCreditTally(tally: UtxoCreditVerdictTally, roundSuccess: Boolean) { + if (tally.total == 0) return + Log.i( + TAG, + "txo credit verdicts: observedSpent=${tally.observedSpent} " + + "doomed=${tally.doomed} alreadySpent=${tally.alreadySpent} " + + "uncredited=${tally.uncredited} roundSuccess=$roundSuccess", + ) + } + // on_store_fn / on_flush_fn have no Swift analog — they are // FFIPersister-level notifications. The begin/end bracket is the only @@ -1057,6 +1145,40 @@ class PlatformWalletPersistenceHandler( 0 } + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistWalletChangesetUtxoVerdicts` (dashpay/platform#4638 Part 1): + * stage the round's credit verdicts (see + * [ChangesetBuffer.utxoCreditVerdicts]). Fired by Rust inside the + * begin/end bracket BEFORE the changeset callback, only on rounds that + * carry at least one verdict. Nothing is written here — the verdicts + * are applied by [upsertUtxoRow] when the round's `utxos_added` entries + * arrive, and a verdict for an outpoint no entry names is simply dropped + * with the round. A malformed batch fails the round rather than losing + * a verdict: a dropped verdict is a phantom coin the store hands back + * to the engine at the next load. + */ + override fun onWalletChangesetUtxoVerdicts( + walletId: ByteArray, + outpoints: ByteArray, + verdicts: ByteArray, + spentAtHeights: IntArray, + count: Int, + ): Int = guarded { + require( + count >= 0 && outpoints.size == count * OUTPOINT_BYTES && + verdicts.size == count && spentAtHeights.size == count, + ) { "credit-verdict batch does not match its count ($count)" } + // Rust fires this slot only inside a round; with no round open there + // is no `utxos_added` delivery for the verdicts to scope. + val round = openRound(walletId) ?: return@guarded 0 + for (i in 0 until count) { + val outpoint = outpoints.copyOfRange(i * OUTPOINT_BYTES, (i + 1) * OUTPOINT_BYTES) + round.utxoCreditVerdicts[outpoint.toHex()] = verdicts[i] + } + 0 + } + override fun onWalletChangesetUtxoAdded( walletId: ByteArray, txid: ByteArray, @@ -1070,112 +1192,1018 @@ class PlatformWalletPersistenceHandler( isInstantLocked: Boolean, isLocked: Boolean, ): Int = guarded { + // The round's verdicts all arrived before this callback, so the + // engine's verdict on this output (if it did NOT credit it) is + // final here and travels with the staged write. + val round = openRound(walletId) + val creditVerdict = round?.utxoCreditVerdicts?.get(makeOutpoint(txid, vout).toHex()) 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, + creditVerdict = creditVerdict, + creditTally = round?.utxoCreditTally, + ) + } + 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, + // The engine's credit verdict on this output for the round + // delivering it (see [ChangesetBuffer.utxoCreditVerdicts]), or null + // when the engine credited it. Only the changeset callback passes + // one; the reconcile heal inserts coins the engine HOLDS. + creditVerdict: Byte? = null, + creditTally: UtxoCreditVerdictTally? = 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. + // + // Any credit verdict vetoes that clear (dashpay/platform#4638): the + // wallet is not handing this coin back as unspent — its record + // merely still names the output as ours. An observed-spent or + // doomed verdict is block-context evidence the coin is not + // spendable (a block spent it before the output was recognised — + // the spender may never have been recorded, rust-dashcore#992 — or + // the record can never confirm), so the row is written spent with + // no spender link. `isSpent` is monotonic: a row already spent is + // left as it is. An uncredited verdict carries no context and only + // keeps the clear from resurrecting the row. + val linked = existing?.spendingTxid != null + val keepSpentState = linked || (existing != null && creditVerdict != null) + val alreadySpent = keepSpentState && existing!!.isSpent + val bornSpent = creditVerdict == UTXO_CREDIT_VERDICT_OBSERVED_SPENT || + creditVerdict == UTXO_CREDIT_VERDICT_DOOMED + if (creditVerdict != null && creditTally != null) { + when { + !bornSpent -> creditTally.uncredited++ + alreadySpent -> creditTally.alreadySpent++ + creditVerdict == UTXO_CREDIT_VERDICT_OBSERVED_SPENT -> creditTally.observedSpent++ + else -> creditTally.doomed++ + } + } + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + isSpent = alreadySpent || bornSpent, + 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 (keepSpentState) 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 + * `walletManagerTxoHealPageJson` 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 + * [findReconcileAccount] resolves the owning Room account by, so every + * field of it is required except the DashPay identity halves, which are + * absent on every non-DashPay account. [healVerdict] is the engine's + * decision whether the store may heal the coin in + * (`TXO_HEAL_VERDICT_*`, `platform_wallet::manager::txo_reconcile`), and + * is required too: a missing verdict must not decode as one. + */ + @Serializable + data class EngineUtxoRow( + val typeTag: Int, + val standardTag: Int, + val index: Int, + val registrationIndex: Int, + val keyClass: Int, + 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, + /** The engine's own flags for the coin. Stamped onto a healed row + * as given: guessing them (a coinbase row filed as non-coinbase) + * would misstate maturity in the mirror the engine reloads from. */ + val isConfirmed: Boolean = false, + val isInstantlocked: Boolean = false, + val isCoinbase: Boolean = false, + val isLocked: Boolean = false, + val healVerdict: Int, + ) + + /** + * One store row handed to `walletManagerTxoReconcileClassifyOutpoints`: + * the account tuple the STORE files the coin under, its outpoint, and + * the script the store recorded for it. The account and script are the + * store's claim — the engine checks both against its own pools rather + * than trusting them, which is why a bare outpoint cannot ask the + * question. + * Serialized against the same field names the Rust `OutpointQuery` + * deserializes. + * + * Only the identity halves have defaults. The Rust tuple requires every + * other account field, and kotlinx's default `Json` omits a property + * equal to its default — so a defaulted `standardTag`/`index`/… went + * out missing for the common BIP44 account-0 row and the native side + * rejected the whole batch. + */ + @Serializable + data class OutpointQuery( + val typeTag: Int, + val standardTag: Int, + val index: Int, + val registrationIndex: Int, + val keyClass: Int, + val userIdentityId: String? = null, + val friendIdentityId: String? = null, + val txid: String, + val vout: Int, + val scriptHex: String, + ) + + /** + * 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, + ) + + /** + * What one [reconcileTxos] run did — the Kotlin twin of Swift's + * `CoreTxoReconcileReport` (dashpay/platform#4638). Counts only: no + * outpoint, address or amount of an individual coin leaves the store + * through this type or the log lines built from it. + */ + data class TxoReconcileReport( + // Heal pass (engine → store, insert-only). + /** Engine inventory rows walked. */ + val engineRows: Int = 0, + /** Engine coins the store already held. */ + val alreadyPresent: Int = 0, + /** Engine coins inserted unspent. A non-zero value after a completed + * sync means a changeset failed to deliver an owned output (the + * CoinJoin-funded-send change-drop class), which would have become + * a fund-loss on the next engine reload from this store. */ + val inserted: Int = 0, + val insertedDuffs: Long = 0, + /** Engine coins the store lacked whose row the pending-input drain + * wrote spent on insert: the divergence is recorded, not repaired. */ + val healedSpent: Int = 0, + /** Engine coins the engine calls immature (not confirmed, or not + * deep enough yet) — they age into a later run. */ + val skippedImmature: Int = 0, + /** Engine coins whose account has no store row to file them under. + * Never inserted unowned: the restore loader routes by account, so + * an unowned row would be dropped at the next launch. */ + val skippedUnresolvedAccount: Int = 0, + /** Engine coins the engine calls invalid (no script, no address), + * or whose fields do not decode (txid not 32 bytes). */ + val skippedInvalid: Int = 0, + // Classify pass (store → engine, flip-only). + /** Unspent store rows of this wallet handed to the engine. */ + val storeRows: Int = 0, + /** Store rows the engine reported unspent — consistent. */ + val unspent: Int = 0, + /** Store rows marked spent on the engine's positive + * [OUTPOINT_CLASS_KNOWN_UNCREDITED] verdict. */ + val flipped: Int = 0, + val flippedDuffs: Long = 0, + /** Store rows the engine has no opinion about — left untouched. + * Watch-only DIP-15 contact rows land here: the engine never gives + * a contact's coin a verdict. */ + val unknown: Int = 0, + /** Store rows filed under an account that does not monitor their + * script — left untouched, reported. */ + val notOwned: Int = 0, + /** Unspent store rows whose owning account resolves through neither + * the explicit link nor the address projection. They carry no claim + * for the engine to check, so they are counted, not asked about. */ + val unresolvedAccount: Int = 0, + // Run shape. + /** Pages classified again because a persistence round committed + * between their read and their apply, or was still open at it. */ + val staleRetries: Int = 0, + /** Engine reads that failed (an inventory page, the first one + * included, or a classification batch); the run stops at the first. */ + val transportFailures: Int = 0, + /** Store steps that failed and rolled back; the run stops at the first. */ + val storeFailures: Int = 0, + /** `false` when the run stopped early (cancelled, a failed read or + * write, or too many stale pages); what landed before the stop stays. */ + val completed: Boolean = true, + ) { + /** Rows this run wrote: healed, flipped, and healed rows the drain + * wrote spent on insert — the last is a divergence recorded, not + * repaired, but it is a row the store did not have before. */ + val mutations: Int get() = inserted + healedSpent + flipped + } + + /** The mutable half of a [TxoReconcileReport] while the run is in flight. */ + private class ReconcileTally { + var engineRows = 0 + var alreadyPresent = 0 + var inserted = 0 + var insertedDuffs = 0L + var healedSpent = 0 + var skippedImmature = 0 + var skippedUnresolvedAccount = 0 + var skippedInvalid = 0 + var storeRows = 0 + var unspent = 0 + var flipped = 0 + var flippedDuffs = 0L + var unknown = 0 + var notOwned = 0 + var unresolvedAccount = 0 + var staleRetries = 0 + var transportFailures = 0 + var storeFailures = 0 + var completed = true + + fun toReport() = TxoReconcileReport( + engineRows = engineRows, + alreadyPresent = alreadyPresent, + inserted = inserted, + insertedDuffs = insertedDuffs, + healedSpent = healedSpent, + skippedImmature = skippedImmature, + skippedUnresolvedAccount = skippedUnresolvedAccount, + skippedInvalid = skippedInvalid, + storeRows = storeRows, + unspent = unspent, + flipped = flipped, + flippedDuffs = flippedDuffs, + unknown = unknown, + notOwned = notOwned, + unresolvedAccount = unresolvedAccount, + staleRetries = staleRetries, + transportFailures = transportFailures, + storeFailures = storeFailures, + completed = completed, + ) + } + + /** Counts from one heal step; merged into the run only once it commits. */ + private class HealCounts { + var alreadyPresent = 0 + var inserted = 0 + var insertedDuffs = 0L + var healedSpent = 0 + var skippedImmature = 0 + var skippedUnresolvedAccount = 0 + var skippedInvalid = 0 + val healedOutpoints = ArrayList() + val healedSpentOutpoints = ArrayList() + } + + /** + * One page of the wallet's unspent store rows for the classify pass: + * the rows the engine is asked about with their queries, what the page + * set aside, the cursor to continue from, and [committedRoundGeneration] + * as read together with the rows. + */ + private class UnspentPage( + val rows: List, + val queries: List, + /** Rows the page read before setting any aside. */ + val fetched: Int, + val lastOutpoint: ByteArray?, + val unresolvedAccount: Int, + val generation: Long, + ) + + /** Counts from one classify-apply step. */ + private class FlipCounts { + var flipped = 0 + var flippedDuffs = 0L + var unspent = 0 + var unknown = 0 + var notOwned = 0 + + /** Rows that changed under the walk (already spent, or gone). */ + var stale = 0 + + /** A round committed between the read and the apply, or one is + * open at the apply: nothing was written and the page is + * classified again. */ + var staleGeneration = false + val flippedOutpoints = ArrayList() + } + + /** + * Reconcile the Room `txos` mirror against the engine — the Kotlin port + * of Swift's `runCoreTxoReconcile` (dashpay/platform#4638 Part 2). The + * mirror is write-behind and is itself the source the engine is + * restored from at every launch ([buildUtxoRestoreData] hands back every + * `isSpent = 0` row), so two failure classes outlive a full scan: a coin + * the engine credited whose row the store never got (the job-flower + * 106.43→86.33 restart drop — rescan dropping the change outputs of + * CoinJoin-funded sends), and a row the store holds unspent for a coin + * the engine never credited (rust-dashcore#992: the spender had no + * wallet-owned output and was discarded before the coin was known). The + * restore hands the second back, and the engine shows a balance it had + * just corrected (dashpay/platform#4575). This repairs exactly what + * positive evidence supports: + * + * * [healMissingTxos] pages the ENGINE ([engineUtxoPage]: `cursor` null + * to start, then the cursor the previous page returned while its + * `hasMore` is true) and inserts each coin the store lacks that the + * engine's verdict calls eligible and whose owning account the store + * resolves. + * * [classifyStoreRows] pages the wallet's UNSPENT store rows and asks + * [classifyOutpoints] about each page. Only + * [OUTPOINT_CLASS_KNOWN_UNCREDITED] — the owning account recorded the + * funding transaction, owns the script, does not hold the coin, AND a + * funds account holds a mined record spending it — marks a row spent. + * The engine answers under a read guard on its wallet lock and keeps + * it until [releaseVerdicts], so the page is applied before any fork + * can drop the spender a verdict names. + * + * Every decision is the engine's (`platform_wallet::manager::txo_reconcile` + * and `classify_outpoints`): which coins may be healed, which rows to + * flip, and — through [retryStalePage] — how often a stale page is + * classified again. This pages the store, resolves owners, and writes. + * + * It never deletes a row, never un-marks a spent row, and never acts on + * absence alone. It is idempotent (a consistent store yields a report + * with zero [TxoReconcileReport.mutations]) and bounded in both + * directions: a wallet's UTXO count is chain-controlled, so a pass that + * materialized a whole inventory would let anyone who knows a watched + * address decide how much a phone allocates. + * + * Engine reads run OUTSIDE [callbackExclusion] — they park on the + * engine's wallet lock, which a persistence callback may hold while it + * waits for ours. Every store step runs under it, one Room transaction + * each, after re-checking [isCancelled]: the manager bumps its epoch on + * close and on wallet deletion, and the run stops between pages, + * reporting `completed = false`. + * + * Lock order, the same as the engine's own: the native handle registry + * (read only transiently, inside one native call, to resolve the + * manager), then the engine's wallet lock, then [callbackExclusion], + * then the Room transaction. The engine calls persistence callbacks — + * which take [callbackExclusion] — while it holds its wallet lock for + * writing; [classifyOutpoints] takes that lock for reading and keeps it + * until [releaseVerdicts]. So a classification is only ever asked for + * outside [callbackExclusion] (never wait for the engine while holding + * it), never from inside a persistence callback, and its guard is + * released after the apply step has let go of [callbackExclusion]. Every + * other native path takes the registry before the engine lock, so no + * path that holds the guard may wait for the registry: [releaseVerdicts] + * must not resolve the manager handle (the native free it calls takes + * none), and it runs before the next page's classification or the + * run's finish resolve it again. The guard covers one page: the apply + * is one Room transaction. + * + * A failed engine read stops the run and is counted, the first page + * included (Swift's contract: the report says the run did not + * complete, rather than there being no report). 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. + * + * `netAmount` is 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 run, and blind addition double-credits. + * + * Logs one summary line of counts per run (see [logReconcileSummary]). + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs Room transactions). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + isCancelled: () -> Boolean = { false }, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + releaseVerdicts: suspend () -> Unit, + retryStalePage: (staleInARow: Int) -> Boolean, + ): TxoReconcileReport { + val limit = pageSize.coerceAtLeast(1) + val walletRef = logReference(walletId) + val tally = ReconcileTally() + + // Watch-only DIP-15 contact coins are the CONTACT's money: the + // engine never offers one to heal and never gives one a verdict, so + // neither pass excludes them here. + if (healMissingTxos(walletId, limit, isCancelled, engineUtxoPage, tally, walletRef)) { + classifyStoreRows( + walletId, limit, isCancelled, classifyOutpoints, releaseVerdicts, retryStalePage, tally, walletRef, ) - 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 report = tally.toReport() + logReconcileSummary(report, walletRef) + return report + } + + /** + * Heal pass of [reconcileTxos]: one engine page at a time, one store + * step 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 + * whether the run may go on to the classify pass. + */ + private suspend fun healMissingTxos( + walletId: ByteArray, + limit: Int, + isCancelled: () -> Boolean, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + tally: ReconcileTally, + walletRef: String, + ): Boolean { + var cursor: String? = null + while (true) { + if (isCancelled()) { + tally.completed = false + return false + } + val pageJson = engineUtxoPage(cursor, limit) + if (pageJson == null) { + // The transport died. Everything already applied stands + // (insert-only, idempotent); the rest waits for the next run. + tally.transportFailures++ + tally.completed = false + return false + } + // 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 = decodeEngineUtxoPage(pageJson) + tally.engineRows += page.utxos.size + + if (page.utxos.isNotEmpty()) { + val counts = reconcileStoreStep(tally, walletRef, "heal", isCancelled) { + database.withTransaction { + val c = HealCounts() + for (row in page.utxos) { + healEngineRow(walletId, row, c) + } + c + } + } ?: return false + tally.alreadyPresent += counts.alreadyPresent + tally.inserted += counts.inserted + tally.insertedDuffs += counts.insertedDuffs + tally.healedSpent += counts.healedSpent + tally.skippedImmature += counts.skippedImmature + tally.skippedUnresolvedAccount += counts.skippedUnresolvedAccount + tally.skippedInvalid += counts.skippedInvalid + // Logged once the step committed, so a rolled-back step + // never claims a row it did not write. + for (outpoint in counts.healedOutpoints) logReconcileItem("healed", outpoint, walletRef) + for (outpoint in counts.healedSpentOutpoints) logReconcileItem("healed_spent", outpoint, walletRef) + } + if (!page.hasMore || page.cursor == null) return true + cursor = page.cursor + } + } + + /** + * Apply one engine inventory row to the store (inside the caller's + * exclusion lock and Room transaction), in Swift's + * `reconcileHealMissingTxos` order: + * + * 1. fields that do not decode (txid not 32 bytes of hex, a malformed + * script or identity half), or the engine's + * [TXO_HEAL_VERDICT_INVALID] → invalid; + * 2. the engine's [TXO_HEAL_VERDICT_IMMATURE] (not confirmed, or not + * deep enough yet) → immature, it ages into a later run; + * 3. the store already holds the outpoint → already present; + * 4. the seven-field account tuple names no store account → unresolved, + * NOT inserted: the restore loader routes by account, and an unowned + * row would be dropped at the next launch, recreating the loss. + * + * Only an [TXO_HEAL_VERDICT_ELIGIBLE] row reaches 3. A row that passes + * is inserted through [upsertUtxoRow] — the changeset path's own insert, + * so both writers honour the same rules (stub parent transaction, + * account stamp, address link, pending-input drain) — with + * `isConfirmed = true` (an eligible coin is engine-confirmed) and the + * engine's other flags as given. A row the drain wrote spent on the spot + * is counted as [HealCounts.healedSpent]: the engine holds the coin and + * the store now says spent, which is not a repair of the divergence the + * engine reported. + */ + private suspend fun healEngineRow( + walletId: ByteArray, + row: EngineUtxoRow, + counts: HealCounts, + ) { + val txid = row.txid.hexToByteArrayOrNull() + val script = row.scriptHex.hexToByteArrayOrNull() + // Absent identity halves are the non-DashPay case (no identity). + val userIdentityId = row.userIdentityId?.hexToByteArrayOrNull() ?: ByteArray(0) + val friendIdentityId = row.friendIdentityId?.hexToByteArrayOrNull() ?: ByteArray(0) + val identitiesValid = (row.userIdentityId == null || row.userIdentityId.hexToByteArrayOrNull() != null) && + (row.friendIdentityId == null || row.friendIdentityId.hexToByteArrayOrNull() != null) + if (txid == null || txid.size != 32 || script == null || !identitiesValid) { + counts.skippedInvalid++ + return + } + when (row.healVerdict) { + TXO_HEAL_VERDICT_ELIGIBLE -> Unit + TXO_HEAL_VERDICT_IMMATURE -> { + counts.skippedImmature++ + return + } + // TXO_HEAL_VERDICT_INVALID, and any verdict this build does not + // know: nothing the engine vouched for. + else -> { + counts.skippedInvalid++ + return + } + } + val outpoint = makeOutpoint(txid, row.vout) + if (database.txoDao().getByOutpoint(outpoint) != null) { + counts.alreadyPresent++ + return + } + val account = findReconcileAccount( + walletId, row.typeTag, row.index, row.standardTag, row.registrationIndex, row.keyClass, + userIdentityId, friendIdentityId, + ) + if (account == null) { + counts.skippedUnresolvedAccount++ + return + } + upsertUtxoRow( + database, walletId, txid, row.vout, row.amount, row.address, script, row.height, + isCoinbase = row.isCoinbase, + isConfirmed = true, + isInstantLocked = row.isInstantlocked, + isLocked = row.isLocked, + resolvedAccountId = account.id, + ) + // 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 run), and adding the healed amount to an + // already-corrected net double-credits. + if (database.txoDao().getByOutpoint(outpoint)?.isSpent == true) { + // A pending-input claim or a swept tombstone already covered + // this outpoint, so the drain wrote the row spent on the spot. + counts.healedSpent++ + counts.healedSpentOutpoints += outpoint + } else { + counts.inserted++ + counts.insertedDuffs += row.amount + counts.healedOutpoints += outpoint + } + } + + /** + * Non-creating lookup of the store account an engine row's seven-field + * tuple names — [fetchAccount]'s match, except that an empty identity + * column and 32 zero bytes both mean "no identity" (the account + * registration path stores whatever the FFI handed it, the inventory + * projects zeros), as Swift's `findAccountRow` treats them. + */ + private suspend fun findReconcileAccount( + walletId: ByteArray, + accountType: Int, + accountIndex: Int, + standardTag: Int, + registrationIndex: Int, + keyClass: Int, + userIdentityId: ByteArray, + friendIdentityId: ByteArray, + ): AccountEntity? { + fun identity(bytes: ByteArray) = if (bytes.isEmpty()) ByteArray(32) else bytes + val user = identity(userIdentityId) + val friend = identity(friendIdentityId) + return database.accountDao().getByKey(walletId, accountType, accountIndex).firstOrNull { c -> + c.standardTag == standardTag && + c.registrationIndex == registrationIndex && + c.keyClass == keyClass && + identity(c.userIdentityId).contentEquals(user) && + identity(c.friendIdentityId).contentEquals(friend) + } + } + + /** + * Classify pass of [reconcileTxos]: the wallet's `isSpent = 0` rows, a + * page at a time, each page asked about in one batch. Spent rows are + * never asked about — nothing here un-marks a row, so their verdict + * could change nothing. + * + * Each page is read under [callbackExclusion] together with + * [committedRoundGeneration], the engine is asked OUTSIDE the lock, and + * the verdicts are applied back under it in one Room transaction — only + * if no round committed in between and none is open for the wallet. A + * round that did may have re-credited one of the page's coins, so then + * nothing is written and the same page is read and classified again + * for as long as [retryStalePage] — the engine's retry limit — allows, + * counting the stale pages in a row; then the run stops incomplete. An + * open round counts too: it has not moved + * [committedRoundGeneration] yet, but it may already have staged that + * re-credit, and a flip landing first would survive it — a credited + * re-delivery keeps the flag of a row with a spender link (see + * [upsertUtxoRow]), and no later run looks at a spent row again. + * + * Those fences see this store's rounds, not the engine, and the engine + * can move with no round at all: dash-spv follows a fork by truncating + * the wallet, which drops the spender and restores the coin it spent, + * and a truncation reaches no persistence round (the wider gap is + * dashpay/platform#5380). Flipping the row after such a fork would + * retire a live coin for good. So [classifyOutpoints] answers under a + * read guard on the engine's wallet lock, which a truncation needs for + * writing, and keeps it until [releaseVerdicts]: the verdicts are + * applied while the spenders they name are still there. The guard is + * released in a `finally` — after a flip, a stale page, a failed or + * cancelled step, or an exception — before the next page is read. + * + * The walk is keyed by outpoint (`outpoint > after`, the primary-key + * order), not by offset, so the flips themselves never disturb it: a + * flipped row leaves the `isSpent = 0` predicate behind the cursor, + * never ahead of it. (Swift walks by offset and has to step back by the + * number of rows it flipped.) + */ + private suspend fun classifyStoreRows( + walletId: ByteArray, + limit: Int, + isCancelled: () -> Boolean, + classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + releaseVerdicts: suspend () -> Unit, + retryStalePage: (staleInARow: Int) -> Boolean, + tally: ReconcileTally, + walletRef: String, + ) { + // An empty BLOB sorts before every real outpoint, so this starts at + // the first row. + var after = ByteArray(0) + var staleInARow = 0 + while (true) { + if (isCancelled()) { + tally.completed = false + return + } + val page = reconcileStoreStep(tally, walletRef, "unspent_page", isCancelled) { + readUnspentPage(walletId, after, limit) + } ?: return + if (page.fetched == 0) return + tally.storeRows += page.rows.size + tally.unresolvedAccount += page.unresolvedAccount + + if (page.rows.isNotEmpty()) { + // The engine guard is held from the classification to the + // `finally`, across the apply only — see above. + try { + val verdicts = classifyOutpoints(encodeOutpointQueries(page.queries)) + if (verdicts == null || verdicts.size != page.rows.size) { + // No verdicts, no classification: stop rather than guess. + tally.transportFailures++ + tally.completed = false + return + } + val counts = reconcileStoreStep(tally, walletRef, "flip", isCancelled) { + if (committedRoundGeneration != page.generation || openRound(walletId) != null) { + FlipCounts().apply { staleGeneration = true } + } else { + database.withTransaction { applyEngineClasses(page.rows, verdicts) } + } + } ?: return + if (counts.staleGeneration) { + staleInARow++ + tally.staleRetries++ + if (!retryStalePage(staleInARow)) { + tally.completed = false + return + } + // Same cursor: nothing was written, so the page is read + // again and classified against the store as it is now. + tally.storeRows -= page.rows.size + tally.unresolvedAccount -= page.unresolvedAccount + continue + } + staleInARow = 0 + tally.flipped += counts.flipped + tally.flippedDuffs += counts.flippedDuffs + tally.unspent += counts.unspent + tally.unknown += counts.unknown + tally.notOwned += counts.notOwned + for (outpoint in counts.flippedOutpoints) logReconcileItem("flipped_spent", outpoint, walletRef) + } finally { + withContext(NonCancellable) { releaseVerdicts() } } - for (p in pending) db.documentDao().deletePendingInput(p) } + after = page.lastOutpoint ?: return + if (page.fetched < limit) return + } + } + + /** + * Read half of one classify step (inside the caller's exclusion lock): + * one page of the wallet's unspent rows from [after], and the query the + * engine classifies each by — the account tuple the STORE files the + * coin under and the script it recorded. Those are the store's claim; + * the engine checks both against its own pools rather than trusting + * them, which is why a row whose owning account cannot be resolved is + * counted rather than asked about, and why a bare outpoint cannot ask + * the question. A row still mid-insert (no 32-byte txid) is not + * classifiable at all. + * + * Production changeset writes leave txos.accountId null and route + * ownership through the address projection, so the owner resolves BOTH + * paths ([resolveTxoOwnerAccount]). A watch-only DIP-15 contact row is + * asked about like any other: the engine gives a contact's coin no + * verdict, so it comes back unknown and is left untouched. + */ + private suspend fun readUnspentPage( + walletId: ByteArray, + after: ByteArray, + limit: Int, + ): UnspentPage { + val fetched = database.txoDao().pageUnspentByWallet(walletId, after, limit) + val rows = ArrayList(fetched.size) + val queries = ArrayList(fetched.size) + var unresolvedAccount = 0 + val accountByAddress = HashMap() + for (row in fetched) { + val txid = row.txid + if (txid == null || txid.size != 32 || row.outpoint.size != OUTPOINT_BYTES) continue + val owner = resolveTxoOwnerAccount(row, accountByAddress) + if (owner == null) { + unresolvedAccount++ + continue + } + rows.add(row) + queries.add(outpointQueryFor(owner, txid, row.vout, row.scriptPubKey)) + } + return UnspentPage( + rows = rows, + queries = queries, + fetched = fetched.size, + lastOutpoint = fetched.lastOrNull()?.outpoint, + unresolvedAccount = unresolvedAccount, + generation = committedRoundGeneration, + ) + } + + /** + * Owning Room account of a `txos` row — the one resolution the restore + * loader ([buildUtxoRestoreData]) and the reconcile ([readUnspentPage]) + * share, so every row the loader hands back to Rust is a row the + * reconcile can ask the engine about. The explicit link first, then the + * address projection production writes use, keyed by the row's own + * address: [upsertUtxoRow] leaves `coreAddressId` null when the address + * row is not there yet, and the address-pool callback that inserts it + * later does not backfill the TXO, so `coreAddressId` alone misses rows + * the loader restores. [accountByAddress] memoises the address leg + * across one walk (a wallet's coins cluster on few addresses). + */ + private suspend fun resolveTxoOwnerAccount( + txo: TxoEntity, + accountByAddress: MutableMap, + ): AccountEntity? { + txo.accountId?.let { id -> database.accountDao().getById(id)?.let { return it } } + val address = txo.coreAddressId ?: txo.address.takeIf { it.isNotEmpty() } ?: return null + return accountByAddress.getOrPut(address) { + database.coreAddressDao().getByAddress(address) + ?.accountId?.let { database.accountDao().getById(it) } + } + } + + /** + * Write half of one classify step (inside the caller's exclusion lock + * and Room transaction, the generation already checked): apply the + * engine's verdicts to the rows they were asked about. Only + * [OUTPOINT_CLASS_KNOWN_UNCREDITED] writes — the row is marked spent + * with no spender link (the spender may never have been recorded — + * rust-dashcore#992), its `lastUpdated` bumped, and every pending-input + * row on the outpoint dropped (Swift's `removePendingInputs`), so a + * claim on a settled coin does not linger. `isSpent` is monotonic: a + * row already spent, or gone, by now is stale and left alone. Unspent, + * unknown and not-owned verdicts are counted, never acted on: absence of + * a coin from the engine proves nothing. + */ + private suspend fun applyEngineClasses(rows: List, verdicts: ByteArray): FlipCounts { + val counts = FlipCounts() + for ((i, row) in rows.withIndex()) { + when (verdicts[i]) { + OUTPOINT_CLASS_UNSPENT -> counts.unspent++ + OUTPOINT_CLASS_NOT_OWNED -> counts.notOwned++ + OUTPOINT_CLASS_KNOWN_UNCREDITED -> { + val current = database.txoDao().getByOutpoint(row.outpoint) + if (current == null || current.isSpent || + database.txoDao().markSpentByOutpoint(row.outpoint, now()) == 0 + ) { + counts.stale++ + continue + } + val claims = database.documentDao().getPendingInputsByOutpoint(row.outpoint) + if (claims.isNotEmpty()) { + database.documentDao().deletePendingInputsByIds(claims.map { it.id }) + } + counts.flipped++ + counts.flippedDuffs += current.amount + counts.flippedOutpoints += row.outpoint + } + // OUTPOINT_CLASS_UNKNOWN, and any code this build does not + // know: the engine has no opinion. After a restart its + // finalized set is empty, so this is the ordinary answer + // rather than the alarming one. + else -> counts.unknown++ + } + } + return counts + } + + /** + * Run one reconcile store step under [callbackExclusion], so no + * changeset round commits inside it. Returns null when the run must + * stop — cancelled, or failed — and marks the run incomplete. + * Cancellation is re-checked UNDER the lock: wallet deletion bumps the + * manager's epoch before it takes the lock for its cascade, so a step + * that gets the lock after the cascade writes nothing for the deleted + * wallet. A step that throws rolled back its Room transaction, so + * nothing it staged survives; it is logged by operation and exception + * type only. + */ + private suspend fun reconcileStoreStep( + tally: ReconcileTally, + walletRef: String, + operation: String, + isCancelled: () -> Boolean, + step: suspend () -> T, + ): T? { + val result = try { + callbackExclusion.withLock { if (isCancelled()) null else step() } + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + throw cancellation + } catch (t: Throwable) { + tally.storeFailures++ + Log.e( + TAG, + "txos reconcile: store step failed operation=$operation " + + "error=${t.javaClass.simpleName} wallet=$walletRef", + ) + null + } + if (result == null) tally.completed = false + return result + } + + /** One line per healed / healed-spent / flipped row: the action and + * keyed references only ([logReference]) — never the outpoint, + * address or amount. */ + private fun logReconcileItem(action: String, outpoint: ByteArray, walletRef: String) { + Log.i(TAG, "txos reconcile: item action=$action outpoint=${logReference(outpoint)} wallet=$walletRef") + } + + /** + * The one summary line per run: counts, `completed` and the wallet's + * reference digest. A completed run with zero mutations starts with + * `txos reconcile: mirror consistent`; anything else is a warning. + * Device tooling greps for `txos reconcile`. + */ + private fun logReconcileSummary(report: TxoReconcileReport, walletRef: String) { + val counts = "engineRows=${report.engineRows} storeRows=${report.storeRows} " + + "inserted=${report.inserted} insertedDuffs=${report.insertedDuffs} " + + "healedSpent=${report.healedSpent} " + + "flipped=${report.flipped} flippedDuffs=${report.flippedDuffs} " + + "alreadyPresent=${report.alreadyPresent} unspent=${report.unspent} " + + "unknown=${report.unknown} notOwned=${report.notOwned} " + + "skippedImmature=${report.skippedImmature} " + + "skippedUnresolvedAccount=${report.skippedUnresolvedAccount} " + + "skippedInvalid=${report.skippedInvalid} " + + "unresolvedAccount=${report.unresolvedAccount} staleRetries=${report.staleRetries} " + + "transportFailures=${report.transportFailures} storeFailures=${report.storeFailures} " + + "completed=${report.completed} wallet=$walletRef" + if (report.completed && report.mutations == 0) { + Log.i(TAG, "txos reconcile: mirror consistent ($counts)") + } else { + Log.w(TAG, "txos reconcile: $counts") } - 0 } override fun onWalletChangesetUtxoSpent( @@ -3267,11 +4295,7 @@ class PlatformWalletPersistenceHandler( continue } } - val account = txo.accountId?.let { database.accountDao().getById(it) } - ?: accountByAddress.getOrPut(txo.address) { - database.coreAddressDao().getByAddress(txo.address) - ?.accountId?.let { database.accountDao().getById(it) } - } + val account = resolveTxoOwnerAccount(txo, accountByAddress) if (account == null) { Log.w(TAG, "load: skipping UTXO with no resolvable account (address=${txo.address})") continue @@ -4101,6 +5125,101 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { + /** 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 + + /** `UTXO_CREDIT_VERDICT_*` codes of the credit-verdict slot + * (rs-platform-wallet-ffi `core_wallet_types.rs`). Observed spent: + * a block spent the outpoint before the output was recognised. */ + internal const val UTXO_CREDIT_VERDICT_OBSERVED_SPENT: Byte = 1 + + /** The record is unconfirmed and a block already spent one of its + * inputs; nothing it created was credited. */ + internal const val UTXO_CREDIT_VERDICT_DOOMED: Byte = 2 + + /** Not credited for a reason the bridge cannot name; no context. */ + internal const val UTXO_CREDIT_VERDICT_UNCREDITED: Byte = 3 + + /** + * 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 + + /** [EngineUtxoRow.healVerdict] codes, mirroring rs-platform-wallet-ffi + * `TXO_HEAL_VERDICT_*` (`platform_wallet::manager::txo_reconcile:: + * TxoHealVerdict`), which decides them. Only + * [TXO_HEAL_VERDICT_ELIGIBLE] may be inserted. */ + internal const val TXO_HEAL_VERDICT_INVALID = 0 + internal const val TXO_HEAL_VERDICT_IMMATURE = 1 + internal const val TXO_HEAL_VERDICT_ELIGIBLE = 2 + + /** + * 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 + + /** One page of `walletManagerTxoHealPageJson`, through [engineJson]. */ + internal fun decodeEngineUtxoPage(pageJson: String): EngineUtxoPage = + engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + + /** One classification batch, as + * `walletManagerTxoReconcileClassifyOutpoints` deserializes it + * (rs-unified-sdk-jni `OutpointQuery`). */ + internal fun encodeOutpointQueries(queries: List): String = + engineJson.encodeToString( + kotlinx.serialization.builtins.ListSerializer(OutpointQuery.serializer()), + queries, + ) + + /** The query for one store coin filed under [owner]: the account + * tuple as the store holds it (an identity half only when it is a + * set 32-byte id, as the native side emits them) plus the + * outpoint and the script the store recorded. */ + internal fun outpointQueryFor( + owner: AccountEntity, + txid: ByteArray, + vout: Int, + scriptPubKey: ByteArray, + ): OutpointQuery = OutpointQuery( + typeTag = owner.accountType, + standardTag = owner.standardTag, + index = owner.accountIndex, + registrationIndex = owner.registrationIndex, + keyClass = owner.keyClass, + userIdentityId = owner.userIdentityId + .takeIf { it.size == 32 && it.any { b -> b != 0.toByte() } }?.toHex(), + friendIdentityId = owner.friendIdentityId + .takeIf { it.size == 32 && it.any { b -> b != 0.toByte() } }?.toHex(), + txid = txid.toHex(), + vout = vout, + scriptHex = scriptPubKey.toHex(), + ) + + /** [reconcileTxos] classification verdicts, mirroring + * `platform_wallet::manager::accessors::OutpointClass`. Only + * [OUTPOINT_CLASS_KNOWN_UNCREDITED] is positive evidence: the + * owning account knows the funding txid, recognises the script, + * does not hold the coin, AND a funds account holds a MINED + * record that spends the outpoint. Plain absence from the live + * UTXO set is NOT evidence — a mempool spend that never confirms + * and a conflict sweep both leave a coin absent with its funding + * known — so those arrive as [OUTPOINT_CLASS_UNKNOWN]. */ + internal const val OUTPOINT_CLASS_UNKNOWN: Byte = 0 + internal const val OUTPOINT_CLASS_UNSPENT: Byte = 1 + internal const val OUTPOINT_CLASS_KNOWN_UNCREDITED: Byte = 2 + internal const val OUTPOINT_CLASS_NOT_OWNED: Byte = 3 + internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 @@ -4324,6 +5443,43 @@ internal fun ByteArray.toHex(): String { } /** Inverse of [toHex]; requires an even-length lowercase/uppercase hex string. */ +/** [hexToByteArray], or null when this is not well-formed hex. */ +internal fun String.hexToByteArrayOrNull(): ByteArray? = + try { + hexToByteArray() + } catch (_: IllegalArgumentException) { + null + } + +/** + * A log reference for [value]: the first 12 hex characters of its + * HMAC-SHA256 under [logReferenceKey]. Lets log lines from one process run + * be correlated (same wallet, same coin) without carrying the wallet id, + * txid or outpoint itself. + * + * Keyed, unlike Swift's `SDKLogger.reference` (a plain SHA-256 prefix): + * outpoints are public chain data, so an unkeyed digest lets anyone holding + * the logs hash candidate outpoints and match them, and the wallet + * reference on the same line then groups the matches into one wallet's + * history. The key is random per process and never logged, so a reference + * matches no digest computable outside the process and does not correlate + * across runs. + */ +internal fun logReference(value: ByteArray): String = + javax.crypto.Mac.getInstance(LOG_REFERENCE_MAC).run { + init(logReferenceKey) + doFinal(value) + }.copyOf(6).toHex() + +private const val LOG_REFERENCE_MAC = "HmacSHA256" + +/** The [logReference] key: 32 random bytes, drawn once per process on + * first use and held only in memory. */ +private val logReferenceKey: javax.crypto.spec.SecretKeySpec by lazy { + val key = ByteArray(32).also { java.security.SecureRandom().nextBytes(it) } + javax.crypto.spec.SecretKeySpec(key, LOG_REFERENCE_MAC) +} + internal fun String.hexToByteArray(): ByteArray { require(length % 2 == 0) { "hex string must have even length, got $length" } return ByteArray(length / 2) { i -> 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..fc1ea409f9f 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,36 @@ 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 + + /** + * [pageByWallet] restricted to `isSpent = 0` — the store reconcile's + * classify pass, which only ever asks the engine about the rows the + * restore would hand back. The cursor is the previous page's last + * outpoint, not an offset, so a row the pass marks spent behind the + * cursor leaving the predicate never shifts the rows after it. + */ + @Query( + "SELECT * FROM txos WHERE walletId = :walletId AND isSpent = 0 AND outpoint > :after " + + "ORDER BY outpoint LIMIT :limit", + ) + suspend fun pageUnspentByWallet(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 1fe2572e8fc..ba363ee6fbc 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 @@ -33,6 +33,7 @@ import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity import org.dashfoundation.dashsdk.persistence.hexToByteArray +import org.dashfoundation.dashsdk.persistence.logReference import org.dashfoundation.dashsdk.persistence.toBase58String import org.dashfoundation.dashsdk.persistence.toHex import org.json.JSONArray @@ -1093,6 +1094,12 @@ class PlatformWalletManager( _wallets.value[key]?.let { runCatching { it.close() } } _wallets.update { it - key } _dashPayUnlockStatus.update { it - key } + // A TXO-store reconcile in flight for any wallet stops at its next + // page or store step (each re-checks under the callback gate, so + // none writes after the cascade below). Coarse on purpose — the + // cadence re-runs the others. The native removal above already + // dropped the wallet's cadence. + txoReconcileEpoch.incrementAndGet() // 3. Snapshot → atomic secret delete → Room cascade, with both // persistence callbacks and app-side key writers excluded. @@ -1327,6 +1334,163 @@ class PlatformWalletManager( mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } } + /** + * Reconcile the Room `txos` mirror of [walletId] against the engine, + * once the SPV scan has reached a trustworthy steady state — the Kotlin + * port of Swift's `reconcileCoreTxoStore(for:)` (dashpay/platform#4638 + * Part 2). The mirror is what the engine is restored from at every + * launch, so this inserts engine coins the store lacks and marks spent + * the rows the engine proves spent; it never deletes a row, never + * un-marks one, and never acts on absence. See + * [PlatformWalletPersistenceHandler.reconcileTxos] for the passes. + * + * Whether the run may start is the native start gate's decision + * ([WalletManagerNative.walletManagerTxoReconcileBegin], backed by + * `platform_wallet::manager::txo_reconcile`): one run per wallet at a + * time, SPV in steady state with a scan tip, no latched sync fault + * ([syncFaultDetected]), and the wallet's own scan watermark as the + * engine holds it within the native margin of the tip. Each refusal is a + * [TxoReconcileOutcome.Skipped], not an error; so is a closed manager or + * a wallet this manager has not loaded, checked here first. + * + * The run holds a [teardownGate] borrow, so the native manager is not + * destroyed under an engine read, and releases its native claim — and + * any engine guard a classification still holds — when it ends + * ([WalletManagerNative.walletManagerTxoReconcileFinish]). + * [closeSuspending] and [removeWallet] move [txoReconcileEpoch] first, + * and the run stops between pages, reporting `completed = false`. + * + * Runs automatically when the native schedule says so (see + * [maybeReconcileTxoStores]); hosts may also call it. Logs one skip + * line (reason and wallet reference) or the handler's summary line — + * counts and reference digests only. + */ + suspend fun reconcileTxoStore(walletId: ByteArray): TxoReconcileOutcome { + val walletRef = logReference(walletId) + fun skip(reason: TxoReconcileSkipReason): TxoReconcileOutcome { + android.util.Log.d( + "PlatformWalletManager", + "txos reconcile: skipped reason=${reason.name} wallet=$walletRef", + ) + return TxoReconcileOutcome.Skipped(reason) + } + if (isClosed) return skip(TxoReconcileSkipReason.MANAGER_CLOSED) + if (walletId.size != 32 || _wallets.value[walletId.toHex()] == null) { + return skip(TxoReconcileSkipReason.WALLET_UNKNOWN) + } + val generation = txoReconcileEpoch.get() + return try { + teardownGate.withOp { + val tipOut = IntArray(1) + // NonCancellable: a RUN return holds a native claim that only + // the `finally` below releases. A cancellable withContext would + // discard that result and throw before the `try` is entered, + // leaking the claim for the rest of the session. Cancellation + // is still observed at the first suspension inside the `try`. + val gate = withContext(NonCancellable + Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileBegin(managerHandle, walletId, tipOut) + } + } + if (gate != WalletManagerNative.TXO_RECONCILE_GATE_RUN) { + return@withOp skip(TxoReconcileSkipReason.fromGateCode(gate)) + } + val tipHeight = tipOut[0] + var ran = false + // The engine guard the last classified page is held under — + // see [freeHeldVerdicts]. 0 when none is. + val heldVerdicts = AtomicLong(0L) + try { + val isCancelled = { isClosed || txoReconcileEpoch.get() != generation } + if (isCancelled()) return@withOp skip(TxoReconcileSkipReason.MANAGER_CLOSED) + val report = persistenceHandler.reconcileTxos( + walletId = walletId, + isCancelled = isCancelled, + engineUtxoPage = { cursor, limit -> + reconcileEngineRead("engine inventory page", walletRef) { + WalletManagerNative.walletManagerTxoHealPageJson( + managerHandle, walletId, cursor, limit, tipHeight, + ) + } + }, + classifyOutpoints = { queriesJson -> + reconcileEngineRead("outpoint classification", walletRef) { + val outHeld = LongArray(1) + try { + WalletManagerNative.walletManagerTxoReconcileClassifyOutpoints( + managerHandle, walletId, queriesJson, outHeld, + ) + } finally { + // Kept even when the call failed after the + // guard was written. One the handler did + // not release is freed, never overwritten. + val previous = heldVerdicts.getAndSet(outHeld[0]) + if (previous != 0L) WalletManagerNative.txoReconcileHeldVerdictsFree(previous) + } + } + }, + releaseVerdicts = { freeHeldVerdicts(heldVerdicts) }, + retryStalePage = WalletManagerNative::txoReconcileRetryStalePage, + ) + ran = true + TxoReconcileOutcome.Reconciled(report) + } finally { + // Released even when the run threw or was cancelled: a + // claim left behind would refuse every later run of the + // wallet as ALREADY_RUNNING. The teardown borrow is still + // held, so the handle is live. + withContext(NonCancellable + Dispatchers.IO) { + endTxoReconcileRun( + walletRef = walletRef, + freeHeldVerdicts = { freeHeldVerdicts(heldVerdicts) }, + finish = { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileFinish(managerHandle, walletId, ran) + } + }, + ) + } + } + } + } catch (closing: IllegalStateException) { + // The teardown gate refuses new borrows once close began. + if (isClosed) skip(TxoReconcileSkipReason.MANAGER_CLOSED) else throw closing + } + } + + /** + * Release the engine guard [held] carries, at most once (the value is + * taken out before it is freed). The native free takes no manager + * handle and never waits: it must complete while a registry writer — + * a manager being created or destroyed — is queued behind a native read + * that waits on the engine behind a writer that waits on this guard. + */ + private fun freeHeldVerdicts(held: AtomicLong) { + val token = held.getAndSet(0L) + if (token != 0L) WalletManagerNative.txoReconcileHeldVerdictsFree(token) + } + + /** + * One engine read of the reconcile, off the caller's thread. A native + * failure is logged by kind and exception type only — the message of a + * native error can echo the input it rejected — and becomes the null + * the handler's stop-and-report contract is written against. + */ + private suspend fun reconcileEngineRead(what: String, walletRef: String, read: () -> T): T? = + withContext(Dispatchers.IO) { + try { + mapNativeErrors { read() } + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + throw cancellation + } catch (t: Throwable) { + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: $what failed error=${t.javaClass.simpleName} wallet=$walletRef", + ) + null + } + } + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -2238,11 +2402,65 @@ class PlatformWalletManager( // SPV stopped — reset so the overlay clears. _spvProgress.value = SpvSyncProgressData.EMPTY } + // Every tick, running or not: the poll is the native + // reconcile schedule's clock, and a stopped client arms its + // next steady-state edge. + maybeReconcileTxoStores() delay(POLL_INTERVAL_MS) } } } + /** + * Cancellation generation of the TXO-store reconcile: moved by + * [closeSuspending] and [removeWallet]; a run captures it at start and + * stops between pages once it moves. + */ + private val txoReconcileEpoch = AtomicLong() + + /** + * SDK-internal trigger for [reconcileTxoStore], called on every tick of + * the SPV progress poll: launches a run for each loaded wallet the + * native schedule reports due + * ([WalletManagerNative.walletManagerTxoReconcileDueWallets], which owns + * the steady-state edge and the 30-minute cadence) — never inline, so + * the poll tick stays cheap. A wallet the engine already holds but this + * manager has not wrapped yet (creation and restore publish the native + * wallet first) is skipped; being reported spent none of its cadence — + * only the native claim in [reconcileTxoStore] restarts it — so it is + * reported again on the next tick. Lives here rather than in the host + * apps so every host gets the repair without wiring anything. A run + * that throws is logged (exception type and wallet reference only) and + * retried when the schedule next reports it due — never allowed to kill + * the progress poll. + */ + private suspend fun maybeReconcileTxoStores() { + if (isClosed) return + val due = runCatching { + withContext(Dispatchers.IO) { + mapNativeErrors { WalletManagerNative.walletManagerTxoReconcileDueWallets(managerHandle) } + } + }.getOrNull() ?: return + val loaded = wallets.value + for (offset in 0 until due.size - due.size % 32 step 32) { + val key = due.copyOfRange(offset, offset + 32).toHex() + val walletId = loaded[key]?.walletId ?: continue + scope.launch { + try { + reconcileTxoStore(walletId) + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + throw cancellation + } catch (t: Throwable) { + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: run failed error=${t.javaClass.simpleName} " + + "wallet=${logReference(walletId)}", + ) + } + } + } + } + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2669,6 +2887,10 @@ class PlatformWalletManager( * teardown hops to [Dispatchers.IO]). */ suspend fun closeSuspending() { + // Stale any TXO-store reconcile in flight first: it stops at its next + // page or store step instead of holding teardown behind the rest of + // its walk. + txoReconcileEpoch.incrementAndGet() val bundle = bundleRef.getAndSet(0) if (bundle == 0L) return // NonCancellable from the moment the bundle is claimed: bundleRef @@ -2828,6 +3050,30 @@ internal suspend fun isGenuineWatchOnly( return true } +/** + * End a TXO-store reconcile run: free the engine guard its last page may + * still hold, then release its native claim. The guard goes first and + * without the native registry — `finish` resolves the manager handle + * there, which must never be waited for while a guard is held. Neither + * step can skip the other: a failure is logged by exception type and + * [walletRef] only, and a claim left behind would refuse every later run + * of the wallet as ALREADY_RUNNING. + */ +internal fun endTxoReconcileRun(walletRef: String, freeHeldVerdicts: () -> Unit, finish: () -> Unit) { + runCatching(freeHeldVerdicts).onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: verdict release failed error=${t.javaClass.simpleName} wallet=$walletRef", + ) + } + runCatching(finish).onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: release failed error=${t.javaClass.simpleName} wallet=$walletRef", + ) + } +} + /** * Decode the tagged shielded-create payload the JNI returns: * `[tag || identity_id[32] || diagnostic_utf8...]`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcile.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcile.kt new file mode 100644 index 00000000000..ee4d026fd6b --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcile.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.dashsdk.wallet + +import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler + +/** + * What [PlatformWalletManager.reconcileTxoStore] did — the Kotlin twin of + * Swift's `CoreTxoReconcileOutcome` (dashpay/platform#4638). + */ +sealed class TxoReconcileOutcome { + /** A gate refused the run; nothing was read or written. */ + data class Skipped(val reason: TxoReconcileSkipReason) : TxoReconcileOutcome() + + /** The run happened; [report] says how far it got and what it changed. */ + data class Reconciled( + val report: PlatformWalletPersistenceHandler.TxoReconcileReport, + ) : TxoReconcileOutcome() +} + +/** + * Why [PlatformWalletManager.reconcileTxoStore] did not run. Every reason + * but [MANAGER_CLOSED] comes from the native start gate + * (`platform_wallet::manager::txo_reconcile::TxoReconcileSkip`), which owns + * the thresholds; [gateCode] is its `TXO_RECONCILE_SKIP_*` code. + */ +enum class TxoReconcileSkipReason(internal val gateCode: Int?) { + /** The manager is closed or closing. */ + MANAGER_CLOSED(null), + + /** The wallet is not loaded in this manager. */ + WALLET_UNKNOWN(1), + + /** A run for this wallet is already in flight. */ + ALREADY_RUNNING(2), + + /** SPV is not running, or not in its steady state. */ + NOT_STEADY_STATE(3), + + /** The progress carries no scan tip height. */ + TIP_UNAVAILABLE(4), + + /** The native manager latched a sync fault: rows are missing by design + * and a rescan is pending ([PlatformWalletManager.syncFaultDetected]). */ + SYNC_FAULT_DETECTED(5), + + /** The wallet's scan watermark, as the engine holds it, trails the scan + * tip by more than the native margin — it is still being scanned. */ + WALLET_BEHIND_TIP(6), + ; + + internal companion object { + /** The reason a non-run code from the native start gate names. A + * code this build does not know is a contract break with the + * native library it shipped with, not a reason to guess. */ + fun fromGateCode(code: Int): TxoReconcileSkipReason = + entries.firstOrNull { it.gateCode == code } + ?: throw IllegalStateException("unknown TXO reconcile gate code $code") + } +} 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 cc6dede2141..641b8da1edf 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 @@ -6332,6 +6336,81 @@ 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 + + /** Stale pages in a row [retryStale] lets a run classify again. */ + private val staleRetriesAllowed = 3 + + /** + * The stale-page decision a run is handed. The native one + * (`txo_reconcile_retry_stale_page`) allows five retries in a row and is + * tested in rs-platform-wallet; this double allows [staleRetriesAllowed], + * so these tests show the handler follows the decision it is given + * rather than a constant of its own. + */ + private val retryStale: (Int) -> Boolean = { staleInARow -> staleInARow <= staleRetriesAllowed } + + private val healEligible = PlatformWalletPersistenceHandler.TXO_HEAL_VERDICT_ELIGIBLE + private val healImmature = PlatformWalletPersistenceHandler.TXO_HEAL_VERDICT_IMMATURE + private val healInvalid = PlatformWalletPersistenceHandler.TXO_HEAL_VERDICT_INVALID + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + isConfirmed: Boolean = true, + isCoinbase: Boolean = false, + healVerdict: Int = healEligible, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isConfirmed":$isConfirmed,"isInstantlocked":false,""" + + """"isCoinbase":$isCoinbase,"isLocked":false,"healVerdict":$healVerdict}]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndLeavesNetAmountAlone() = 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), + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + + 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 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 @@ -6836,186 +6915,2412 @@ class PlatformWalletPersistenceHandlerTest { chainLockHeightRound(handler, 600) assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } - /** One packed cover-set entry: owner id ‖ contact id ‖ covered-from (LE u32). */ - private fun backfillEntry(owner: Byte, contact: Byte, coveredFrom: Int): ByteArray = - ByteArray(32) { owner } + ByteArray(32) { contact } + ByteArray(4) + byteArrayOf( - (coveredFrom and 0xFF).toByte(), - ((coveredFrom shr 8) and 0xFF).toByte(), - ((coveredFrom shr 16) and 0xFF).toByte(), - ((coveredFrom shr 24) and 0xFF).toByte(), - ) - /** - * dashpay/platform#4302: the DashPay backfill record is a whole-record - * replace of the three `wallets.dashPayBackfill*` columns, written on - * the same round as the lowered `syncedHeight` the rescan carries. A - * later record replaces the earlier one outright — a contact the new - * record no longer lists is gone, never merged back — and the cover set - * is stored byte-for-byte so native reads back exactly what it wrote. - */ @Test - fun onWalletChangesetDashPayBackfillReplacesTheRecordOnTheWalletRow() = runTest { - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val row = db.walletDao().getByWalletId(walletId)!! - assertNull("no record until the slot fires", row.dashPayBackfillFloor) - assertNull(row.dashPayBackfillRewoundFrom) - assertNull(row.dashPayBackfillCovered) + 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) - val first = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) - handler.onChangesetBegin(walletId) - handler.onWalletChangesetHeader( - walletId = walletId, - hasSyncedHeight = true, - syncedHeight = 2_167_092, - hasBalance = false, - confirmedDelta = 0, - unconfirmedDelta = 0, - immatureDelta = 0, - lockedDelta = 0, - lastAppliedChainLockBytes = ByteArray(0), + handler.reconcileFromInventory(walletId, json) + val second = handler.reconcileFromInventory(walletId, json) + + assertEquals(0, second.inserted) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + + @Test + fun reconcileSkipsImmatureOutputsAndNeverUnmarksASpentRow() = runTest { + // Immature by the engine's verdict (inside its confirmation gate) — + // nothing inserted; the coin ages into a later run. + val fresh = handler.reconcileFromInventory( + walletId, + engineUtxoJson( + changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3, + healVerdict = healImmature, + ), ) + 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. assertEquals( 0, - handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, first, 2), + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 2, 42L, "yTestAddr", byteArrayOf(0x51), 1_500_000, + false, true, false, false, + ), ) - handler.onChangesetEnd(walletId, success = true) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) - val stored = db.walletDao().getByWalletId(walletId)!! - assertEquals("the lowered cursor lands with the record", 2_167_092, stored.syncedHeight) - assertEquals(2_167_092, stored.dashPayBackfillFloor) - assertEquals(2_537_092, stored.dashPayBackfillRewoundFrom) - assertTrue(first.contentEquals(stored.dashPayBackfillCovered)) + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), + ) + assertEquals(0, report.inserted) + assertEquals(1, report.alreadyPresent) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) + } - // A later record replaces the whole cover set. - val second = backfillEntry(1, 2, 2_167_092) - handler.onChangesetBegin(walletId) - handler.onWalletChangesetDashPayBackfill(walletId, 2_100_000, 2_600_000, second, 1) - handler.onChangesetEnd(walletId, success = true) - val replaced = db.walletDao().getByWalletId(walletId)!! - assertEquals(2_100_000, replaced.dashPayBackfillFloor) - assertEquals(2_600_000, replaced.dashPayBackfillRewoundFrom) - assertTrue(second.contentEquals(replaced.dashPayBackfillCovered)) - assertEquals("sibling columns are untouched", 2_167_092, replaced.syncedHeight) + /** 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,"registrationIndex":0,"keyClass":0,"txid":"$txid","vout":$vout,""" + + """"amount":$amount,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd",""" + + """"scriptHex":"76a914000088ac","height":1400000,"isConfirmed":true,""" + + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false,"healVerdict":$healEligible}""" + } + val spentRows = spent.joinToString(",") { (txid, vout) -> + """{"txid":"$txid","vout":$vout}""" + } + return """{"utxos":[$utxoRows],"spent":[$spentRows]}""" } - /** - * The record vouches for the cursor written in its round, so a round - * that rolls back must take the record with it: a persisted record over - * a cursor still at its high-water would tell native the backfill - * resumed when it never started — the lossy direction. A malformed cover - * set (not a whole number of 68-byte entries) is refused outright. - */ - @Test - fun onWalletChangesetDashPayBackfillRollsBackWithItsRoundAndRefusesATornCoverSet() = runTest { - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - handler.onChangesetBegin(walletId) - handler.onWalletChangesetHeader( - walletId = walletId, - hasSyncedHeight = true, - syncedHeight = 2_537_092, - hasBalance = false, - confirmedDelta = 0, - unconfirmedDelta = 0, - immatureDelta = 0, - lockedDelta = 0, - lastAppliedChainLockBytes = ByteArray(0), + /** One engine inventory row, every field the transport carries. */ + private fun engineRowJson( + txid: ByteArray, + vout: Int = 0, + amount: Long = 19_549L, + address: String = reconcileAddress, + scriptHex: String = reconcileScript.toHexLower(), + height: Int = 1_534_921, + isConfirmed: Boolean = true, + typeTag: Int = 0, + txidHex: String = txid.toHexLower(), + healVerdict: Int = healEligible, + ): String = + """{"typeTag":$typeTag,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"$scriptHex",""" + + """"height":$height,"isConfirmed":$isConfirmed,"isInstantlocked":false,""" + + """"isCoinbase":false,"isLocked":false,"healVerdict":$healVerdict}""" + + /** A whole inventory for [FakeEngine]: the engine's coins, the outpoints + * it proves spent, and the ones it says the store misfiled. */ + private fun inventoryJson( + rows: List = emptyList(), + spent: List> = emptyList(), + notOwned: List> = emptyList(), + ): String { + fun keys(list: List>) = + list.joinToString(",") { (txid, vout) -> """{"txid":"${txid.toHexLower()}","vout":$vout}""" } + return """{"utxos":[${rows.joinToString(",")}],"spent":[${keys(spent)}],""" + + """"notOwned":[${keys(notOwned)}]}""" + } + + private val reconcileAddress = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd" + private val reconcileScript = byteArrayOf(0x76, 0xa9.toByte(), 0x14) + ByteArray(20) { 0x5b } + + byteArrayOf(0x88.toByte(), 0xac.toByte()) + + /** A wallet whose BIP44 account owns [reconcileAddress] — what a real + * wallet load leaves behind, and what the restore loader needs. */ + private suspend fun seedReconcileWallet(wallet: ByteArray = walletId, address: String = reconcileAddress) { + seedWalletWithAddress(wallet, address) + } + + /** An unspent, confirmed row of [wallet] for `txid:vout`, owned through + * the address projection exactly as the changeset path writes it. */ + private fun seedUnspentTxo( + txid: ByteArray, + vout: Int = 0, + amount: Long = 19_549L, + wallet: ByteArray = walletId, + address: String = reconcileAddress, + ) { + assertEquals( + 0, + handler.onWalletChangesetUtxoAdded( + wallet, txid, vout, amount, address, reconcileScript, 2_391_743, + false, true, false, false, + ), ) - handler.onChangesetEnd(walletId, success = true) + } - handler.onChangesetBegin(walletId) - handler.onWalletChangesetHeader( + private suspend fun pendingClaims(txid: ByteArray, vout: Int = 0) = + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(txid, vout)).size + + private fun restoredUtxoCount(h: PlatformWalletPersistenceHandler = handler): Int = + h.onLoadWalletList().single { it.walletId.contentEquals(walletId) }.utxos.size + + private suspend fun PlatformWalletPersistenceHandler.reconcileWith( + engine: FakeEngine, + pageSize: Int = 2, + isCancelled: () -> Boolean = { false }, + ): PlatformWalletPersistenceHandler.TxoReconcileReport = + reconcileTxos( walletId = walletId, - hasSyncedHeight = true, - syncedHeight = 2_167_092, - hasBalance = false, - confirmedDelta = 0, - unconfirmedDelta = 0, - immatureDelta = 0, - lockedDelta = 0, - lastAppliedChainLockBytes = ByteArray(0), - ) - handler.onWalletChangesetDashPayBackfill( - walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092), 1, + retryStalePage = retryStale, + pageSize = pageSize, + isCancelled = isCancelled, + engineUtxoPage = engine::page, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + ) + + private val emptyEnginePage = """{"utxos":[],"cursor":null,"hasMore":false}""" + + // ── 1. Positive engine evidence marks a local unspent row spent ─── + + @Test + fun aPositiveEngineVerdictFlipsTheRowAndKeepsItOutOfTheRestore() = runTest { + // The lost-spend class (dashpay/platform#4425, rust-dashcore#992): + // a store row unspent for a coin the engine proves spent — the + // owning account recorded the funding, owns the script, does not + // hold the coin, and a funds account holds a mined spender. The row + // is marked spent with no spender invented, and the claims on it + // are dropped. + seedReconcileWallet() + val txid = ByteArray(32) { 0x71 } + seedUnspentTxo(txid) + db.documentDao().insertPendingInputs( + listOf( + PendingInputEntity( + outpoint = makeOutpoint(txid, 0), + inputIndex = 0, + spendingTxid = ByteArray(32) { 0x7f }, + walletId = walletId, + ), + ), ) - handler.onChangesetEnd(walletId, success = false) + assertEquals(1, restoredUtxoCount()) + val engine = FakeEngine(inventoryJson(spent = listOf(txid to 0))) - val row = db.walletDao().getByWalletId(walletId)!! - assertEquals("the rolled-back cursor stays at its committed value", 2_537_092, row.syncedHeight) - assertNull("the rolled-back record never lands", row.dashPayBackfillFloor) - assertNull(row.dashPayBackfillRewoundFrom) - assertNull(row.dashPayBackfillCovered) + val report = handler.reconcileWith(engine) - handler.onChangesetBegin(walletId) - val rc = handler.onWalletChangesetDashPayBackfill( - walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092).copyOf(67), 1, + assertTrue(report.completed) + assertEquals(1, report.storeRows) + assertEquals(1, report.flipped) + assertEquals(19_549L, report.flippedDuffs) + assertEquals(0, report.inserted) + assertEquals(1, report.mutations) + val coin = db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!! + assertTrue(coin.isSpent) + assertNull("no spender is invented", coin.spendingTxid) + assertNull(coin.supersededByTxid) + assertEquals("claims on a settled coin are dropped", 0, pendingClaims(txid)) + assertEquals(0, restoredUtxoCount()) + // The engine was asked about exactly this coin, with the store's + // own account and script — the ownership it checks. + val query = engine.queries.single() + assertEquals(0, query["typeTag"]!!.jsonPrimitive.int) + assertEquals(0, query["index"]!!.jsonPrimitive.int) + assertEquals(reconcileScript.toHexLower(), query["scriptHex"]!!.jsonPrimitive.content) + } + + @Test + fun aVerdictReadBeforeAnInterveningRoundIsRefusedAndThePageReclassified() = runTest { + // The engine is asked outside the exclusion lock and the verdict is + // applied under it; a round that opens AND commits in that gap can + // re-credit the very coin (a reorg of its spender hands it back in + // `utxos_added`). The apply refuses a verdict read before that + // round, and the page is classified again against the store as it + // is now — here the engine holds the coin again, so nothing flips. + seedReconcileWallet() + val txid = ByteArray(32) { 0x72 } + seedUnspentTxo(txid) + var classifyCalls = 0 + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = { + classifyCalls++ + if (classifyCalls == 1) { + handler.onChangesetBegin(walletId) + handler.onChangesetEnd(walletId, success = true) + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED) + } else { + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT) + } + }, + ) + + assertTrue(report.completed) + assertEquals("the first verdict was read before the round and refused", 1, report.staleRetries) + assertEquals("the page is classified again after the refusal", 2, classifyCalls) + assertEquals(0, report.flipped) + assertEquals(1, report.unspent) + assertEquals(1, report.storeRows) + assertFalse( + "a coin the engine re-credited in the gap stays unspent", + db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent, ) - handler.onChangesetEnd(walletId, success = rc == 0) - assertTrue("a torn cover set is refused", rc != 0) - assertNull(db.walletDao().getByWalletId(walletId)!!.dashPayBackfillFloor) } /** - * The restore side of dashpay/platform#4302: a stored record comes back - * on `loadWalletList` exactly as written, and a wallet with no record - * (every pre-migration row) reports none, so native rewinds once for it - * as it always did. + * The engine's read guard on its wallet lock, as the native + * classification takes it and the release drops it. A fork (dash-spv's + * `truncate_above`) needs the lock for writing, so one requested while + * the guard is held runs only once it is dropped. */ + private class EngineGuardProbe { + var held = false + private set + var acquired = 0 + private set + var releaseCalls = 0 + private set + private var pendingFork: (() -> Unit)? = null + + fun acquire() { + assertFalse("a run never asks for a page while still holding the last", held) + held = true + acquired++ + } + + fun release() { + releaseCalls++ + held = false + pendingFork?.invoke() + pendingFork = null + } + + /** A fork: it runs at once if no guard is held, else on release. */ + fun fork(truncate: () -> Unit) { + if (held) pendingFork = truncate else truncate() + } + } + @Test - fun loadWalletListRoundTripsTheDashPayBackfillRecord() = runTest { - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + fun aForkRequestedBetweenTheVerdictAndTheApplyWaitsUntilTheFlipIsWritten() = runTest { + // The fences above see this store's rounds only. The engine can + // follow a fork with none: the truncation drops the spender, + // restores the coin, and opens no round (dashpay/platform#5380). + // The verdicts are therefore answered under a read guard on the + // engine's wallet lock, held until the page is applied: a fork + // requested after the answer (it needs that lock for writing) runs + // only after the flip is committed and the guard released. What the + // flip wrote was true when it was written; the fork afterwards is + // the same truncation gap any persisted spend has. + seedReconcileWallet() + val txid = ByteArray(32) { 0x7a } + seedUnspentTxo(txid) + val engine = EngineGuardProbe() + var spenderInEngine = true + var spentAtRelease: Boolean? = null + var heldAtRelease: Boolean? = null + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { + engine.acquire() + val verdict = if (spenderInEngine) { + PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED + } else { + PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT + } + // The fork arrives now, between the answer and the apply. + engine.fork { spenderInEngine = false } + byteArrayOf(verdict) + }, + releaseVerdicts = { + heldAtRelease = engine.held + spentAtRelease = db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent + engine.release() + }, + ) + + assertTrue(report.completed) + assertEquals(1, report.flipped) + assertEquals(0, report.staleRetries) + assertEquals("the guard was still held when the release came", true, heldAtRelease) + assertEquals("the flip was committed under the guard", true, spentAtRelease) + assertFalse("the fork ran, but only after the release", spenderInEngine) + assertFalse(engine.held) + assertEquals(1, engine.acquired) + } + + @Test + fun theEngineGuardIsReleasedOnEveryPath() = runTest { + seedReconcileWallet() + val txid = ByteArray(32) { 0x7b } + seedUnspentTxo(txid) + + // A stale page: released before the page is classified again. + val stale = EngineGuardProbe() + var calls = 0 + val staleReport = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { + stale.acquire() + calls++ + if (calls == 1) { + handler.onChangesetBegin(walletId) + handler.onChangesetEnd(walletId, success = true) + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED) + } else { + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT) + } + }, + releaseVerdicts = { stale.release() }, + ) + assertEquals(1, staleReport.staleRetries) + assertEquals("one guard per classification", 2, stale.acquired) + assertEquals(2, stale.releaseCalls) + assertFalse(stale.held) + + // The run stopped between the answer and the apply. + val stopped = EngineGuardProbe() + var stop = false + val stoppedReport = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + isCancelled = { stop }, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { + stopped.acquire() + stop = true + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED) + }, + releaseVerdicts = { stopped.release() }, + ) + assertFalse(stoppedReport.completed) + assertEquals(0, stoppedReport.flipped) + assertFalse(stopped.held) + + // A failed transport: nothing held, and the release is harmless. + val failed = EngineGuardProbe() + val failedReport = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { null }, + releaseVerdicts = { failed.release() }, + ) + assertEquals(1, failedReport.transportFailures) + assertEquals(1, failed.releaseCalls) + + // An exception, and a cancellation, after the guard was taken. + for (thrown in listOf( + IllegalStateException("engine read failed"), + kotlinx.coroutines.CancellationException("run cancelled"), + )) { + val probe = EngineGuardProbe() + val caught = runCatching { + handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { + probe.acquire() + throw thrown + }, + releaseVerdicts = { probe.release() }, + ) + }.exceptionOrNull() + assertTrue(caught === thrown) + assertFalse("released after ${thrown.javaClass.simpleName}", probe.held) + } + + assertFalse( + "no path flipped the row", + db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent, ) + } - val before = handler.onLoadWalletList().single() - assertFalse("no record on a fresh wallet", before.hasDashPayBackfill) - assertEquals(0, before.dashPayBackfillFloor) - assertEquals(0, before.dashPayBackfillRewoundFrom) - assertEquals(0, before.dashPayBackfillCovered.size) + @Test + fun aPageThatKeepsGoingStaleStopsTheRunIncomplete() = runTest { + // A round commits between every read and apply: the page is + // re-classified a bounded number of times, then the run gives up + // without having written anything. + seedReconcileWallet() + val txid = ByteArray(32) { 0x73 } + seedUnspentTxo(txid) + var classifyCalls = 0 - val covered = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) - handler.onChangesetBegin(walletId) - handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, covered, 2) + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = { + classifyCalls++ + handler.onChangesetBegin(walletId) + handler.onChangesetEnd(walletId, success = true) + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED) + }, + ) + + val limit = staleRetriesAllowed + 1 + assertFalse(report.completed) + assertEquals(limit, report.staleRetries) + assertEquals(limit, classifyCalls) + assertEquals(0, report.flipped) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent) + } + + @Test + fun aVerdictIsNotAppliedWhileARoundIsOpenEvenBeforeItCommits() = runTest { + // A round that has BEGUN but not yet committed in the gap between + // the engine read and the apply has not moved the commit count, yet + // it may already have staged the re-credit of the very coins the + // verdict condemns. A flip landing first would outlive the round on + // a row with a mempool spender link: the credited re-delivery keeps + // a linked row's flag, the restore drops it while the engine holds + // the coin, and no later run looks at a spent row again. The apply + // refuses while the round is open; the run stops incomplete and the + // rows are classified again once it has closed. + seedReconcileWallet() + val plain = ByteArray(32) { 0x74 } + val linked = ByteArray(32) { 0x75 } + val spender = ByteArray(32) { 0x76 } + seedUnspentTxo(plain) + seedUnspentTxo(linked) + recordMempoolSpend(spender, makeOutpoint(linked, 0)) + val linkedBefore = db.txoDao().getByOutpoint(makeOutpoint(linked, 0))!! + assertTrue("sanity: the mempool spender is linked", spender.contentEquals(linkedBefore.spendingTxid)) + assertFalse("sanity: a mempool spender does not mark the coin spent", linkedBefore.isSpent) + assertEquals(2, restoredUtxoCount()) + var classifyCalls = 0 + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = { + classifyCalls++ + if (classifyCalls == 1) { + // A reorg of the spenders hands both coins back: the + // round opens and stages the credited re-delivery, and + // stays open past the apply. + handler.onChangesetBegin(walletId) + for (txid in listOf(plain, linked)) { + handler.onWalletChangesetUtxoAdded( + walletId, txid, 0, 19_549L, reconcileAddress, reconcileScript, 2_391_743, + false, true, false, false, + ) + } + } + ByteArray(2) { PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED } + }, + ) handler.onChangesetEnd(walletId, success = true) - val after = handler.onLoadWalletList().single() - assertTrue(after.hasDashPayBackfill) - assertEquals(2_167_092, after.dashPayBackfillFloor) - assertEquals(2_537_092, after.dashPayBackfillRewoundFrom) - assertTrue(covered.contentEquals(after.dashPayBackfillCovered)) + for (txid in listOf(plain, linked)) { + assertFalse( + "a coin re-credited by the open round stays unspent", + db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent, + ) + } + assertTrue( + "the mempool spender link is untouched", + spender.contentEquals(db.txoDao().getByOutpoint(makeOutpoint(linked, 0))!!.spendingTxid), + ) + assertEquals("both coins are still handed back at the next load", 2, restoredUtxoCount()) + val limit = staleRetriesAllowed + 1 + assertFalse("the run stops rather than apply under an open round", report.completed) + assertEquals(limit, report.staleRetries) + assertEquals(0, report.flipped) + + // Once the round has closed, the next run classifies both rows + // again against the engine as it is now. + val after = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = { ByteArray(2) { PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT } }, + ) + assertTrue(after.completed) + assertEquals(0, after.staleRetries) + assertEquals(2, after.unspent) + assertEquals(0, after.flipped) + assertEquals(2, restoredUtxoCount()) } -} + // ── 2. Absence and no-opinion verdicts change nothing ───────────── -/** - * Room open-helper factory that fails exactly one prepared statement — - * the one whose SQL starts with [failingSqlPrefix] — once [armed], and - * delegates every other read and write to real SQLite. - * - * Faults ONE query rather than dropping its table: `asset_locks` is read - * by three independent restore builders, so a table-level fault proves - * nothing about the isolation of any single one of them. - */ -private class SingleStatementFaultInjector( - private val failingSqlPrefix: String, -) : SupportSQLiteOpenHelper.Factory { - private val real = FrameworkSQLiteOpenHelperFactory() + @Test + fun reconcileLeavesRowsAbsentFromBothInventoriesAlone() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction, or a funding record this + // session never processed (after a restart the engine's finalized + // set is empty). Absence proves nothing: counted, never touched. + 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()), + ) + assertTrue(report.completed) + assertEquals(1, report.storeRows) + assertEquals(1, report.unknown) + assertEquals(0, report.mutations) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } - /** Off while the fixture is seeded, then flipped on by the test. */ - var armed: Boolean = false + @Test + fun reconcileCountsNotOwnedRowsAndLeavesThemAlone() = runTest { + // NOT_OWNED: the account the store filed the coin under does not + // monitor its script, so the engine could never have credited it + // there — a store attribution error, not a missing coin. Counted, + // and the row is left exactly as it was. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 21, 777_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val json = """{"utxos":[],"spent":[],"notOwned":[""" + + """{"txid":"${changeTxid.toHexLower()}","vout":21}]}""" + + val report = handler.reconcileFromInventory(walletId, json) + + assertEquals(1, report.notOwned) + assertEquals("a not-owned row is not also counted as unknown", 0, report.unknown) + assertEquals(0, report.flipped) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 21))!! + assertFalse(row.isSpent) + assertEquals(777_000L, row.amount) + } + + @Test + fun reconcileStampsTheEnginesOwnFlagsOnAHealedRow() = runTest { + // The inventory carries the engine's own isCoinbase / isConfirmed / + // isInstantLocked. They are stamped as given, not assumed: a + // coinbase output filed as an ordinary one misstates maturity in + // the mirror the engine reloads from. + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson( + changeTxid.toHexLower(), + vout = 20, + amount = 5_000_000_000L, + isConfirmed = true, + isCoinbase = true, + ), + ) - override fun create( - configuration: SupportSQLiteOpenHelper.Configuration, - ): SupportSQLiteOpenHelper = Helper(real.create(configuration)) + assertEquals(1, report.inserted) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 20))!! + assertTrue("a coinbase output must be filed as one", row.isCoinbase) + assertTrue(row.isConfirmed) + } - private inner class Helper( - private val delegate: SupportSQLiteOpenHelper, - ) : SupportSQLiteOpenHelper by delegate { - override val writableDatabase: SupportSQLiteDatabase - get() = Db(delegate.writableDatabase) - override val readableDatabase: SupportSQLiteDatabase - get() = Db(delegate.readableDatabase) + @Test + fun reconcileCountsRowsWithNoResolvableOwnerInsteadOfAskingAboutThem() = runTest { + // A query names the account the store filed the coin under, because + // the engine checks that claim against its own pools. A row whose + // owner resolves through neither the explicit link nor the + // core_addresses projection carries no claim to check, so it is + // counted rather than asked about. + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yOrphanAddr", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/7", + isUsed = true, + accountId = null, + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 22, 31_000L, "yOrphanAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + assertEquals( + "the row must hang off the address projection for this test to mean anything", + "yOrphanAddr", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 22))!!.coreAddressId, + ) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + ) + + assertEquals(1, report.unresolvedAccount) + assertEquals("an unasked row is not handed to the engine", 0, report.storeRows) + assertEquals("an unasked row is not counted as engine-unknown", 0, report.unknown) + } + + @Test + fun aRowWhoseAddressArrivedAfterItResolvesItsOwnerTheWayTheRestoreDoes() = runTest { + // The changeset write links `coreAddressId` only when the address + // row already exists, and the address-pool callback that inserts it + // later does not backfill the TXO. The restore loader still routes + // such a row to its account through the row's own address, so it is + // handed back to the engine at every launch; the reconcile must + // resolve it the same way, or a coin the engine proves spent is + // counted unresolved on every run and restored forever. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + val txid = ByteArray(32) { 0x74 } + seedUnspentTxo(txid) + val account = db.accountDao().observeByWallet(walletId).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = reconcileAddress, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + val stored = db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!! + assertNull("the address row arrived after the TXO and nothing backfilled it", stored.coreAddressId) + assertNull(stored.accountId) + assertEquals("the restore routes the row through its own address", 1, restoredUtxoCount()) + val engine = FakeEngine(inventoryJson(spent = listOf(txid to 0))) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals("the owner resolves through the row's address", 0, report.unresolvedAccount) + assertEquals(1, report.storeRows) + assertEquals(1, report.flipped) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent) + assertEquals("the flipped coin leaves the restore set", 0, restoredUtxoCount()) + // Asked under the account the restore would have routed it to. + val query = engine.queries.single() + assertEquals(account.accountType, query["typeTag"]!!.jsonPrimitive.int) + assertEquals(account.accountIndex, query["index"]!!.jsonPrimitive.int) + } + + // ── 3. A consistent store stays untouched ───────────────────────── + + @Test + fun reconcileClassifyPassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // heal pass would skip as immature — are consistent, not divergence. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isConfirmed":true,""" + + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false,"healVerdict":$healImmature}],""" + + """"spent":[]}""" + val report = handler.reconcileFromInventory(walletId, json) + assertTrue(report.completed) + assertEquals(0, report.mutations) + assertEquals(1, report.unspent) + assertEquals(0, report.unknown) + assertEquals(1, report.skippedImmature) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 5))!!.isSpent) + } + + @Test + fun aConsistentStoreYieldsZeroMutationsAndSpentRowsAreNeverAsked() = runTest { + seedReconcileWallet() + val unspent = ByteArray(32) { 0xb1.toByte() } + val spent = ByteArray(32) { 0xb2.toByte() } + seedUnspentTxo(unspent) + seedUnspentTxo(spent) + db.txoDao().upsert(db.txoDao().getByOutpoint(makeOutpoint(spent, 0))!!.copy(isSpent = true)) + // The engine holds exactly the store's unspent coin, and says so. + val engine = FakeEngine(inventoryJson(rows = listOf(engineRowJson(unspent)))) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals(0, report.mutations) + assertEquals(1, report.alreadyPresent) + assertEquals(1, report.unspent) + assertEquals("spent rows are never even asked about", 1, report.storeRows) + assertEquals(1, engine.classified) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(unspent, 0))!!.isSpent) + assertTrue("never un-marked", db.txoDao().getByOutpoint(makeOutpoint(spent, 0))!!.isSpent) + } + + @Test + fun aWatchOnlyContactRowIsAskedUnderItsContactAccountAndLeftAloneWithoutAVerdict() = runTest { + // Watch-only DIP-15 contact rows carry no exclusion here: the + // engine never gives a contact's coin a verdict, so the row is asked + // about under the account the store files it under and comes back + // unknown — even when the engine holds a mined spend of it. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val contactAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 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 = contactAccountId)) + val engine = ContactBlindEngine() + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + ) + + assertTrue(report.completed) + assertEquals(1, report.storeRows) + assertEquals(1, report.unknown) + assertEquals(0, report.flipped) + assertEquals(ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, engine.typeTags.single()) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) + } + + @Test + fun aContactRowsOwnerResolvesThroughItsAddressRow() = runTest { + // Production changeset writes leave txos.accountId null and route + // ownership through coreAddressId -> core_addresses.accountId. The + // owner must resolve that path, so the contact row is asked about + // under its contact account rather than counted unresolved. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val contactAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 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 = contactAccountId, + ), + ) + 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 engine = ContactBlindEngine() + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + ) + + assertEquals(0, report.unresolvedAccount) + assertEquals(1, report.unknown) + assertEquals(ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, engine.typeTags.single()) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!!.isSpent) + } + + /** + * A classifier that answers like the engine for a contact's coin + * (`classify_outpoints` gives a watch-only contact account no verdict) + * and records the account tag each query was filed under. + */ + private class ContactBlindEngine { + val typeTags = ArrayList() + + fun classify(queriesJson: String): ByteArray { + val batch = kotlinx.serialization.json.Json.parseToJsonElement(queriesJson).jsonArray + return ByteArray(batch.size) { i -> + val typeTag = batch[i].jsonObject["typeTag"]!!.jsonPrimitive.int + typeTags += typeTag + if (typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) { + PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNKNOWN + } else { + PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED + } + } + } + } + + // ── 4. Heal pass: a missing engine coin is inserted, validated ──── + + @Test + fun anEngineCoinMissingFromTheStoreIsInsertedWithStubParentAccountAndAddress() = runTest { + seedReconcileWallet() + val account = db.accountDao().observeByWallet(walletId).first().single() + val txid = ByteArray(32) { 0x74 } + val engine = FakeEngine(inventoryJson(rows = listOf(engineRowJson(txid, height = 1_534_921)))) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals(1, report.engineRows) + assertEquals(1, report.inserted) + assertEquals(19_549L, report.insertedDuffs) + val coin = db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!! + assertFalse(coin.isSpent) + assertTrue(coin.isConfirmed) + assertEquals(19_549L, coin.amount) + assertEquals(reconcileAddress, coin.address) + assertTrue(reconcileScript.contentEquals(coin.scriptPubKey)) + assertEquals(1_534_921, coin.height) + assertTrue(walletId.contentEquals(coin.walletId)) + assertEquals("filed under the account the engine named", account.id, coin.accountId) + assertEquals("linked to its address row", reconcileAddress, coin.coreAddressId) + val parent = db.transactionDao().getByTxid(txid) + assertNotNull("a stub parent row holds the relationship", parent) + assertEquals(0, parent!!.transactionData.size) + assertEquals(1, restoredUtxoCount()) + } + + @Test + fun theHealPassFollowsTheEngineVerdictAndRefusesUnresolvedAndUndecodableCoins() = runTest { + // The engine decides which coins may be healed (confirmation depth, + // engine-confirmed, a script and an address — tested in + // rs-platform-wallet); the store refuses only what it cannot file: an + // account it does not hold, or fields that do not decode. + seedReconcileWallet() // BIP44 only: no CoinJoin account row + fun txid(b: Int) = ByteArray(32) { b.toByte() } + val engine = FakeEngine( + inventoryJson( + rows = listOf( + engineRowJson(txid(0x76), height = reconcileTip - 50, healVerdict = healImmature), + engineRowJson(txid(0x77), height = reconcileTip - 99), // eligible + engineRowJson(txid(0x78), isConfirmed = false, healVerdict = healImmature), + engineRowJson(txid(0x79), typeTag = 1), // CoinJoin: no store account + engineRowJson(txid(0x7a), scriptHex = "", healVerdict = healInvalid), // no script + engineRowJson(txid(0x7b), address = "", healVerdict = healInvalid), // no address + engineRowJson(txid(0x7c), height = 0, healVerdict = healImmature), + engineRowJson(txid(0x7d), txidHex = "7d".repeat(31)), // 31-byte txid + // A verdict this build does not know is no permission. + engineRowJson(txid(0x7e), healVerdict = 9), + ), + ), + ) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals(9, report.engineRows) + assertEquals(1, report.inserted) + assertEquals(3, report.skippedImmature) + assertEquals(1, report.skippedUnresolvedAccount) + assertEquals(4, report.skippedInvalid) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(txid(0x77), 0))) + for (b in listOf(0x76, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7e)) { + assertNull("coin $b must not be healed", db.txoDao().getByOutpoint(makeOutpoint(txid(b), 0))) + } + assertEquals(1, db.txoDao().pageByWallet(walletId, ByteArray(0), 100).size) + } + + @Test + fun aHealTheDrainWritesSpentIsCountedAsHealedSpent() = runTest { + // The spender is already a chainlocked row of the store; only the + // coin it consumed is missing (the funding record never reached the + // store), so its input claim is still pending. The heal inserts the + // row and the drain writes it spent on the spot. Nothing was + // repaired — the engine still holds the coin — but a row was + // written, so it is a mutation rather than a clean pass. + seedReconcileWallet() + val funding = ByteArray(32) { 0x7d } + val spender = ByteArray(32) { 0x7e } + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = spender, + transactionData = ByteArray(10) { 5 }, + context = 3, + blockHeight = 2_391_800, + netAmount = -19_549, + ), + ) + db.documentDao().insertPendingInputs( + listOf( + PendingInputEntity( + outpoint = makeOutpoint(funding, 0), + inputIndex = 0, + spendingTxid = spender, + spendingTransactionTxid = spender, + walletId = walletId, + ), + ), + ) + val engine = FakeEngine(inventoryJson(rows = listOf(engineRowJson(funding)))) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals(1, report.engineRows) + assertEquals(1, report.healedSpent) + assertEquals(0, report.inserted) + assertEquals(0, report.flipped) + assertEquals("a row was written, even though nothing was repaired", 1, report.mutations) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(funding, 0))!!.isSpent) + assertEquals("the claim was consumed by the drain", 0, pendingClaims(funding)) + assertEquals(0, restoredUtxoCount()) + } + + @Test + fun reconcileStampsResolvedAccountOnHealedRows() = runTest { + // Persistence lost BOTH the TXO and its address row. The heal + // resolves the owning Room account from the inventory's account + // tuple and stamps 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. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + // Production shape: onPersistAccountRegistration stores the FFI's + // 32-zero-byte identity ids verbatim. + 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"), + ) + assertEquals(1, report.inserted) + assertEquals(0, report.skippedUnresolvedAccount) + assertEquals( + "the healed row must carry the account resolved from the inventory tuple", + bip44Id, db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 11))!!.accountId, + ) + } + + @Test + fun reconcileRefusesToHealACoinWhoseAccountCannotBeResolved() = runTest { + // No store account matches the tuple the engine filed the coin + // under: the coin is NOT inserted unowned — the restore loader + // routes by account, so an unowned row would be dropped at the next + // launch and recreate the loss. Counted instead. + val json = """{"utxos":[""" + + engineRowJson(changeTxid, vout = 12, amount = 5_000L, typeTag = 1) + + """],"spent":[]}""" + val report = handler.reconcileFromInventory(walletId, json) + assertEquals(0, report.inserted) + assertEquals(1, report.skippedUnresolvedAccount) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))) + } + + // ── 5. Never un-mark, never delete; idempotent; wallet-scoped ───── + + @Test + fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { + // A row marked spent while the engine lists the coin unspent: a live + // spend racing the engine is indistinguishable from lost residue, + // and un-marking mid-payment would let the wallet double-spend. + 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(), + ), + ) + assertEquals(1, report.alreadyPresent) + assertEquals(0, report.storeRows) + assertEquals(0, report.mutations) + assertTrue( + "the row must stay spent — un-marking is never done by reconciliation", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!!.isSpent, + ) + } + + @Test + fun aSecondRunChangesNothing() = runTest { + seedReconcileWallet() + val flippedTxid = ByteArray(32) { 0x81.toByte() } + val healedTxid = ByteArray(32) { 0x82.toByte() } + seedUnspentTxo(flippedTxid) + val engine = FakeEngine( + inventoryJson(rows = listOf(engineRowJson(healedTxid)), spent = listOf(flippedTxid to 0)), + ) + + assertEquals(2, handler.reconcileWith(engine).mutations) + + val second = handler.reconcileWith(engine) + assertTrue(second.completed) + assertEquals(0, second.mutations) + assertEquals(1, second.alreadyPresent) + assertEquals("the healed coin is the only unspent row left", 1, second.storeRows) + assertEquals(1, second.unspent) + assertEquals(2, db.txoDao().pageByWallet(walletId, ByteArray(0), 100).size) + } + + @Test + fun theReconcileTouchesOnlyTheWalletItWasAskedAbout() = runTest { + val otherWalletId = ByteArray(32) { 9 } + seedReconcileWallet() + seedWalletWithAddress(otherWalletId, "yOtherWalletAddr", xpubFill = 31) + val mine = ByteArray(32) { 0x91.toByte() } + val theirs = ByteArray(32) { 0x92.toByte() } + seedUnspentTxo(mine) + seedUnspentTxo(theirs, wallet = otherWalletId, address = "yOtherWalletAddr") + // The fake would flip both if asked; only one may be asked. + val engine = FakeEngine(inventoryJson(spent = listOf(mine to 0, theirs to 0))) + + val report = handler.reconcileWith(engine) + + assertEquals(1, report.storeRows) + assertEquals(1, report.flipped) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(mine, 0))!!.isSpent) + assertFalse( + "the other wallet's coin is untouched", + db.txoDao().getByOutpoint(makeOutpoint(theirs, 0))!!.isSpent, + ) + assertEquals(mine.toHexLower(), engine.queries.single()["txid"]!!.jsonPrimitive.content) + } + + @Test + fun repeatedRelaunchesRestoreNothingForAFlippedCoin() = runTest { + // The acceptance shape: after the run that flipped the phantom, a + // relaunch — a fresh handler over the same ON-DISK store — restores + // nothing for it, and so does the relaunch after that. + val context = ApplicationProvider.getApplicationContext() + val name = "txo-reconcile-restart.db" + context.deleteDatabase(name) + fun open() = Room.databaseBuilder(context, DashDatabase::class.java, name) + .allowMainThreadQueries() + .build() + fun handlerOver(store: DashDatabase) = PlatformWalletPersistenceHandler( + store, + Dispatchers.Unconfined, + null, + storedTransactionInputs = storedInputs, + ) + val txid = ByteArray(32) { 0xa1.toByte() } + try { + val first = open() + try { + val h = handlerOver(first) + h.onPersistWalletMetadata(walletId, testnet, groupId, 0) + h.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + val account = first.accountDao().observeByWallet(walletId).first().single() + first.coreAddressDao().upsert( + CoreAddressEntity( + address = reconcileAddress, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + h.onWalletChangesetUtxoAdded( + walletId, txid, 0, 19_549, reconcileAddress, reconcileScript, 2_391_743, + false, true, false, false, + ) + assertEquals("the phantom the engine would be handed", 1, restoredUtxoCount(h)) + val report = h.reconcileWith(FakeEngine(inventoryJson(spent = listOf(txid to 0)))) + assertEquals(1, report.flipped) + assertEquals(0, restoredUtxoCount(h)) + } finally { + first.close() + } + repeat(2) { + val reopened = open() + try { + val h = handlerOver(reopened) + assertTrue(reopened.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent) + assertEquals("a relaunch without a rescan restores nothing", 0, restoredUtxoCount(h)) + } finally { + reopened.close() + } + } + } finally { + context.deleteDatabase(name) + } + } + + // ── 6. Run shape: paging, failures, cancellation ────────────────── + + @Test + fun reconcileWalksEveryPageOfTheEngineInventory() = runTest { + // The engine inventory is chain-controlled in size, so the run + // reads it a page at a time. Every page must be applied. + attributeUnownedTxos(walletId) + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 5).map { Triple(changeTxid.toHexLower(), 20 + it, 1_000L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileWith(engine) + + assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) + assertEquals(5, report.engineRows) + 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 theClassifyPassWalksEveryUnspentRowAcrossFlips() = runTest { + // Flipped rows leave the `isSpent = 0` predicate; the outpoint + // cursor sits past them, so the next page still starts at the first + // row this walk has not seen. + seedReconcileWallet() + val txids = (0 until 5).map { i -> ByteArray(32) { (0xf0 + i).toByte() } } + txids.forEach { seedUnspentTxo(it) } + val engine = FakeEngine(inventoryJson(spent = txids.map { it to 0 })) + + val report = handler.reconcileWith(engine, pageSize = 2) + + assertTrue(report.completed) + assertEquals(5, report.storeRows) + assertEquals("flipping rows out of the page does not skip the next ones", 5, report.flipped) + assertEquals(3, engine.batches) + assertEquals(0, restoredUtxoCount()) + } + + @Test + fun reconcileStopsAtAFailedPageAndKeepsWhatItAlreadyHealed() = runTest { + // A transport that dies mid-run must not discard the pages that + // already landed, and must not report a clean run either. + attributeUnownedTxos(walletId) + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 4).map { Triple(changeTxid.toHexLower(), 30 + it, 500L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + pageSize = 2, + engineUtxoPage = { cursor, limit -> + if (cursor == null) engine.page(cursor, limit) else null + }, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + ) + + assertFalse(report.completed) + assertEquals(1, report.transportFailures) + assertEquals("only the page that arrived", 2, report.inserted) + assertEquals("the classify pass does not run after a failed read", 0, engine.batches) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 30))) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 32))) + } + + @Test + fun reconcileClassifiesStoreRowsInBoundedBatches() = runTest { + // 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. + for (vout in 40 until 43) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + attributeUnownedTxos(walletId) + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileWith(engine, pageSize = 1) + + assertEquals("one classification batch per store page", 3, engine.batches) + assertEquals("every store row reached the classifier", 3, engine.classified) + assertEquals(3, report.storeRows) + assertEquals(3, report.unknown) + } + + @Test + fun reconcileStopsWhenAClassificationBatchFails() = runTest { + // No verdicts, no classification: the classify 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, + ) + } + attributeUnownedTxos(walletId) + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + pageSize = 1, + engineUtxoPage = engine::page, + releaseVerdicts = {}, + classifyOutpoints = { null }, + ) + + assertFalse(report.completed) + assertEquals(1, report.transportFailures) + assertEquals(0, report.unknown) + assertEquals(0, report.flipped) + } + + @Test + fun reconcileReportsAnIncompleteRunWhenTheFirstPageIsUnavailable() = runTest { + // Swift's contract: a dead transport is a report that says the run + // did not complete, and the classify pass never runs without the + // heal pass having finished. + var classifyCalls = 0 + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> null }, + releaseVerdicts = {}, + classifyOutpoints = { queriesJson -> + classifyCalls++ + ByteArray( + kotlinx.serialization.json.Json.parseToJsonElement(queriesJson).jsonArray.size, + ) + }, + ) + assertFalse(report.completed) + assertEquals(1, report.transportFailures) + assertEquals(0, report.mutations) + assertEquals("and the classification pass never runs", 0, classifyCalls) + } + + @Test + fun theRunStopsWhenTheHealCannotReadTheStore() = runTest { + // The heal asks the store whether it already holds each engine + // coin. A read that fails there must not read as "absent" (that + // would insert a row the store may well hold): the step fails, it + // rolls back, and the run stops. + val faults = useFaultedDatabase("SELECT * FROM txos WHERE outpoint") + seedReconcileWallet() + val txid = ByteArray(32) { 0x84.toByte() } + faults.armed = true + val report = handler.reconcileWith(FakeEngine(inventoryJson(rows = listOf(engineRowJson(txid))))) + faults.armed = false + + assertFalse(report.completed) + assertEquals(1, report.storeFailures) + assertEquals("a failed read is not a hit either", 0, report.alreadyPresent) + assertEquals(0, report.inserted) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(txid, 0))) + } + + @Test + fun theRunStopsWhenTheFlipLookupCannotRead() = runTest { + // The classify pass re-reads each known-uncredited row before it + // flips it. A read that fails there must not read as "stale": the + // step fails, nothing staged is kept, and the row stays as it was. + val faults = useFaultedDatabase("SELECT * FROM txos WHERE outpoint") + seedReconcileWallet() + val txid = ByteArray(32) { 0x85.toByte() } + seedUnspentTxo(txid) + org.robolectric.shadows.ShadowLog.clear() + faults.armed = true + val report = handler.reconcileWith(FakeEngine(inventoryJson(spent = listOf(txid to 0)))) + faults.armed = false + + assertFalse(report.completed) + assertEquals(1, report.storeFailures) + assertEquals("the page itself was read", 1, report.storeRows) + assertEquals(0, report.flipped) + assertEquals("a failed read is not a stale page either", 0, report.staleRetries) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent) + val failure = org.robolectric.shadows.ShadowLog.getLogs() + .map { it.msg } + .single { it.startsWith("txos reconcile: store step failed") } + assertTrue(failure, failure.contains("operation=flip")) + assertFalse(failure, failure.contains(txid.toHexLower())) + } + + @Test + fun aCancelledRunDoesNothingAndSaysSo() = runTest { + seedReconcileWallet() + val engine = FakeEngine(inventoryJson(rows = listOf(engineRowJson(ByteArray(32) { 0x11 })))) + + val report = handler.reconcileWith(engine, isCancelled = { true }) + + assertFalse(report.completed) + assertEquals(0, report.mutations) + assertEquals("cancellation is checked before the first engine read", 0, engine.pages) + assertEquals(0, db.txoDao().pageByWallet(walletId, ByteArray(0), 100).size) + } + + @Test + fun aRunCancelledMidWayStopsAtThePageBoundaryAndKeepsWhatLanded() = runTest { + seedReconcileWallet() + val engine = FakeEngine( + inventoryJson(rows = (0 until 6).map { i -> engineRowJson(ByteArray(32) { (0x20 + i).toByte() }) }), + ) + var checks = 0 + + val report = handler.reconcileWith( + engine, + // The run checks once before each engine read and once more + // before each store step: the first two admit page one and its + // heal; the third (before page two) reports the epoch bumped. + isCancelled = { ++checks >= 3 }, + ) + + assertFalse(report.completed) + assertEquals(1, engine.pages) + assertEquals("the page that landed stays", 2, report.inserted) + assertEquals(2, db.txoDao().pageByWallet(walletId, ByteArray(0), 100).size) + } + + @Test + fun aPageReadWhileTheManagerShutsDownNeverReachesTheStore() = runTest { + // Shutdown (or a wallet deletion) bumps the epoch while an engine + // read is parked; the page that read returns must not be written: + // cancellation is re-checked under the exclusion lock, after the + // read and before the write. + seedReconcileWallet() + val engine = FakeEngine( + inventoryJson(rows = listOf(engineRowJson(ByteArray(32) { 0x41 }), engineRowJson(ByteArray(32) { 0x42 }))), + ) + var cancelled = false + + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + pageSize = 1, + isCancelled = { cancelled }, + engineUtxoPage = { cursor, limit -> + engine.page(cursor, limit).also { cancelled = true } + }, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + ) + + assertFalse(report.completed) + assertEquals("no second engine read may start", 1, engine.pages) + assertEquals("the old page must not reach the store", 0, report.mutations) + assertEquals(0, db.txoDao().pageByWallet(walletId, ByteArray(0), 100).size) + } + + // ── 7. Logs carry counts and digests only ───────────────────────── + + @Test + fun theReconcileLogsCarryNoWalletHistory() = runTest { + // Every line the reconcile writes, rendered with fixture values + // shaped like the real thing: no address, no txid or outpoint in + // either byte orientation, no script, no wallet id — and, as a + // backstop against any future field, no long hex or address-length + // Base58 run at all. References are 12 hex characters, keyed: the + // plain SHA-256 prefix of a public outpoint (what anyone holding the + // logs could compute and match) never appears. + val address = "yTestPrivacyFixtureAddress12345678" + val healedTxid = ByteArray(32) { (0xa0 + it % 16).toByte() } + val flippedTxid = ByteArray(32) { (0x30 + it % 16).toByte() } + val unknownTxid = ByteArray(32) { (0x50 + it % 16).toByte() } + seedWalletWithAddress(walletId, address) + seedUnspentTxo(flippedTxid, vout = 1, address = address) + seedUnspentTxo(unknownTxid, vout = 2, address = address) + val engine = FakeEngine( + inventoryJson( + rows = listOf( + engineRowJson(healedTxid, amount = 100_001L, address = address, height = 1_434_986), + // Refused (immature), so the skip path renders too. + engineRowJson( + ByteArray(32) { 0x66 }, address = address, height = reconcileTip, + healVerdict = healImmature, + ), + ), + spent = listOf(flippedTxid to 1), + ), + ) + org.robolectric.shadows.ShadowLog.clear() + + val report = handler.reconcileWith(engine) + // A second, failing run renders the incomplete summary as well. + handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = engine::page, + releaseVerdicts = {}, + classifyOutpoints = { null }, + ) + + assertEquals(1, report.inserted) + assertEquals(1, report.flipped) + assertEquals(1, report.unknown) + val lines = org.robolectric.shadows.ShadowLog.getLogs().map { it.msg } + .filter { it.contains("txos reconcile") } + assertTrue(lines.any { it.startsWith("txos reconcile: item action=healed ") }) + assertTrue(lines.any { it.startsWith("txos reconcile: item action=flipped_spent ") }) + assertTrue(lines.any { it.startsWith("txos reconcile: inserted=") || it.startsWith("txos reconcile: engineRows=") }) + assertTrue(lines.any { it.contains("transportFailures=1") && it.contains("completed=false") }) + val walletRef = logReference(walletId) + assertTrue("every summary names the wallet by reference", lines.filter { "completed=" in it }.all { "wallet=$walletRef" in it }) + val flippedRef = logReference(makeOutpoint(flippedTxid, 1)) + assertTrue( + "the flipped coin's line carries its in-process reference", + lines.any { it == "txos reconcile: item action=flipped_spent outpoint=$flippedRef wallet=$walletRef" }, + ) + + val forbidden = listOf( + "address" to address, + "script" to reconcileScript.toHexLower(), + "healed txid" to healedTxid.toHexLower(), + "healed txid reversed" to healedTxid.reversedArray().toHexLower(), + "flipped txid" to flippedTxid.toHexLower(), + "flipped txid reversed" to flippedTxid.reversedArray().toHexLower(), + "unknown txid" to unknownTxid.toHexLower(), + "flipped outpoint" to makeOutpoint(flippedTxid, 1).toHexLower(), + "wallet id" to walletId.toHexLower(), + "healed outpoint's unkeyed digest" to unkeyedReference(makeOutpoint(healedTxid, 0)), + "flipped outpoint's unkeyed digest" to unkeyedReference(makeOutpoint(flippedTxid, 1)), + "wallet id's unkeyed digest" to unkeyedReference(walletId), + ) + val longHex = Regex("[0-9a-fA-F]{32,}") + val base58Run = Regex("[1-9A-HJ-NP-Za-km-z]{26,}") + for (line in lines) { + for ((label, value) in forbidden) { + assertFalse("the log must not carry the $label: $line", line.contains(value, ignoreCase = true)) + } + assertNull("no 32+ character hex run: $line", longHex.find(line)) + assertNull("no address-length Base58 run: $line", base58Run.find(line)) + } + } + + /** The reference an UNKEYED digest would give [value] — what anyone + * holding the logs could compute for a public outpoint. */ + private fun unkeyedReference(value: ByteArray): String = + java.security.MessageDigest.getInstance("SHA-256").digest(value).copyOf(6).toHexLower() + + @Test + fun logReferencesAreKeyedPerProcessAndStableWithinIt() { + // Stable within the process, so one run's lines still correlate; + // never the plain SHA-256 prefix, so a public outpoint cannot be + // hashed and matched against the logs. + val outpoint = makeOutpoint(ByteArray(32) { (0x40 + it).toByte() }, 3) + val ref = logReference(outpoint) + assertTrue("12 lower-hex characters: $ref", Regex("[0-9a-f]{12}").matches(ref)) + assertEquals(ref, logReference(outpoint.copyOf())) + assertFalse("distinct values get distinct references", ref == logReference(makeOutpoint(ByteArray(32), 3))) + for (value in listOf(outpoint, walletId, ByteArray(0))) { + assertFalse( + "a reference must not be the unkeyed digest", + logReference(value) == unkeyedReference(value), + ) + } + } + + @Test + fun aConsistentRunLogsMirrorConsistentWithTheEngineRowCount() = runTest { + seedReconcileWallet() + val txid = ByteArray(32) { 0x5e } + seedUnspentTxo(txid) + org.robolectric.shadows.ShadowLog.clear() + + handler.reconcileWith(FakeEngine(inventoryJson(rows = listOf(engineRowJson(txid))))) + + val summary = org.robolectric.shadows.ShadowLog.getLogs().map { it.msg } + .single { it.startsWith("txos reconcile:") } + assertTrue(summary, summary.startsWith("txos reconcile: mirror consistent (")) + assertTrue(summary, summary.contains("engineRows=1")) + assertTrue(summary, summary.contains("completed=true")) + } + + @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, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> malformed }, + releaseVerdicts = {}, + classifyOutpoints = { queriesJson -> + ByteArray( + kotlinx.serialization.json.Json + .parseToJsonElement(queriesJson).jsonArray.size, + ) + }, + ) + }.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)), + ) + } + + @Test + fun anInventoryRowMissingAnyAccountFieldFailsTheDecode() { + // The account tuple is the routing context a healed row is filed + // under. A row missing `index` must not decode as account 0 and be + // healed under the wrong owner; only the DashPay identity halves + // may be absent. + val complete = kotlinx.serialization.json.Json.parseToJsonElement( + engineRowJson(changeTxid, vout = 41), + ).jsonObject + fun page(row: Map) = + """{"utxos":[${kotlinx.serialization.json.JsonObject(row)}],"cursor":null,"hasMore":false}""" + assertEquals( + 1, + PlatformWalletPersistenceHandler.decodeEngineUtxoPage(page(complete)).utxos.size, + ) + + for (key in listOf("typeTag", "standardTag", "index", "registrationIndex", "keyClass", "healVerdict")) { + val thrown = runCatching { + PlatformWalletPersistenceHandler.decodeEngineUtxoPage(page(complete - key)) + }.exceptionOrNull() + assertTrue( + "a row without `$key` must fail the decode, got $thrown", + thrown is kotlinx.serialization.SerializationException, + ) + } + } + + @Test + fun classificationQueriesCarryZeroValuedAccountFields() { + // A BIP44 account-0 row is the common case and every field of its + // account tuple is 0. kotlinx's default `Json` drops a property + // equal to its default, so a defaulted tuple went out as + // {"typeTag":0,"txid":…,"vout":…,"scriptHex":…} and the native + // side rejected every batch: "malformed outpoint queries: missing + // field `standardTag` at line 1 column 162" (int26/int27 devices). + val txid = changeTxid.toHexLower() + val script = "76a914" + "cd".repeat(20) + "88ac" + val json = PlatformWalletPersistenceHandler.encodeOutpointQueries( + listOf( + PlatformWalletPersistenceHandler.OutpointQuery( + typeTag = 0, + standardTag = 0, + index = 0, + registrationIndex = 0, + keyClass = 0, + txid = txid, + vout = 1, + scriptHex = script, + ), + ), + ) + val query = kotlinx.serialization.json.Json.parseToJsonElement(json).jsonArray.single().jsonObject + for (k in FakeEngine.RUST_REQUIRED_QUERY_KEYS) { + assertNotNull("missing field `$k` in $json", query[k]) + } + assertEquals(0, query["standardTag"]!!.jsonPrimitive.int) + assertEquals(script, query["scriptHex"]!!.jsonPrimitive.content) + // The identity halves are the tuple's only optional fields; absent + // is what the native side emits for a non-DashPay account. + assertNull(query["userIdentityId"]) + assertNull(query["friendIdentityId"]) + } + + // ── Cross-language golden fixture (shared with rs-unified-sdk-jni) ── + + /** + * The checked-in TXO-reconcile transport payloads. The Rust + * `wallet_manager::tests` read the SAME file (`include_str!` of this + * resource): they serialize the real `UtxoPage` for four heal-page + * rows and require `inventoryPage` exactly, and they deserialize + * `outpointQueries` through the real `OutpointQuery` conversion. Here + * the page is decoded by the production decoder and the queries are + * built by the production encoder, so neither codec can drift from the + * other without one of the two suites failing. + */ + private fun reconcileGolden(): kotlinx.serialization.json.JsonObject = + javaClass.getResourceAsStream("/golden/txo_reconcile_transport_v1.json") + .use { requireNotNull(it) { "golden fixture resource missing" }.readBytes() } + .let { kotlinx.serialization.json.Json.parseToJsonElement(it.decodeToString()).jsonObject } + + /** The four golden coins: txid bytes counting up from [first]. */ + private fun goldenTxid(first: Int) = ByteArray(32) { (first + it).toByte() } + private fun goldenScript(fill: Int) = + byteArrayOf(0x76, 0xa9.toByte(), 0x14) + ByteArray(20) { fill.toByte() } + + byteArrayOf(0x88.toByte(), 0xac.toByte()) + private val goldenUserIdentity = ByteArray(32) { 0xa1.toByte() } + private val goldenFriendIdentity = ByteArray(32) { 0xb2.toByte() } + + @Test + fun theInventoryDecoderReadsTheGoldenRustPage() { + val page = PlatformWalletPersistenceHandler.decodeEngineUtxoPage( + reconcileGolden()["inventoryPage"]!!.toString(), + ) + + assertTrue(page.hasMore) + assertNotNull("a page with more to come carries its cursor", page.cursor) + assertEquals( + listOf( + // BIP44 account 0: every account field zero, and present. + PlatformWalletPersistenceHandler.EngineUtxoRow( + 0, 0, 0, 0, 0, null, null, goldenTxid(0x00).toHexLower(), 0, 19_549L, + reconcileAddress, goldenScript(0x5b).toHexLower(), 1_534_921, + true, false, false, false, healEligible, + ), + // Non-default account fields, every flag set, and an amount + // past 32 bits. + PlatformWalletPersistenceHandler.EngineUtxoRow( + 0, 1, 2, 0, 0, null, null, goldenTxid(0x20).toHexLower(), 7, 5_000_000_000L, + "yGoldenFixtureBip32Addr", goldenScript(0xc3).toHexLower(), 1_400_000, + true, true, true, true, healEligible, + ), + // A registration index, and a script with no address form. + PlatformWalletPersistenceHandler.EngineUtxoRow( + 3, 0, 0, 4, 0, null, null, goldenTxid(0x40).toHexLower(), 1, 100_000L, + "", "", 0, + false, false, false, false, healInvalid, + ), + // DashPay: both identity halves, as lower hex. + PlatformWalletPersistenceHandler.EngineUtxoRow( + 12, 0, 1, 0, 0, goldenUserIdentity.toHexLower(), goldenFriendIdentity.toHexLower(), + goldenTxid(0x60).toHexLower(), 3, 250_000L, + "yGoldenFixtureDashPayAddr", goldenScript(0xd4).toHexLower(), 1_534_000, + true, true, false, false, healImmature, + ), + ), + page.utxos, + ) + } + + @Test + fun classificationQueriesMatchTheGoldenRustInput() { + // The same four coins, filed under the store accounts they resolve + // to, through the encoder [readUnspentPage] uses. A zeroed identity + // half on a non-DashPay account goes out absent, as the native side + // emits it. + fun account(type: Int, index: Int, name: String) = org.dashfoundation.dashsdk.persistence.entities + .AccountEntity(walletId = walletId, accountType = type, accountIndex = index, accountTypeName = name) + val queries = listOf( + PlatformWalletPersistenceHandler.outpointQueryFor( + account(0, 0, "Standard").copy(userIdentityId = ByteArray(32)), + goldenTxid(0x00), 0, goldenScript(0x5b), + ), + PlatformWalletPersistenceHandler.outpointQueryFor( + account(0, 2, "Standard").copy(standardTag = 1), + goldenTxid(0x20), 7, goldenScript(0xc3), + ), + PlatformWalletPersistenceHandler.outpointQueryFor( + account(3, 0, "IdentityTopUp").copy(registrationIndex = 4), + goldenTxid(0x40), 1, ByteArray(0), + ), + PlatformWalletPersistenceHandler.outpointQueryFor( + account(12, 1, "DashpayReceivingFunds").copy( + userIdentityId = goldenUserIdentity, + friendIdentityId = goldenFriendIdentity, + ), + goldenTxid(0x60), 3, goldenScript(0xd4), + ), + ) + + val json = PlatformWalletPersistenceHandler.encodeOutpointQueries(queries) + + assertEquals( + "Kotlin's classification batch drifted from the golden fixture the Rust deserializer is tested against", + reconcileGolden()["outpointQueries"], + kotlinx.serialization.json.Json.parseToJsonElement(json), + ) + } + + /** + * Drive the paged reconcile from one whole-inventory JSON blob — the + * shape these tests describe an engine in. The blob is served the way + * the transport 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. Every still-unattributed TXO is filed under a default BIP44 + * account first (see [attributeUnownedTxos]), which is also the account + * the inventory's `typeTag 0 / index 0` rows resolve to. + */ + private suspend fun PlatformWalletPersistenceHandler.reconcileFromInventory( + walletId: ByteArray, + inventoryJson: String, + pageSize: Int = 2, + ): PlatformWalletPersistenceHandler.TxoReconcileReport { + attributeUnownedTxos(walletId) + val engine = FakeEngine(inventoryJson) + return reconcileTxos( + walletId = walletId, + pageSize = pageSize, + engineUtxoPage = engine::page, + releaseVerdicts = {}, + classifyOutpoints = engine::classify, + retryStalePage = retryStale, + ) + } + + /** + * File every still-unattributed TXO of [walletId] under a default + * BIP44 account, seeding that account on first use. + * + * The classify pass asks the engine about a row by naming the account + * the STORE filed it under, so a row with no resolvable owner carries no + * claim and is counted instead of asked about; the heal pass refuses a + * coin whose account tuple names no store account. Production rows + * always resolve one, through the explicit link or the `core_addresses` + * projection; these tests write TXOs straight through the changeset + * callbacks without the account rows a real wallet load creates, so the + * link is seeded here rather than in each test. Rows a test attributed + * itself (the watch-only contact case) keep their own account. + */ + private suspend fun attributeUnownedTxos(walletId: ByteArray): Long { + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val existing = db.accountDao().getByKey(walletId, accountType = 0, accountIndex = 0) + val ownerId = existing.firstOrNull()?.id + ?: db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 0, + accountIndex = 0, + accountTypeName = "Standard", + ), + ) + for (row in db.txoDao().pageByWallet(walletId, ByteArray(0), limit = 1_000)) { + if (row.accountId == null && row.coreAddressId == null) { + db.txoDao().upsert(row.copy(accountId = ownerId)) + } + } + return ownerId + } + + /** + * 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 inventories: 1 unspent, 2 + * known-uncredited (the blob's `spent` list: the engine holds a mined + * record spending the coin), 3 not-owned (its optional `notOwned` + * list), 0 neither. + */ + private class FakeEngine(inventoryJson: String) { + private val utxos: List + private val unspentKeys: Set + private val spentKeys: Set + private val notOwnedKeys: 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 + + /** Every classification query received, in order. */ + val queries = ArrayList() + + 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() + notOwnedKeys = (root["notOwned"]?.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(queriesJson: String): ByteArray { + batches++ + val batch = kotlinx.serialization.json.Json + .parseToJsonElement(queriesJson).jsonArray + classified += batch.size + val verdicts = ByteArray(batch.size) + for ((i, query) in batch.withIndex()) { + val q = query.jsonObject + queries += q + // Every query must name the account the store filed the + // coin under and the script it recorded — that claim is + // the whole reason the engine can answer NOT_OWNED. The + // real deserializer rejects the WHOLE batch on a missing + // key, so this fake does too rather than answering it. + for (k in RUST_REQUIRED_QUERY_KEYS) { + requireNotNull(q[k]) { "missing field `$k` in classification query $q" } + } + requireNotNull(q["scriptHex"]) { "classification query carries no script" } + val k = key( + q["txid"]!!.jsonPrimitive.content, + q["vout"]!!.jsonPrimitive.int, + ) + verdicts[i] = when { + k in notOwnedKeys -> 3 + k in unspentKeys -> 1 + k in spentKeys -> 2 + else -> 0 + } + } + return verdicts + } + + private fun key(txidHex: String, vout: Int) = "$txidHex:$vout" + + companion object { + /** The rs-unified-sdk-jni `OutpointQuery` keys with no serde + * default (its flattened `UtxoAccountTuple` plus the outpoint): + * omitting any one fails the whole classification batch with + * `malformed outpoint queries: missing field …`. */ + val RUST_REQUIRED_QUERY_KEYS = listOf( + "typeTag", "standardTag", "index", "registrationIndex", "keyClass", "txid", "vout", + ) + } + } + + // ── Credit verdicts at the changeset seam (dashpay/platform#4638 Part 1) ── + // + // Port of the Swift `BornSpentTxoPersistTests`. The shape the verdicts + // exist for (rust-dashcore#992, dashpay/platform#4575): a coin is spent + // by a transaction with no wallet-owned output (a CoinJoin collateral + // burn — sole `OP_RETURN` output) that the engine processed while the + // coin was not yet in its UTXO set, so the spender matched nothing and + // was discarded. When the funding record is (re)emitted it still + // classifies the output `Received`, a `utxos_added` entry is derived + // from that role, and — without the verdict — the row is written + // UNSPENT for a coin the engine never held. The restore path then hands + // it back to the engine on every launch. With the verdict the row is + // written spent at creation and the restore emits nothing for it. + + private val bornSpentFundingTxid = ByteArray(32) { 0x61 } + private val bornSpentOtherTxid = ByteArray(32) { 0x62 } + private val bornSpentAddress = "yBornSpentFixtureAddr" + + private class CreditVerdict(val txid: ByteArray, val vout: Int, val code: Byte, val height: Int = 0) + + /** The verdict slot alone, inside whatever bracket the caller opened. */ + private fun stageVerdicts( + h: PlatformWalletPersistenceHandler, + vararg verdicts: CreditVerdict, + wallet: ByteArray = walletId, + ): Int { + val outpoints = ByteArray(verdicts.size * 36) + verdicts.forEachIndexed { i, v -> + System.arraycopy(makeOutpoint(v.txid, v.vout), 0, outpoints, i * 36, 36) + } + return h.onWalletChangesetUtxoVerdicts( + wallet, + outpoints, + ByteArray(verdicts.size) { verdicts[it].code }, + IntArray(verdicts.size) { verdicts[it].height }, + verdicts.size, + ) + } + + /** One `utxos_added` entry for `txid:vout`, inside whatever bracket the caller opened. */ + private fun stageBornSpentUtxo(h: PlatformWalletPersistenceHandler, txid: ByteArray, vout: Int = 0): Int = + h.onWalletChangesetUtxoAdded( + walletId, txid, vout, 19_549, bornSpentAddress, ByteArray(25) { 6 }, + 2_391_743, false, true, false, false, + ) + + private fun verdictRound( + h: PlatformWalletPersistenceHandler = handler, + success: Boolean = true, + body: () -> Unit, + ) { + h.onChangesetBegin(walletId) + body() + h.onChangesetEnd(walletId, success) + } + + private val observedSpent = PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_OBSERVED_SPENT + private val doomed = PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_DOOMED + private val uncredited = PlatformWalletPersistenceHandler.UTXO_CREDIT_VERDICT_UNCREDITED + + @Test + fun observedSpentVerdictWritesTheRowSpentAndKeepsItOutOfTheRestore() = runTest { + seedWalletWithAddress(walletId, bornSpentAddress) + verdictRound { + assertEquals(0, stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896))) + assertEquals(0, stageBornSpentUtxo(handler, bornSpentFundingTxid)) + } + val coin = db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!! + assertTrue(coin.isSpent) + assertNull("the spender was never recorded", coin.spendingTxid) + assertNull(coin.spendingInputIndex) + assertNull(coin.supersededByTxid) + assertEquals(19_549L, coin.amount) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun doomedVerdictWritesTheRowSpent() = runTest { + seedWalletWithAddress(walletId, bornSpentAddress) + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, doomed)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun uncreditedVerdictLeavesANewRowUnspent() = runTest { + // A context-free verdict never marks the row spent: the engine only + // said "not credited", and the spender's own record or the sweep + // callback settles it. What it does change is the redelivery clear. + seedWalletWithAddress(walletId, bornSpentAddress) + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, uncredited)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun aVerdictVetoesTheRedeliveryClearAndItsAbsenceDoesNot() = runTest { + // Without a verdict, a redelivery of a spent, unlinked row clears + // the flag (the wallet is handing the coin back as unspent — the + // recovery rule). With ANY verdict the clear is vetoed: the record + // merely still names the output as ours, the engine does not hold it. + seedWalletWithAddress(walletId, bornSpentAddress) + val outpoint = makeOutpoint(bornSpentFundingTxid, 0) + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + + // Redelivered with a context-free verdict: stays spent. + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, uncredited)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertTrue(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + + // Redelivered with no verdict at all: the engine credited it again + // (a reorg of the spender), and the row follows the wallet. + verdictRound { stageBornSpentUtxo(handler, bornSpentFundingTxid) } + assertFalse(db.txoDao().getByOutpoint(outpoint)!!.isSpent) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun aVerdictKeepsASweepStampOnARedeliveredRow() = runTest { + // The veto covers the whole clear, the stamp included: a sweep-held + // row redelivered with a verdict keeps both its flag and its stamp, + // exactly as Swift leaves `supersededByTxid` alone. + seedWalletWithAddress(walletId, bornSpentAddress) + val outpoint = makeOutpoint(bornSpentFundingTxid, 0) + val winner = ByteArray(32) { 0x63 } + verdictRound { stageBornSpentUtxo(handler, bornSpentFundingTxid) } + db.txoDao().upsert( + db.txoDao().getByOutpoint(outpoint)!!.copy(isSpent = true, supersededByTxid = winner), + ) + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, uncredited)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + val row = db.txoDao().getByOutpoint(outpoint)!! + assertTrue(row.isSpent) + assertTrue(winner.contentEquals(row.supersededByTxid)) + } + + @Test + fun verdictsDoNotOutliveTheirRound() = runTest { + // A verdict for an outpoint the round never delivers is dropped with + // the round, and a later round delivering that outpoint without a + // verdict writes it unspent as ever. + seedWalletWithAddress(walletId, bornSpentAddress) + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentOtherTxid, 0, observedSpent, 2_402_896)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(bornSpentOtherTxid, 0))) + + verdictRound { stageBornSpentUtxo(handler, bornSpentOtherTxid) } + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(bornSpentOtherTxid, 0))!!.isSpent) + } + + @Test + fun aVerdictForAnUnknownWalletOrOutsideARoundIsAcceptedAndIgnored() = runTest { + // The same contract as every other per-kind callback for an unknown + // wallet: accepted, nothing written. Rust only fires the slot inside + // a round; outside one there is nothing to scope it to. + val stranger = ByteArray(32) { 0x7e } + assertEquals( + 0, + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent), wallet = stranger), + ) + handler.onChangesetBegin(stranger) + assertEquals( + 0, + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent), wallet = stranger), + ) + handler.onChangesetEnd(stranger, success = true) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))) + } + + @Test + fun aMalformedVerdictBatchFailsTheCallback() { + // A verdict silently dropped is a phantom coin the store hands back + // to the engine at the next load, so a batch whose arrays do not + // match its count fails the round instead of being half-read. + handler.onChangesetBegin(walletId) + assertEquals( + 1, + handler.onWalletChangesetUtxoVerdicts(walletId, ByteArray(36), ByteArray(2), IntArray(2), 2), + ) + handler.onChangesetEnd(walletId, success = false) + } + + @Test + fun aRolledBackRoundLeavesNoRow() = runTest { + // A rolled-back round leaves neither the row nor the verdict behind. + seedWalletWithAddress(walletId, bornSpentAddress) + verdictRound(success = false) { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896)) + stageBornSpentUtxo(handler, bornSpentFundingTxid) + } + assertNull(db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + + verdictRound { stageBornSpentUtxo(handler, bornSpentFundingTxid) } + assertFalse( + "the rolled-back verdict did not survive into a later round", + db.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent, + ) + } + + @Test + fun theRoundLogsOneLineOfCountsAndNoOutpoints() = runTest { + seedWalletWithAddress(walletId, bornSpentAddress) + org.robolectric.shadows.ShadowLog.clear() + verdictRound { + stageVerdicts( + handler, + CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896), + CreditVerdict(bornSpentFundingTxid, 1, doomed), + CreditVerdict(bornSpentFundingTxid, 2, uncredited), + ) + stageBornSpentUtxo(handler, bornSpentFundingTxid, 0) + stageBornSpentUtxo(handler, bornSpentFundingTxid, 1) + stageBornSpentUtxo(handler, bornSpentFundingTxid, 2) + } + val lines = org.robolectric.shadows.ShadowLog.getLogs() + .map { it.msg } + .filter { it.startsWith("txo credit verdicts:") } + assertEquals( + listOf("txo credit verdicts: observedSpent=1 doomed=1 alreadySpent=0 uncredited=1 roundSuccess=true"), + lines, + ) + val txidHex = bornSpentFundingTxid.toHex() + assertTrue( + "no txid, outpoint or address in any persistence log line", + org.robolectric.shadows.ShadowLog.getLogs().none { + it.msg.contains(txidHex) || it.msg.contains(bornSpentAddress) + }, + ) + + // A redelivery of an already-spent row counts as such, not as a new spend. + org.robolectric.shadows.ShadowLog.clear() + verdictRound { + stageVerdicts(handler, CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896)) + stageBornSpentUtxo(handler, bornSpentFundingTxid, 0) + } + assertEquals( + listOf("txo credit verdicts: observedSpent=0 doomed=0 alreadySpent=1 uncredited=0 roundSuccess=true"), + org.robolectric.shadows.ShadowLog.getLogs().map { it.msg }.filter { it.startsWith("txo credit verdicts:") }, + ) + + // A round with no verdict logs nothing. + org.robolectric.shadows.ShadowLog.clear() + verdictRound { stageBornSpentUtxo(handler, bornSpentOtherTxid) } + assertTrue( + org.robolectric.shadows.ShadowLog.getLogs().none { it.msg.startsWith("txo credit verdicts:") }, + ) + } + + @Test + fun aRestartRestoresNothingForABornSpentRow() = runTest { + // The acceptance shape: after the round that wrote the row spent, a + // relaunch — a fresh handler over the same ON-DISK store, reopened — + // restores zero coins for the wallet, and so does the relaunch after + // that. The phantom never comes back. + val context = ApplicationProvider.getApplicationContext() + val name = "born-spent-restart.db" + context.deleteDatabase(name) + fun open() = Room.databaseBuilder(context, DashDatabase::class.java, name) + .allowMainThreadQueries() + .build() + fun handlerOver(store: DashDatabase) = PlatformWalletPersistenceHandler( + store, + Dispatchers.Unconfined, + null, + storedTransactionInputs = storedInputs, + ) + try { + val first = open() + try { + val h = handlerOver(first) + h.onPersistWalletMetadata(walletId, testnet, groupId, 0) + h.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + val account = first.accountDao().observeByWallet(walletId).first().single() + first.coreAddressDao().upsert( + CoreAddressEntity( + address = bornSpentAddress, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + verdictRound(h) { + stageVerdicts(h, CreditVerdict(bornSpentFundingTxid, 0, observedSpent, 2_402_896)) + stageBornSpentUtxo(h, bornSpentFundingTxid) + } + assertTrue(first.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent) + assertTrue(h.onLoadWalletList().single().utxos.isEmpty()) + } finally { + first.close() + } + repeat(2) { + val reopened = open() + try { + val restore = handlerOver(reopened).onLoadWalletList().single() + assertTrue( + reopened.txoDao().getByOutpoint(makeOutpoint(bornSpentFundingTxid, 0))!!.isSpent, + ) + assertTrue("a relaunch restores nothing for the burned coin", restore.utxos.isEmpty()) + } finally { + reopened.close() + } + } + } finally { + context.deleteDatabase(name) + } + } + + /** One packed cover-set entry: owner id ‖ contact id ‖ covered-from (LE u32). */ + private fun backfillEntry(owner: Byte, contact: Byte, coveredFrom: Int): ByteArray = + ByteArray(32) { owner } + ByteArray(32) { contact } + ByteArray(4) + byteArrayOf( + (coveredFrom and 0xFF).toByte(), + ((coveredFrom shr 8) and 0xFF).toByte(), + ((coveredFrom shr 16) and 0xFF).toByte(), + ((coveredFrom shr 24) and 0xFF).toByte(), + ) + + /** + * dashpay/platform#4302: the DashPay backfill record is a whole-record + * replace of the three `wallets.dashPayBackfill*` columns, written on + * the same round as the lowered `syncedHeight` the rescan carries. A + * later record replaces the earlier one outright — a contact the new + * record no longer lists is gone, never merged back — and the cover set + * is stored byte-for-byte so native reads back exactly what it wrote. + */ + @Test + fun onWalletChangesetDashPayBackfillReplacesTheRecordOnTheWalletRow() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val row = db.walletDao().getByWalletId(walletId)!! + assertNull("no record until the slot fires", row.dashPayBackfillFloor) + assertNull(row.dashPayBackfillRewoundFrom) + assertNull(row.dashPayBackfillCovered) + + val first = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_167_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + assertEquals( + 0, + handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, first, 2), + ) + handler.onChangesetEnd(walletId, success = true) + + val stored = db.walletDao().getByWalletId(walletId)!! + assertEquals("the lowered cursor lands with the record", 2_167_092, stored.syncedHeight) + assertEquals(2_167_092, stored.dashPayBackfillFloor) + assertEquals(2_537_092, stored.dashPayBackfillRewoundFrom) + assertTrue(first.contentEquals(stored.dashPayBackfillCovered)) + + // A later record replaces the whole cover set. + val second = backfillEntry(1, 2, 2_167_092) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetDashPayBackfill(walletId, 2_100_000, 2_600_000, second, 1) + handler.onChangesetEnd(walletId, success = true) + val replaced = db.walletDao().getByWalletId(walletId)!! + assertEquals(2_100_000, replaced.dashPayBackfillFloor) + assertEquals(2_600_000, replaced.dashPayBackfillRewoundFrom) + assertTrue(second.contentEquals(replaced.dashPayBackfillCovered)) + assertEquals("sibling columns are untouched", 2_167_092, replaced.syncedHeight) + } + + /** + * The record vouches for the cursor written in its round, so a round + * that rolls back must take the record with it: a persisted record over + * a cursor still at its high-water would tell native the backfill + * resumed when it never started — the lossy direction. A malformed cover + * set (not a whole number of 68-byte entries) is refused outright. + */ + @Test + fun onWalletChangesetDashPayBackfillRollsBackWithItsRoundAndRefusesATornCoverSet() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_537_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = 2_167_092, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(0), + ) + handler.onWalletChangesetDashPayBackfill( + walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092), 1, + ) + handler.onChangesetEnd(walletId, success = false) + + val row = db.walletDao().getByWalletId(walletId)!! + assertEquals("the rolled-back cursor stays at its committed value", 2_537_092, row.syncedHeight) + assertNull("the rolled-back record never lands", row.dashPayBackfillFloor) + assertNull(row.dashPayBackfillRewoundFrom) + assertNull(row.dashPayBackfillCovered) + + handler.onChangesetBegin(walletId) + val rc = handler.onWalletChangesetDashPayBackfill( + walletId, 2_167_092, 2_537_092, backfillEntry(1, 2, 2_167_092).copyOf(67), 1, + ) + handler.onChangesetEnd(walletId, success = rc == 0) + assertTrue("a torn cover set is refused", rc != 0) + assertNull(db.walletDao().getByWalletId(walletId)!!.dashPayBackfillFloor) + } + + /** + * The restore side of dashpay/platform#4302: a stored record comes back + * on `loadWalletList` exactly as written, and a wallet with no record + * (every pre-migration row) reports none, so native rewinds once for it + * as it always did. + */ + @Test + fun loadWalletListRoundTripsTheDashPayBackfillRecord() = runTest { + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + val before = handler.onLoadWalletList().single() + assertFalse("no record on a fresh wallet", before.hasDashPayBackfill) + assertEquals(0, before.dashPayBackfillFloor) + assertEquals(0, before.dashPayBackfillRewoundFrom) + assertEquals(0, before.dashPayBackfillCovered.size) + + val covered = backfillEntry(1, 2, 2_167_092) + backfillEntry(1, 3, 2_300_000) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetDashPayBackfill(walletId, 2_167_092, 2_537_092, covered, 2) + handler.onChangesetEnd(walletId, success = true) + + val after = handler.onLoadWalletList().single() + assertTrue(after.hasDashPayBackfill) + assertEquals(2_167_092, after.dashPayBackfillFloor) + assertEquals(2_537_092, after.dashPayBackfillRewoundFrom) + assertTrue(covered.contentEquals(after.dashPayBackfillCovered)) + } + +} + +/** + * Room open-helper factory that fails exactly one prepared statement — + * the one whose SQL starts with [failingSqlPrefix] — once [armed], and + * delegates every other read and write to real SQLite. + * + * Faults ONE query rather than dropping its table: `asset_locks` is read + * by three independent restore builders, so a table-level fault proves + * nothing about the isolation of any single one of them. + */ +private class SingleStatementFaultInjector( + private val failingSqlPrefix: String, +) : SupportSQLiteOpenHelper.Factory { + private val real = FrameworkSQLiteOpenHelperFactory() + + /** Off while the fixture is seeded, then flipped on by the test. */ + var armed: Boolean = false + + override fun create( + configuration: SupportSQLiteOpenHelper.Configuration, + ): SupportSQLiteOpenHelper = Helper(real.create(configuration)) + + private inner class Helper( + private val delegate: SupportSQLiteOpenHelper, + ) : SupportSQLiteOpenHelper by delegate { + override val writableDatabase: SupportSQLiteDatabase + get() = Db(delegate.writableDatabase) + override val readableDatabase: SupportSQLiteDatabase + get() = Db(delegate.readableDatabase) } private inner class Db( @@ -7036,3 +9341,6 @@ private class SingleStatementFaultInjector( else delegate.query(query, cancellationSignal) } } + +/** `AccountTypeTagFFI::DashpayExternalAccount`: a contact's watch-only DIP-15 account. */ +private const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileRunEndTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileRunEndTest.kt new file mode 100644 index 00000000000..8bac59f0527 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileRunEndTest.kt @@ -0,0 +1,52 @@ +package org.dashfoundation.dashsdk.wallet + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The end of a TXO-store reconcile run ([endTxoReconcileRun]): the held + * verdicts are freed first, then the native claim is released, and a + * failure of either never skips the other. Robolectric, for the warning + * each failure logs. + */ +@RunWith(RobolectricTestRunner::class) +class TxoReconcileRunEndTest { + + @Test + fun theHeldVerdictsAreFreedBeforeTheClaimIsReleased() { + val steps = ArrayList() + endTxoReconcileRun( + walletRef = "ref", + freeHeldVerdicts = { steps += "free" }, + finish = { steps += "finish" }, + ) + assertEquals(listOf("free", "finish"), steps) + } + + @Test + fun aFailedFreeStillReleasesTheClaim() { + val steps = ArrayList() + endTxoReconcileRun( + walletRef = "ref", + freeHeldVerdicts = { + steps += "free" + throw IllegalStateException("native free failed") + }, + finish = { steps += "finish" }, + ) + assertEquals("finish runs after a failed free", listOf("free", "finish"), steps) + } + + @Test + fun aFailedFinishIsLoggedNotThrown() { + var freed = 0 + endTxoReconcileRun( + walletRef = "ref", + freeHeldVerdicts = { freed++ }, + finish = { throw IllegalStateException("handle gone") }, + ) + assertEquals(1, freed) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt new file mode 100644 index 00000000000..60728bec505 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt @@ -0,0 +1,43 @@ +package org.dashfoundation.dashsdk.wallet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Kotlin side of the TXO-store reconcile's start gate. The gates, the + * schedule and their thresholds live in + * `platform_wallet::manager::txo_reconcile` and are tested there; what + * Kotlin owns is reading the gate's answer. + */ +class TxoReconcileTest { + + @Test + fun everyNativeSkipCodeMapsToItsReason() { + // rs-platform-wallet-ffi `TXO_RECONCILE_SKIP_*`, in code order. + val expected = mapOf( + 1 to TxoReconcileSkipReason.WALLET_UNKNOWN, + 2 to TxoReconcileSkipReason.ALREADY_RUNNING, + 3 to TxoReconcileSkipReason.NOT_STEADY_STATE, + 4 to TxoReconcileSkipReason.TIP_UNAVAILABLE, + 5 to TxoReconcileSkipReason.SYNC_FAULT_DETECTED, + 6 to TxoReconcileSkipReason.WALLET_BEHIND_TIP, + ) + for ((code, reason) in expected) { + assertEquals(reason, TxoReconcileSkipReason.fromGateCode(code)) + } + assertEquals( + "only the manager's own closed check has no native code", + listOf(TxoReconcileSkipReason.MANAGER_CLOSED), + TxoReconcileSkipReason.entries.filter { it.gateCode == null }, + ) + } + + @Test + fun aCodeThisBuildDoesNotKnowIsAContractBreakNotASkip() { + for (code in listOf(0, 7, -1)) { + val thrown = runCatching { TxoReconcileSkipReason.fromGateCode(code) }.exceptionOrNull() + assertTrue("code $code must not map to a reason, got $thrown", thrown is IllegalStateException) + } + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json b/packages/kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json new file mode 100644 index 00000000000..5cb3dc52918 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json @@ -0,0 +1,127 @@ +{ + "_about": "Golden TXO-reconcile transport payloads, read by rs-unified-sdk-jni (wallet_manager::tests) and the Kotlin PlatformWalletPersistenceHandlerTest. inventoryPage is exactly what walletManagerTxoHealPageJson emits for the four heal-page rows the Rust test builds; outpointQueries is exactly what the Kotlin reconcile sends walletManagerTxoReconcileClassifyOutpoints for the same four coins. Change both sides together.", + "inventoryPage": { + "utxos": [ + { + "typeTag": 0, + "standardTag": 0, + "index": 0, + "registrationIndex": 0, + "keyClass": 0, + "txid": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vout": 0, + "amount": 19549, + "address": "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + "scriptHex": "76a9145b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b88ac", + "height": 1534921, + "isConfirmed": true, + "isInstantlocked": false, + "isCoinbase": false, + "isLocked": false, + "healVerdict": 2 + }, + { + "typeTag": 0, + "standardTag": 1, + "index": 2, + "registrationIndex": 0, + "keyClass": 0, + "txid": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "vout": 7, + "amount": 5000000000, + "address": "yGoldenFixtureBip32Addr", + "scriptHex": "76a914c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c388ac", + "height": 1400000, + "isConfirmed": true, + "isInstantlocked": true, + "isCoinbase": true, + "isLocked": true, + "healVerdict": 2 + }, + { + "typeTag": 3, + "standardTag": 0, + "index": 0, + "registrationIndex": 4, + "keyClass": 0, + "txid": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "vout": 1, + "amount": 100000, + "address": "", + "scriptHex": "", + "height": 0, + "isConfirmed": false, + "isInstantlocked": false, + "isCoinbase": false, + "isLocked": false, + "healVerdict": 0 + }, + { + "typeTag": 12, + "standardTag": 0, + "index": 1, + "registrationIndex": 0, + "keyClass": 0, + "userIdentityId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "friendIdentityId": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "txid": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "vout": 3, + "amount": 250000, + "address": "yGoldenFixtureDashPayAddr", + "scriptHex": "76a914d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d488ac", + "height": 1534000, + "isConfirmed": true, + "isInstantlocked": true, + "isCoinbase": false, + "isLocked": false, + "healVerdict": 1 + } + ], + "cursor": "{\"typeTag\":12,\"standardTag\":0,\"index\":1,\"registrationIndex\":0,\"keyClass\":0,\"userIdentityId\":\"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"friendIdentityId\":\"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2\",\"txid\":\"606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f\",\"vout\":3}", + "hasMore": true + }, + "outpointQueries": [ + { + "typeTag": 0, + "standardTag": 0, + "index": 0, + "registrationIndex": 0, + "keyClass": 0, + "txid": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "vout": 0, + "scriptHex": "76a9145b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b88ac" + }, + { + "typeTag": 0, + "standardTag": 1, + "index": 2, + "registrationIndex": 0, + "keyClass": 0, + "txid": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "vout": 7, + "scriptHex": "76a914c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c388ac" + }, + { + "typeTag": 3, + "standardTag": 0, + "index": 0, + "registrationIndex": 4, + "keyClass": 0, + "txid": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", + "vout": 1, + "scriptHex": "" + }, + { + "typeTag": 12, + "standardTag": 0, + "index": 1, + "registrationIndex": 0, + "keyClass": 0, + "userIdentityId": "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", + "friendIdentityId": "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", + "txid": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "vout": 3, + "scriptHex": "76a914d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d488ac" + } + ] +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 609f72d136a..b0e7391f852 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -89,6 +89,7 @@ pub(crate) mod test_fixtures; pub mod token_persistence; pub mod tokens; pub mod tracked_masternode; +pub mod txo_reconcile; pub mod types; pub mod utils; pub mod wallet; @@ -166,6 +167,7 @@ pub use sign_with_mnemonic_resolver::*; pub use spv::*; pub use token_persistence::*; pub use tokens::*; +pub use txo_reconcile::*; pub use types::*; pub use utils::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..d60b06e2ed3 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1581,6 +1581,10 @@ mod tests { fn classify_outpoints_answers_unknown_for_an_unmappable_account_tag() { use crate::core_wallet_types::{OutPointFFI, OutpointOwnershipQueryFFI}; use crate::manager_diagnostics::platform_wallet_classify_outpoints; + use crate::txo_reconcile::{ + platform_wallet_txo_reconcile_classify_outpoints, + platform_wallet_txo_reconcile_held_verdicts_free, TxoReconcileHeldVerdictsFFI, + }; let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_cbs = event_callbacks(); @@ -1634,6 +1638,48 @@ mod tests { [0, 0, 0], "every slot is answered, Unknown where nothing can be said" ); + // The reconcile's classifier shares the marshalling; it answers only + // a wallet that holds a run claim, and holds nothing otherwise. + let mut out = [0xFFu8; 3]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + queries.as_ptr(), + queries.len(), + out.as_mut_ptr(), + &mut held, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + assert_eq!(out, [0, 0, 0]); + assert!(held.is_null()); + + // With a run claim, every slot is answered and the guard comes back. + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| m.txo_reconciler()) + .expect("manager"); + assert!(reconciler.claim_without_gate(&wallet_id)); + let mut out = [0xFFu8; 3]; + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + queries.as_ptr(), + queries.len(), + out.as_mut_ptr(), + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, [0, 0, 0]); + assert!(!held.is_null()); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + reconciler.finish(&wallet_id, false); let destroyed = unsafe { platform_wallet_manager_destroy(handle) }; assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success); } diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 5630ad287be..4983206cfff 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -17,19 +17,21 @@ use std::ffi::CString; use std::os::raw::c_char; use platform_wallet::manager::accessors::{ - AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, - AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, - CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, + classify_outpoints, AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, + AccountMetadataSnapshot, AccountTransactionSnapshot, AccountUtxoSnapshot, + AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, + IdentityWalletStateSnapshot, OutpointClass, OutpointOwnershipQuery, 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, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutpointOwnershipQueryFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, + WalletIdentityRowFFI, WalletUtxoCursorFFI, WalletUtxoEntryFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -982,6 +984,78 @@ fn account_spec_from_raw_tags( } } +/// The resume point a cursor names: its raw account tags validated into an +/// `AccountType`, and its outpoint. The error is the tag validator's. +pub(crate) fn wallet_utxo_cursor_from_ffi( + cursor: &WalletUtxoCursorFFI, +) -> Result { + let spec = account_spec_from_raw_tags( + cursor.type_tag, + cursor.standard_tag, + cursor.index, + cursor.registration_index, + cursor.key_class, + cursor.user_identity_id, + cursor.friend_identity_id, + ); + let account_type = account_type_from_spec_ref(&spec)?; + Ok((account_type, dashcore::OutPoint::from(&cursor.outpoint))) +} + +/// The C row for one inventory row. Its `address` and `script_pubkey` +/// allocations move to the caller and are released by +/// [`release_wallet_utxo_entry`]. +pub(crate) fn wallet_utxo_entry_ffi(row: WalletUtxoRow) -> WalletUtxoEntryFFI { + use crate::core_wallet_types::{account_type_to_tags, OutPointFFI}; + + let tags = account_type_to_tags(&row.account_type); + let script_len = row.script_pubkey.len(); + let script_ptr = if script_len == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(row.script_pubkey.into_boxed_slice()) as *mut u8 + }; + let address = CString::new(row.address) + .unwrap_or_else(|_| CString::new("").expect("empty string has no NUL")) + .into_raw(); + 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: OutPointFFI::from(&row.outpoint), + value_duffs: row.value_duffs, + address, + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height: row.height, + is_confirmed: row.is_confirmed, + is_instantlocked: row.is_instantlocked, + is_coinbase: row.is_coinbase, + is_locked: row.is_locked, + } +} + +/// Release the allocations one [`wallet_utxo_entry_ffi`] row owns (not the +/// row itself). +/// +/// # Safety +/// `entry` must come from [`wallet_utxo_entry_ffi`], released once. +pub(crate) unsafe fn release_wallet_utxo_entry(entry: &WalletUtxoEntryFFI) { + if !entry.address.is_null() { + let _ = CString::from_raw(entry.address); + } + 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, + )); + } +} + /// One page of a wallet's UTXO inventory across every funds account, in /// `(account, outpoint)` order, starting strictly after `cursor` (null = /// from the beginning). `limit` is clamped natively (0 = default page, @@ -1006,7 +1080,6 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( out_count: *mut usize, out_has_more: *mut bool, ) -> PlatformWalletFFIResult { - use crate::core_wallet_types::{account_type_to_tags, WalletUtxoEntryFFI}; use platform_wallet::manager::accessors::{wallet_utxos_page, WalletUtxoCursor}; check_ptr!(wallet_id); @@ -1021,26 +1094,15 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( let after: Option = if cursor.is_null() { None } else { - let c = &*cursor; - let spec = account_spec_from_raw_tags( - c.type_tag, - c.standard_tag, - c.index, - c.registration_index, - c.key_class, - c.user_identity_id, - c.friend_identity_id, - ); - let account_type = match account_type_from_spec_ref(&spec) { - Ok(at) => at, + match wallet_utxo_cursor_from_ffi(&*cursor) { + Ok(after) => Some(after), Err(e) => { return PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, format!("cursor: {e}"), ); } - }; - Some((account_type, dashcore::OutPoint::from(&c.outpoint))) + } }; let Some(wallet_manager) = @@ -1060,40 +1122,7 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( return PlatformWalletFFIResult::ok(); } - let entries: Vec = rows - .into_iter() - .map(|row| { - let tags = account_type_to_tags(&row.account_type); - let script_len = row.script_pubkey.len(); - let script_ptr = if script_len == 0 { - std::ptr::null_mut() - } else { - Box::into_raw(row.script_pubkey.into_boxed_slice()) as *mut u8 - }; - let address = CString::new(row.address) - .unwrap_or_else(|_| CString::new("").expect("empty string has no NUL")) - .into_raw(); - 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: crate::core_wallet_types::OutPointFFI::from(&row.outpoint), - value_duffs: row.value_duffs, - address, - script_pubkey: script_ptr, - script_pubkey_len: script_len, - height: row.height, - is_confirmed: row.is_confirmed, - is_instantlocked: row.is_instantlocked, - is_coinbase: row.is_coinbase, - is_locked: row.is_locked, - } - }) - .collect(); + let entries: Vec = rows.into_iter().map(wallet_utxo_entry_ffi).collect(); let count = entries.len(); *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; *out_count = count; @@ -1112,17 +1141,8 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( if rows.is_null() || count == 0 { return; } - let slice = std::slice::from_raw_parts(rows, count); - for entry in slice { - if !entry.address.is_null() { - let _ = CString::from_raw(entry.address); - } - 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, - )); - } + for entry in std::slice::from_raw_parts(rows, count) { + release_wallet_utxo_entry(entry); } let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); } @@ -1144,12 +1164,45 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( pub unsafe extern "C" fn platform_wallet_classify_outpoints( manager_handle: Handle, wallet_id: *const u8, - queries: *const crate::core_wallet_types::OutpointOwnershipQueryFFI, + queries: *const OutpointOwnershipQueryFFI, count: usize, out_classes: *mut u8, ) -> PlatformWalletFFIResult { - use platform_wallet::manager::accessors::{classify_outpoints, OutpointOwnershipQuery}; + classify_outpoint_rows(wallet_id, queries, count, out_classes, |wid, owned| { + let wallet_manager = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.wallet_manager_arc()) + .ok_or_else(invalid_manager_handle)?; + let wm = wallet_manager.blocking_read(); + Ok(classify_outpoints(&wm, &wid, &owned)) + }) +} + +/// The error for a manager handle that is not live. +pub(crate) fn invalid_manager_handle() -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ) +} +/// The marshalling `platform_wallet_classify_outpoints` and +/// `platform_wallet_txo_reconcile_classify_outpoints` share: read `count` +/// store rows, hand the ones this build can map to `classify`, which makes +/// the one engine call, and write its answers back to their positions in +/// `out_classes`. An `Err` from `classify` is returned as is. +/// +/// # Safety +/// As `platform_wallet_classify_outpoints`. +pub(crate) unsafe fn classify_outpoint_rows( + wallet_id: *const u8, + queries: *const OutpointOwnershipQueryFFI, + count: usize, + out_classes: *mut u8, + classify: impl FnOnce( + [u8; 32], + Vec, + ) -> Result, PlatformWalletFFIResult>, +) -> PlatformWalletFFIResult { check_ptr!(wallet_id); if count == 0 { return PlatformWalletFFIResult::ok(); @@ -1165,7 +1218,7 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( // behind it is durable, so a batch failure would repeat on every run, // and the caller already leaves `Unknown` alone. let out = std::slice::from_raw_parts_mut(out_classes, count); - out.fill(platform_wallet::manager::accessors::OutpointClass::Unknown.as_u8()); + out.fill(OutpointClass::Unknown.as_u8()); let mut owned: Vec = Vec::with_capacity(count); let mut positions: Vec = Vec::with_capacity(count); for (i, q) in std::slice::from_raw_parts(queries, count) @@ -1198,17 +1251,9 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( }); } - let Some(wallet_manager) = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) - else { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidHandle, - "Manager handle invalid".to_string(), - ); - }; - let classes = { - let wm = wallet_manager.blocking_read(); - classify_outpoints(&wm, &wid, &owned) + let classes = match classify(wid, owned) { + Ok(classes) => classes, + Err(error) => return error, }; for (position, class) in positions.into_iter().zip(classes) { out[position] = class.as_u8(); diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs new file mode 100644 index 00000000000..814ba3d494e --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -0,0 +1,642 @@ +//! C ABI over `platform_wallet::manager::txo_reconcile`: the schedule, +//! start gate, heal verdicts, spend verdicts and stale-page retry of the +//! host TXO-store reconcile (dashpay/platform#4638). Each export forwards +//! to one Rust operation; the decisions, thresholds, cadence and the lock +//! that keeps a spend verdict true until it is written live in +//! `platform-wallet`. + +use platform_wallet::manager::accessors::WalletUtxoCursor; +use platform_wallet::manager::txo_reconcile::{ + txo_heal_page, txo_reconcile_retry_stale_page, TxoReconcileHeldVerdicts, +}; + +use crate::check_ptr; +use crate::core_wallet_types::{ + OutpointOwnershipQueryFFI, WalletUtxoCursorFFI, WalletUtxoEntryFFI, +}; +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; +use crate::manager_diagnostics::{ + classify_outpoint_rows, invalid_manager_handle, release_wallet_utxo_entry, + wallet_utxo_cursor_from_ffi, wallet_utxo_entry_ffi, +}; +use crate::runtime::block_on_worker; +use crate::types::IdentifierArray; +use crate::unwrap_option_or_return; + +/// `platform_wallet_manager_txo_reconcile_begin`: the run may start. +pub const TXO_RECONCILE_GATE_RUN: u8 = 0; +/// `platform_wallet_manager_txo_reconcile_begin`: the manager does not hold +/// the wallet. +pub const TXO_RECONCILE_SKIP_WALLET_UNKNOWN: u8 = 1; +/// `platform_wallet_manager_txo_reconcile_begin`: a run for this wallet is +/// already in flight. +pub const TXO_RECONCILE_SKIP_ALREADY_RUNNING: u8 = 2; +/// `platform_wallet_manager_txo_reconcile_begin`: SPV is not running, or +/// not in its steady state. +pub const TXO_RECONCILE_SKIP_NOT_STEADY_STATE: u8 = 3; +/// `platform_wallet_manager_txo_reconcile_begin`: the progress carries no +/// scan tip. +pub const TXO_RECONCILE_SKIP_TIP_UNAVAILABLE: u8 = 4; +/// `platform_wallet_manager_txo_reconcile_begin`: the manager latched a +/// sync fault; rows are missing by design and a rescan is pending. +pub const TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED: u8 = 5; +/// `platform_wallet_manager_txo_reconcile_begin`: the wallet's scan +/// watermark trails the scan tip; it is still being scanned. +pub const TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP: u8 = 6; + +/// `TxoHealRowFFI::verdict`: no script or no address; the store could not +/// file the row. +pub const TXO_HEAL_VERDICT_INVALID: u8 = 0; +/// `TxoHealRowFFI::verdict`: not confirmed by the engine, or not deep +/// enough yet; it ages into a later run. +pub const TXO_HEAL_VERDICT_IMMATURE: u8 = 1; +/// `TxoHealRowFFI::verdict`: may be inserted unspent, if the store lacks +/// the outpoint and resolves the account that owns it. +pub const TXO_HEAL_VERDICT_ELIGIBLE: u8 = 2; + +/// One row of a heal page: an inventory row and whether the store may heal +/// it in. Its `utxo` allocations are released by +/// `platform_wallet_txo_reconcile_heal_page_free`. +#[repr(C)] +#[derive(Debug)] +pub struct TxoHealRowFFI { + pub utxo: WalletUtxoEntryFFI, + /// One of the `TXO_HEAL_VERDICT_*` constants. + pub verdict: u8, +} + +/// The wallets whose reconcile is due now. Call on every SPV progress poll, +/// running or not: the poll is the schedule's clock, and a stopped client +/// arms the next steady-state edge. Reporting a wallet consumes nothing — +/// its cadence restarts when `platform_wallet_manager_txo_reconcile_begin` +/// claims it — so one the host skips is reported again on the next poll. +/// `*out_wallet_ids` is released by +/// `platform_wallet_identifier_array_free`. +/// +/// # Safety +/// `out_wallet_ids` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( + manager_handle: Handle, + out_wallet_ids: *mut IdentifierArray, +) -> PlatformWalletFFIResult { + check_ptr!(out_wallet_ids); + *out_wallet_ids = IdentifierArray::empty(); + // Looked up under the registry guard, waited on outside it — see + // `platform_wallet_manager_sync_progress`. + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); + let reconciler = unwrap_option_or_return!(option); + let due = block_on_worker(async move { reconciler.due_wallets().await }); + *out_wallet_ids = IdentifierArray::from_hashes(due); + PlatformWalletFFIResult::ok() +} + +/// Claim `wallet_id` for one reconcile run and check that it may start. +/// `*out_gate` is [`TXO_RECONCILE_GATE_RUN`] or one of the +/// `TXO_RECONCILE_SKIP_*` reasons; on `RUN`, `*out_tip_height` is the scan +/// tip to pass to `platform_wallet_txo_reconcile_heal_page`, and the claim +/// holds until `platform_wallet_manager_txo_reconcile_finish`. A skip holds +/// no claim. Either way the wallet's cadence restarts now, unless another +/// run already held it. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; the out-pointers are writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( + manager_handle: Handle, + wallet_id: *const u8, + out_gate: *mut u8, + out_tip_height: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_gate); + check_ptr!(out_tip_height); + *out_gate = TXO_RECONCILE_SKIP_WALLET_UNKNOWN; + *out_tip_height = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); + let reconciler = unwrap_option_or_return!(option); + match block_on_worker(async move { reconciler.begin(&wid).await }) { + Ok(tip_height) => { + *out_gate = TXO_RECONCILE_GATE_RUN; + *out_tip_height = tip_height; + } + Err(skip) => *out_gate = skip.as_u8(), + } + PlatformWalletFFIResult::ok() +} + +/// Release the claim a `RUN` from +/// `platform_wallet_manager_txo_reconcile_begin` granted. `ran` is whether +/// the run went ahead (even if it stopped early), which restarts the +/// wallet's cadence. Also releases, as a backstop, an engine guard the +/// host failed to free — but this resolves the manager handle, so a host +/// frees each page's guard first +/// (`platform_wallet_txo_reconcile_held_verdicts_free`). +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( + manager_handle: Handle, + wallet_id: *const u8, + ran: bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, ran)); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + +/// One page of the wallet's UTXO inventory — the same walk, cursor and +/// bounds as `platform_wallet_wallet_utxos_page` — each row with its +/// `TXO_HEAL_VERDICT_*` at `tip_height` (the tip +/// `platform_wallet_manager_txo_reconcile_begin` returned). Contact +/// watch-only coins are never offered. The rows are released by +/// `platform_wallet_txo_reconcile_heal_page_free`. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `cursor` is null or points at a +/// live `WalletUtxoCursorFFI`; the out-pointers are writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page( + manager_handle: Handle, + wallet_id: *const u8, + cursor: *const WalletUtxoCursorFFI, + limit: usize, + tip_height: u32, + out_rows: *mut *const TxoHealRowFFI, + out_count: *mut usize, + out_has_more: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_rows); + check_ptr!(out_count); + check_ptr!(out_has_more); + *out_rows = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + let after: Option = if cursor.is_null() { + None + } else { + match wallet_utxo_cursor_from_ffi(&*cursor) { + Ok(after) => Some(after), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("cursor: {e}"), + ); + } + } + }; + + // The wallet lock is taken outside the registry guard, as + // `platform_wallet_wallet_utxos_page` does. + let Some(wallet_manager) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let (rows, has_more) = { + let wm = wallet_manager.blocking_read(); + txo_heal_page(&wm, &wid, after.as_ref(), limit, tip_height) + }; + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|(row, verdict)| TxoHealRowFFI { + utxo: wallet_utxo_entry_ffi(row), + verdict: verdict.as_u8(), + }) + .collect(); + let count = entries.len(); + *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +/// Release a page returned by `platform_wallet_txo_reconcile_heal_page`. +/// +/// # Safety +/// `rows`/`count` must be exactly what one call returned, released once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_free( + rows: *mut TxoHealRowFFI, + count: usize, +) { + if rows.is_null() || count == 0 { + return; + } + for row in std::slice::from_raw_parts(rows, count) { + release_wallet_utxo_entry(&row.utxo); + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); +} + +/// The engine guard one page's spend verdicts are held under — see +/// `platform_wallet_txo_reconcile_classify_outpoints`. Opaque; released by +/// `platform_wallet_txo_reconcile_held_verdicts_free`. +pub struct TxoReconcileHeldVerdictsFFI { + _held: TxoReconcileHeldVerdicts, +} + +/// Classify `count` store rows of `wallet_id` for the spend pass of the +/// reconcile run it holds a claim for — `platform_wallet_classify_outpoints`'s +/// answers and marshalling — and keep the engine's wallet-manager read +/// lock held when this returns, in `*out_held`, so no fork can drop a +/// spender these answers name before the host has written them. Only +/// `OUTPOINT_CLASS_KNOWN_UNCREDITED` marks a row spent. +/// +/// Release `*out_held` with +/// `platform_wallet_txo_reconcile_held_verdicts_free` as soon as the page +/// is applied, and before the next call for this wallet or its +/// `platform_wallet_manager_txo_reconcile_finish`: those resolve the +/// manager handle, and the handle registry must never be waited for while +/// the guard is held (every other path takes the registry before the +/// engine lock). Finish and manager destroy release a guard the host +/// failed to, as a backstop. Call this outside the host's +/// persistence-callback exclusion and never from inside a persistence +/// callback: the engine calls those callbacks under its write lock. The +/// registry is read only to resolve the manager, with no guard held; the +/// wait for the new guard happens after it is released. +/// +/// Fails with `*out_held` null — nothing held — when the wallet holds no +/// run claim. `*out_held` is also null when `count` is 0. +/// +/// # Safety +/// As `platform_wallet_classify_outpoints`: `wallet_id` points at 32 +/// readable bytes; `queries` points at `count` live entries whose script +/// pointers are valid for the call; `out_classes` is writable for `count` +/// bytes. `out_held` is writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + queries: *const OutpointOwnershipQueryFFI, + count: usize, + out_classes: *mut u8, + out_held: *mut *mut TxoReconcileHeldVerdictsFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_held); + *out_held = std::ptr::null_mut(); + classify_outpoint_rows(wallet_id, queries, count, out_classes, |wid, owned| { + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler()) + .ok_or_else(invalid_manager_handle)?; + let (classes, held) = + block_on_worker(async move { reconciler.classify_held(&wid, &owned).await }) + .ok_or_else(|| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the wallet holds no reconcile run claim".to_string(), + ) + })?; + *out_held = Box::into_raw(Box::new(TxoReconcileHeldVerdictsFFI { _held: held })); + Ok(classes) + }) +} + +/// Release the engine guard `platform_wallet_txo_reconcile_classify_outpoints` +/// handed out. Touches no handle registry and never blocks, so it can run +/// whatever else is queued on the registry or the engine. Null is a no-op. +/// +/// # Safety +/// `held` is null or a pointer that call returned, released exactly once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_held_verdicts_free( + held: *mut TxoReconcileHeldVerdictsFFI, +) { + if !held.is_null() { + drop(Box::from_raw(held)); + } +} + +/// Whether a reconcile run classifies a page again after it came back +/// stale for the `stale_in_a_row`-th time in a row (counting this one), or +/// stops incomplete. +#[no_mangle] +pub extern "C" fn platform_wallet_txo_reconcile_retry_stale_page(stale_in_a_row: u32) -> bool { + txo_reconcile_retry_stale_page(stale_in_a_row) +} + +#[cfg(test)] +mod tests { + use super::*; + + use platform_wallet::manager::txo_reconcile::{TxoHealVerdict, TxoReconcileSkip}; + + use crate::core_wallet_types::{OutPointFFI, OUTPOINT_CLASS_UNKNOWN}; + + #[test] + fn the_c_codes_match_the_rust_enums() { + for (code, skip) in [ + ( + TXO_RECONCILE_SKIP_WALLET_UNKNOWN, + TxoReconcileSkip::WalletUnknown, + ), + ( + TXO_RECONCILE_SKIP_ALREADY_RUNNING, + TxoReconcileSkip::AlreadyRunning, + ), + ( + TXO_RECONCILE_SKIP_NOT_STEADY_STATE, + TxoReconcileSkip::NotSteadyState, + ), + ( + TXO_RECONCILE_SKIP_TIP_UNAVAILABLE, + TxoReconcileSkip::TipUnavailable, + ), + ( + TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED, + TxoReconcileSkip::SyncFaultDetected, + ), + ( + TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP, + TxoReconcileSkip::WalletBehindTip, + ), + ] { + assert_eq!(code, skip.as_u8(), "{skip:?}"); + assert_ne!(code, TXO_RECONCILE_GATE_RUN, "{skip:?}"); + } + for (code, verdict) in [ + (TXO_HEAL_VERDICT_INVALID, TxoHealVerdict::Invalid), + (TXO_HEAL_VERDICT_IMMATURE, TxoHealVerdict::Immature), + (TXO_HEAL_VERDICT_ELIGIBLE, TxoHealVerdict::Eligible), + ] { + assert_eq!(code, verdict.as_u8(), "{verdict:?}"); + } + } + + #[test] + fn the_stale_page_export_forwards_the_retry_limit() { + assert!(platform_wallet_txo_reconcile_retry_stale_page(5)); + assert!(!platform_wallet_txo_reconcile_retry_stale_page(6)); + } + + #[test] + fn an_invalid_handle_is_an_error_and_leaves_the_out_params_initialized() { + let wallet_id = [0u8; 32]; + let mut gate = 0xFFu8; + let mut tip = 7u32; + let result = unsafe { + platform_wallet_manager_txo_reconcile_begin( + Handle::MAX, + wallet_id.as_ptr(), + &mut gate, + &mut tip, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(gate, TXO_RECONCILE_SKIP_WALLET_UNKNOWN); + assert_eq!(tip, 0); + + let mut ids = IdentifierArray::empty(); + let result = + unsafe { platform_wallet_manager_txo_reconcile_due_wallets(Handle::MAX, &mut ids) }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(ids.items.is_null()); + assert_eq!(ids.count, 0); + + let mut rows: *const TxoHealRowFFI = std::ptr::null(); + let mut count = 0usize; + let mut has_more = true; + let result = unsafe { + platform_wallet_txo_reconcile_heal_page( + Handle::MAX, + wallet_id.as_ptr(), + std::ptr::null(), + 0, + 0, + &mut rows, + &mut count, + &mut has_more, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(rows.is_null()); + assert_eq!(count, 0); + assert!(!has_more); + + let script = [0x76u8, 0xa9, 0x14]; + let query = OutpointOwnershipQueryFFI { + type_tag: 0, + standard_tag: 0, + index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + outpoint: OutPointFFI { + txid: [0x22; 32], + vout: 0, + }, + script_pubkey: script.as_ptr(), + script_pubkey_len: script.len(), + }; + let mut classes = [0xFFu8; 1]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::dangling_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + Handle::MAX, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(classes, [OUTPOINT_CLASS_UNKNOWN]); + assert!(held.is_null(), "nothing is held on a failure"); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + } + + /// Set in the child process that runs [`held_verdicts_registry_scenario`]. + const HELD_VERDICTS_CHILD: &str = "PLATFORM_WALLET_FFI_HELD_VERDICTS_CHILD"; + const WAIT: std::time::Duration = std::time::Duration::from_secs(10); + + /// The lock cycle the per-page release must not join. A run holds one + /// page's engine guard; an engine writer (a block, a fork) queues + /// behind it; a native read that resolved the manager under the + /// registry's read guard parks on the engine behind that writer; a + /// registry writer (a manager create or destroy) queues behind that + /// reader, and parking_lot then parks every new registry reader. A + /// release that looked the manager up in the registry would wait on + /// the registry writer, it on the reader, the reader on the engine + /// writer, and that writer on the guard. Freeing the held verdicts + /// touches no registry, so the guard goes, then the writer, the reader + /// and the registry writer in turn. + /// + /// The scenario runs in a child process (this test binary, re-invoked + /// on this test alone with [`HELD_VERDICTS_CHILD`] set), as + /// `spv_start_registry_tests` does: a regression forms a real deadlock + /// that would hold the process-wide registry and wedge every other + /// registry-backed test. The parent bounds the child and kills it. + #[test] + fn freeing_held_verdicts_completes_with_a_registry_writer_queued_behind_the_engine() { + if std::env::var_os(HELD_VERDICTS_CHILD).is_some() { + held_verdicts_registry_scenario(); + return; + } + let test_name = format!( + "{}::freeing_held_verdicts_completes_with_a_registry_writer_queued_behind_the_engine", + module_path!() + .split_once("::") + .map(|(_, path)| path) + .expect("test module path below the crate root") + ); + let mut child = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", &test_name, "--nocapture"]) + .env(HELD_VERDICTS_CHILD, "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn the scenario child"); + let deadline = std::time::Instant::now() + 2 * WAIT; + let status = loop { + if let Some(status) = child.try_wait().expect("child status") { + break Some(status); + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let output = child.wait_with_output().expect("reap the scenario child"); + let transcript = format!( + "--- child stdout ---\n{}\n--- child stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + match status { + Some(status) => assert!( + status.success(), + "the scenario failed in the child process ({status})\n{transcript}" + ), + None => { + panic!("the scenario deadlocked: the child was killed\n{transcript}") + } + } + } + + fn held_verdicts_registry_scenario() { + use std::sync::mpsc; + use std::sync::Arc; + + use crate::test_fixtures::mock_manager; + + /// Poll `condition` until it holds, failing after [`WAIT`]. + fn wait_for(what: &str, condition: impl Fn() -> bool) { + let deadline = std::time::Instant::now() + WAIT; + while !condition() { + assert!(std::time::Instant::now() < deadline, "{what}"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); + let wallet_id = [0x5au8; 32]; + let (reconciler, engine) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| (m.txo_reconciler(), m.wallet_manager_arc())) + .expect("manager"); + assert!(reconciler.claim_without_gate(&wallet_id)); + + // One page's verdicts, held. + let script = [0x76u8, 0xa9, 0x14]; + let query = OutpointOwnershipQueryFFI { + type_tag: 0, + standard_tag: 0, + index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + outpoint: OutPointFFI { + txid: [0x22; 32], + vout: 0, + }, + script_pubkey: script.as_ptr(), + script_pubkey_len: script.len(), + }; + let mut classes = [0xFFu8; 1]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!held.is_null()); + + // An engine writer queued behind the guard. With the guard held, a + // fresh engine read is refused exactly while a writer waits. + let writer_engine = Arc::clone(&engine); + let writer = std::thread::spawn(move || drop(writer_engine.blocking_write())); + wait_for("the engine writer never queued behind the guard", || { + engine.try_read().is_err() + }); + + // A native read holding the registry's read guard, parked on the + // engine behind that writer. + let (reading_tx, reading_rx) = mpsc::channel::<()>(); + let reader = std::thread::spawn(move || { + PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let engine = manager.wallet_manager_arc(); + reading_tx.send(()).expect("test observes the reader"); + drop(engine.blocking_read()); + }) + }); + reading_rx + .recv_timeout(WAIT) + .expect("the reader holds the registry's read guard"); + + // A registry writer queued behind that reader. + let other = mock_manager(); + let registry_writer = + std::thread::spawn(move || PLATFORM_WALLET_MANAGER_STORAGE.insert(other)); + wait_for("the registry writer never queued behind the reader", || { + PLATFORM_WALLET_MANAGER_STORAGE.try_read_refused() + }); + + // The per-page release. + let held_address = held as usize; + let (freed_tx, freed_rx) = mpsc::channel::<()>(); + std::thread::spawn(move || { + unsafe { + platform_wallet_txo_reconcile_held_verdicts_free( + held_address as *mut TxoReconcileHeldVerdictsFFI, + ) + }; + freed_tx.send(()).expect("test observes the release"); + }); + freed_rx + .recv_timeout(WAIT) + .expect("the release completes without the registry"); + + writer.join().expect("engine writer"); + assert!(reader.join().expect("registry reader").is_some()); + let other = registry_writer.join().expect("registry writer"); + + reconciler.finish(&wallet_id, false); + drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(other)); + drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(handle)); + } +} diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index bc939526561..737a8f62c46 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1859,7 +1859,7 @@ mod computed_balance_tests { } #[cfg(test)] -mod txo_inventory_tests { +pub(crate) mod txo_inventory_tests { //! Coverage for [`wallet_utxos_page`] and [`classify_outpoints`] — the //! two engine reads a store reconcile is built on. Drives a real //! `ManagedWalletInfo` through `check_core_transaction` in the exact @@ -1883,14 +1883,14 @@ mod txo_inventory_tests { use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; - fn bip44_account_0() -> AccountType { + pub(crate) fn bip44_account_0() -> AccountType { AccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, } } - fn in_block(height: u32) -> TransactionContext { + pub(crate) fn in_block(height: u32) -> TransactionContext { TransactionContext::InBlock(BlockInfo::new( height, BlockHash::from_slice(&[6u8; 32]).expect("valid block hash"), @@ -1907,7 +1907,7 @@ mod txo_inventory_tests { } } - fn funding(script_pubkey: ScriptBuf, seed: u8, value: u64) -> Transaction { + pub(crate) fn funding(script_pubkey: ScriptBuf, seed: u8, value: u64) -> Transaction { Transaction { version: 2, lock_time: 0, @@ -1924,7 +1924,7 @@ mod txo_inventory_tests { } /// The rust-dashcore#992 shape: one input, one zero-value `OP_RETURN`. - fn collateral_burn(coin: OutPoint) -> Transaction { + pub(crate) fn collateral_burn(coin: OutPoint) -> Transaction { Transaction { version: 2, lock_time: 0, @@ -1950,7 +1950,9 @@ mod txo_inventory_tests { dashcore::Address::p2pkh(&pubkey, key_wallet::Network::Testnet).script_pubkey() } - fn manager_with(ctx: TestWalletContext) -> (WalletManager, WalletId) { + pub(crate) fn manager_with( + ctx: TestWalletContext, + ) -> (WalletManager, WalletId) { let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), @@ -1966,7 +1968,7 @@ mod txo_inventory_tests { (wm, wallet_id) } - fn query( + pub(crate) fn query( account_type: AccountType, outpoint: OutPoint, script: &ScriptBuf, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6ea8ba9b5fd..18643d602ee 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -12,6 +12,7 @@ pub mod platform_address_sync; #[cfg(feature = "shielded")] pub mod shielded_sync; pub mod startup; +pub mod txo_reconcile; mod wallet_lifecycle; pub(crate) use persistence_load::run_blocking_load; @@ -42,6 +43,7 @@ use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] use crate::manager::shielded_sync::ShieldedSyncManager; +use crate::manager::txo_reconcile::TxoReconcileSchedule; use crate::spv::SpvRuntime; use crate::wallet::asset_lock::LockNotifyHandler; use crate::wallet::core::{BalanceUpdateHandler, SpendObservationHandler}; @@ -478,6 +480,10 @@ pub struct PlatformWalletManager { pub(super) in_broadcast_fences: std::sync::Mutex< std::collections::BTreeMap>, >, + /// The host TXO-store reconcile's steady-state edge, runs in flight and + /// per-wallet cadence — see [`txo_reconcile`]. A `std::sync::Mutex`, + /// never held across an await. + pub(super) txo_reconcile_schedule: Arc>, } /// Keep `record`'s coverage only for the DashPay receival accounts `core` @@ -658,6 +664,7 @@ impl PlatformWalletManager

{ sync_fault, durable_cursors, in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), + txo_reconcile_schedule: Arc::default(), } } @@ -1133,6 +1140,10 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { + // A reconcile's engine guard is held across one host write and + // released by the host; one it failed to release must not hold the + // writers below off. + self.release_txo_reconcile_holds(); // SPV first: it is the event source feeding everything below. Its // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs new file mode 100644 index 00000000000..950a8702b63 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -0,0 +1,1465 @@ +//! Policy of the host TXO-store reconcile (dashpay/platform#4638). +//! +//! A host keeps a write-behind mirror of the wallet's coins and restores +//! the engine from it at every launch, so a coin the mirror lost, or a row +//! it holds unspent for a coin the engine proves spent, outlives a full +//! scan. The repair walks the host's own store, but every decision in it +//! lives here, so each host asks the same questions and gets the same +//! answers: +//! +//! - **when to run** — [`TxoReconciler::due_wallets`] on every SPV progress +//! poll: each wallet on the rising edge into steady state, then again on +//! [`TXO_RECONCILE_CADENCE`] while it lasts; +//! - **whether a run may start** — [`TxoReconciler::begin`]: one run per +//! wallet at a time, SPV in steady state with a scan tip, no latched sync +//! fault, and the wallet's own scan watermark within +//! [`TXO_RECONCILE_TIP_MARGIN`] blocks of the tip; +//! - **which engine coins may be healed into the store** — [`txo_heal_page`] +//! tags each inventory row with a [`TxoHealVerdict`]; +//! - **which store rows to mark spent** — [`TxoReconciler::classify_held`]: +//! [`classify_outpoints`]'s verdicts, with the engine held still until +//! the host has written them; +//! - **how long to retry a page a persistence round staled** — +//! [`txo_reconcile_retry_stale_page`]. +//! +//! The host keeps only what needs its store: paging its rows, resolving the +//! account that owns each, its transactions, and the fences that keep its +//! persistence callbacks and teardown out of a run's writes. +//! +//! # Lock order +//! +//! A run touches, in this order and never the other way round: +//! +//! 1. a host's handle registry (the C API's manager registry, a fair +//! `parking_lot` lock), read side only and only transiently, to resolve +//! the manager for one call — never while a run holds an engine guard; +//! 2. the wallet manager's `RwLock`, read side only, taken by +//! [`TxoReconciler::classify_held`] and kept, in a +//! [`TxoReconcileHeldVerdicts`], until the host's apply has committed; +//! 3. the host's callback exclusion (Kotlin's `callbackExclusion`), taken +//! for the apply; +//! 4. the host's store transaction (one Room transaction for one page). +//! +//! The engine calls the persistence callbacks, which take the callback +//! exclusion, while it holds the wallet manager's write lock — the same +//! order, 2 before 3. So a host must take the guard before the exclusion +//! (never wait for the engine while holding the exclusion), must never +//! classify from inside a persistence callback, and must release the guard +//! after the exclusion. Every other C API path also takes the registry +//! before the engine lock (a blocking read inside a registry read, or a +//! manager create or destroy that writes the registry), so **no path that +//! holds the engine guard ever waits for the registry**: dropping a +//! [`TxoReconcileHeldVerdicts`] touches only the guard's own slot, and the +//! host drops a page's guard before it resolves the manager again for the +//! next page or for [`TxoReconciler::finish`]. A classification resolves +//! the manager with no guard held, then waits for its new guard outside +//! the registry. The schedule mutex and a guard's slot mutex are leaves, +//! taken in that order when both are: each is held only for a few map or +//! slot operations, never across an await or another lock. SPV progress is +//! read only by [`TxoReconciler::due_wallets`] and [`TxoReconciler::begin`], +//! before a run holds anything. +//! +//! tokio's `RwLock` is fair: a writer waiting behind the held guard (a fork +//! in `truncate_above`, a block being applied) queues every later reader +//! behind it, and `parking_lot`'s registry lock likewise parks new readers +//! behind a queued writer. That cannot close a cycle here. The writer holds +//! nothing the guard holder waits for — the guard holder waits only for the +//! callback exclusion and the store, and whatever holds the exclusion is +//! either a callback that already holds its engine lock (a read lock taken +//! before the writer queued; a write lock cannot be held beside the guard) +//! or the host's own code, which makes no engine call under it. A registry +//! reader parked on the engine behind that writer, and a registry writer +//! queued behind that reader, wait on the guard, never the other way +//! round. The one reader that could queue behind such a writer while +//! holding a guard is the same run asking for its next page, which is why +//! a host releases each page's guard first and +//! [`TxoReconciler::classify_held`] drops one it failed to release before +//! it waits for a new one. + +use std::collections::BTreeMap; +use std::fmt; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use key_wallet_manager::WalletManager; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; + +use crate::changeset::PlatformWalletPersistence; +use crate::manager::accessors::{ + classify_outpoints, wallet_utxos_page, OutpointClass, OutpointOwnershipQuery, WalletUtxoCursor, + WalletUtxoRow, +}; +use crate::manager::PlatformWalletManager; +use crate::spv::{ProgressPercentage, SpvRuntime, SyncProgress, SyncState}; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::wallet::PlatformWallet; + +/// Confirmations an engine coin needs before a host may heal it into its +/// store: coinbase maturity, and well past any plausible reorg. +pub const TXO_RECONCILE_MIN_CONFIRMATIONS: u32 = 100; + +/// How far a wallet's scan watermark may trail the scan tip for the scan to +/// count as complete for that wallet. +pub const TXO_RECONCILE_TIP_MARGIN: u32 = 6; + +/// How often each wallet is reconciled again while SPV stays in steady +/// state. +pub const TXO_RECONCILE_CADENCE: Duration = Duration::from_secs(30 * 60); + +/// Times in a row one page may be classified again because a persistence +/// round committed between its read and its apply, or was still open at +/// it, before the run gives up. +pub const TXO_RECONCILE_MAX_STALE_RETRIES: u32 = 5; + +/// One SPV phase's heights, as dash-spv reports them for progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PhaseHeights { + pub current: u32, + pub target: u32, +} + +/// The part of an SPV progress read the reconcile schedule depends on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScanProgress { + pub state: SyncState, + /// Absent until the filter manager has started. + pub filters: Option, + /// Absent until the header manager has started. + pub headers: Option, +} + +impl ScanProgress { + pub fn from_sync_progress(progress: &SyncProgress) -> Self { + let heights = |current: u32, target: u32| PhaseHeights { current, target }; + Self { + state: progress.state(), + filters: progress + .filters() + .ok() + .map(|f| heights(f.current_height(), f.target_height())), + headers: progress + .headers() + .ok() + .map(|h| heights(h.current_height(), h.target_height())), + } + } + + /// Whether the scan is finished. dash-spv's steady state for a fully + /// synced client is `WaitForEvents` with the filter phase at its target + /// height; `Synced` is the transient window before it. Either counts — + /// anything else is a scan still running, or none at all. + pub fn is_steady(&self) -> bool { + match self.state { + SyncState::Synced => true, + SyncState::WaitForEvents => self + .filters + .is_some_and(|f| f.target > 0 && f.current >= f.target), + SyncState::WaitingForConnections | SyncState::Syncing | SyncState::Error => false, + } + } + + /// The height the scan reached: the filter phase's (the wallet-relevant + /// one), falling back to the header tip when the filter phase has no + /// height; `None` when neither has one. + pub fn scan_tip(&self) -> Option { + let positive = |phase: Option| phase.map(|p| p.current).filter(|h| *h > 0); + positive(self.filters).or_else(|| positive(self.headers)) + } +} + +/// Why [`TxoReconciler::begin`] refused a run. Nothing was read or written. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxoReconcileSkip { + /// The manager does not hold the wallet. + WalletUnknown = 1, + /// A run for this wallet is already in flight. + AlreadyRunning = 2, + /// SPV is not running, or not in its steady state + /// ([`ScanProgress::is_steady`]). + NotSteadyState = 3, + /// The progress carries no scan tip. + TipUnavailable = 4, + /// A persistence round was rejected: rows are missing by design and a + /// rescan is pending ([`PlatformWalletManager::sync_fault_detected`]). + SyncFaultDetected = 5, + /// The wallet's scan watermark trails the scan tip by more than + /// [`TXO_RECONCILE_TIP_MARGIN`] blocks: it is still being scanned. + WalletBehindTip = 6, +} + +impl TxoReconcileSkip { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// The SPV half of the start gate: steady state, a scan tip, and no latched +/// sync fault, in that order. Returns the scan tip a run is measured +/// against. +pub fn txo_reconcile_scan_gate( + progress: Option, + sync_fault: bool, +) -> Result { + let progress = progress + .filter(ScanProgress::is_steady) + .ok_or(TxoReconcileSkip::NotSteadyState)?; + let tip = progress + .scan_tip() + .ok_or(TxoReconcileSkip::TipUnavailable)?; + if sync_fault { + return Err(TxoReconcileSkip::SyncFaultDetected); + } + Ok(tip) +} + +/// The wallet half of the start gate, against the wallet's scan watermark +/// as the engine holds it (`None` when the engine does not hold the +/// wallet). The tip says how far the CLIENT got; a wallet added behind it +/// is still being scanned. The engine's watermark, not a host's persisted +/// copy: that trails the engine by up to one persistence round, and the +/// transition into steady state lands inside that window. +pub fn txo_reconcile_wallet_gate( + wallet_synced_height: Option, + tip_height: u32, +) -> Result<(), TxoReconcileSkip> { + let synced = wallet_synced_height.ok_or(TxoReconcileSkip::WalletUnknown)?; + if synced.saturating_add(TXO_RECONCILE_TIP_MARGIN) >= tip_height { + Ok(()) + } else { + Err(TxoReconcileSkip::WalletBehindTip) + } +} + +/// Whether a host may heal one engine coin into its store — see +/// [`txo_heal_verdict`]. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxoHealVerdict { + /// No script or no address: the store could not file the row. + Invalid = 0, + /// The engine does not call it confirmed, or it is fewer than + /// [`TXO_RECONCILE_MIN_CONFIRMATIONS`] deep at the tip. A fresh coin can + /// still reorg out (or, for coinbase, be immature); it ages into a later + /// run. + Immature = 1, + /// May be inserted unspent, if the store lacks the outpoint and can + /// resolve the account that owns it. + Eligible = 2, +} + +impl TxoHealVerdict { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// The verdict on one inventory row at `tip_height`. The watch-only contact +/// exclusion is not repeated here: [`wallet_utxos_page`] never offers a +/// contact's coin. +pub fn txo_heal_verdict(row: &WalletUtxoRow, tip_height: u32) -> TxoHealVerdict { + if row.script_pubkey.is_empty() || row.address.is_empty() { + return TxoHealVerdict::Invalid; + } + let deep_enough = row.height > 0 + && tip_height >= row.height + && tip_height - row.height + 1 >= TXO_RECONCILE_MIN_CONFIRMATIONS; + if row.is_confirmed && deep_enough { + TxoHealVerdict::Eligible + } else { + TxoHealVerdict::Immature + } +} + +/// One page of `wallet_id`'s inventory ([`wallet_utxos_page`], same order, +/// cursor and bounds), each row with its [`TxoHealVerdict`] at +/// `tip_height` — the tip [`TxoReconciler::begin`] returned for the run. +pub fn txo_heal_page( + wm: &WalletManager, + wallet_id: &WalletId, + after: Option<&WalletUtxoCursor>, + limit: usize, + tip_height: u32, +) -> (Vec<(WalletUtxoRow, TxoHealVerdict)>, bool) { + let (rows, has_more) = wallet_utxos_page(wm, wallet_id, after, limit); + let rows = rows + .into_iter() + .map(|row| { + let verdict = txo_heal_verdict(&row, tip_height); + (row, verdict) + }) + .collect(); + (rows, has_more) +} + +/// Whether a run classifies a page again after it came back stale for the +/// `stale_in_a_row`-th time in a row (counting this one), or stops +/// incomplete. A round committing between a page's read and its apply may +/// have re-credited one of its coins, so nothing is written and the page is +/// read again; a store under constant write load must not hold the run +/// forever. +pub fn txo_reconcile_retry_stale_page(stale_in_a_row: u32) -> bool { + stale_in_a_row <= TXO_RECONCILE_MAX_STALE_RETRIES +} + +/// The scheduling state behind [`TxoReconciler`]: the steady-state edge, +/// the runs in flight and each wallet's last run. +#[derive(Debug, Default)] +pub struct TxoReconcileSchedule { + /// The SPV run session steady state was last seen in; `None` while the + /// client is not steady. A new session is a new steady state even when + /// no non-steady read was seen in between (a stop and restart quicker + /// than the host's poll). + steady_session: Option, + in_flight: BTreeMap, + last_run: BTreeMap, + /// The id the next claim gets. + next_claim: u64, +} + +/// The read side of the wallet manager's lock, owned so it can outlive the +/// call that took it — see [`TxoReconciler::classify_held`]. +type EngineReadGuard = OwnedRwLockReadGuard>; + +/// Where one page's engine guard lives: shared by the run's claim, which +/// empties it on [`TxoReconcileSchedule::finish`] and at shutdown, and the +/// host's [`TxoReconcileHeldVerdicts`], which empties it on drop. Whichever +/// comes first releases the guard; the other finds the slot empty. +type HeldSlot = Arc>>; + +/// Empty `slot`, releasing the guard outside its mutex. +fn release_slot(slot: &HeldSlot) { + let guard = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(guard); +} + +/// The engine guard one page's verdicts were answered under +/// ([`TxoReconciler::classify_held`]). Dropping it releases the guard; that +/// touches nothing but the guard's own slot — no schedule lookup, no +/// manager, no host registry — so the host can release it whatever else is +/// queued. Drop it once the page is applied. +pub struct TxoReconcileHeldVerdicts { + slot: HeldSlot, +} + +impl fmt::Debug for TxoReconcileHeldVerdicts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TxoReconcileHeldVerdicts") + .finish_non_exhaustive() + } +} + +impl Drop for TxoReconcileHeldVerdicts { + fn drop(&mut self) { + release_slot(&self.slot); + } +} + +/// One run in flight. +#[derive(Default)] +struct ReconcileClaim { + /// Tells this claim from a later one for the same wallet, so a + /// classification that waited across a finish and a new begin does not + /// hand its guard to the new run. + id: u64, + /// The wallet was removed while the run was in flight + /// ([`TxoReconcileSchedule::forget`]). The run still holds the wallet + /// until it finishes, but its finish records nothing: the cadence it + /// would restart belongs to a wallet that no longer exists. + forgotten: bool, + /// The slot of the guard the run's last classification is held under + /// until its host has applied the verdicts. Emptied by + /// [`TxoReconcileSchedule::finish`], so a run never outlives its guard. + held: Option, +} + +impl fmt::Debug for ReconcileClaim { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReconcileClaim") + .field("id", &self.id) + .field("forgotten", &self.forgotten) + .field("holds_engine", &self.held.is_some()) + .finish() + } +} + +impl TxoReconcileSchedule { + /// Feed one progress observation (every poll, not only a changed one: + /// this is the schedule's only clock, and a quiet steady-state client's + /// progress does not change for the whole cadence). Returns the wallets + /// to run now — all of `wallets` on the rising edge into steady state, + /// and afterwards each wallet whose last run is at least + /// [`TXO_RECONCILE_CADENCE`] old or that never ran — never one already + /// in flight. Reporting a wallet consumes nothing: its cadence restarts + /// only when a run claims it ([`Self::try_begin`]), so a wallet the host + /// cannot run yet is reported again on the next poll. + pub fn due_wallets( + &mut self, + steady: bool, + spv_session: u64, + wallets: impl IntoIterator, + now: Instant, + ) -> Vec { + if !steady { + self.steady_session = None; + return Vec::new(); + } + let rising = self.steady_session != Some(spv_session); + self.steady_session = Some(spv_session); + wallets + .into_iter() + .filter(|wallet_id| !self.in_flight.contains_key(wallet_id)) + .filter(|wallet_id| { + rising + || self.last_run.get(wallet_id).is_none_or(|last| { + now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE + }) + }) + .collect() + } + + /// Claim the wallet for one run at `now`; `false` when a run is already + /// in flight. The claim restarts the wallet's cadence whether or not the + /// run goes ahead, so a run its gate refuses is not retried on every + /// poll. + pub fn try_begin(&mut self, wallet_id: WalletId, now: Instant) -> bool { + if self.in_flight.contains_key(&wallet_id) { + return false; + } + self.next_claim += 1; + let claim = ReconcileClaim { + id: self.next_claim, + ..ReconcileClaim::default() + }; + self.in_flight.insert(wallet_id, claim); + self.last_run.insert(wallet_id, now); + true + } + + /// Release the claim [`Self::try_begin`] took. `ran_at` is when a run + /// that went ahead finished, which restarts the wallet's cadence from + /// then; `None` for one that did not. A claim whose wallet was + /// forgotten meanwhile records nothing. An engine guard the run still + /// holds is released. + pub fn finish(&mut self, wallet_id: &WalletId, ran_at: Option) { + let Some(claim) = self.in_flight.remove(wallet_id) else { + return; + }; + if let Some(slot) = &claim.held { + release_slot(slot); + } + if let Some(at) = ran_at.filter(|_| !claim.forgotten) { + self.last_run.insert(*wallet_id, at); + } + } + + /// Forget a removed wallet's cadence, so one re-added under the same id + /// is due at once. A run still in flight keeps the wallet until it + /// finishes, but cannot bring the cadence back. + pub fn forget(&mut self, wallet_id: &WalletId) { + self.last_run.remove(wallet_id); + if let Some(claim) = self.in_flight.get_mut(wallet_id) { + claim.forgotten = true; + } + } +} + +/// Lock the schedule, recovering from poisoning: every method leaves it +/// consistent at each step, so a panicking holder cannot tear it. +fn lock_schedule(schedule: &Mutex) -> MutexGuard<'_, TxoReconcileSchedule> { + schedule.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// A claim [`TxoReconciler::begin`] took and has not handed to its caller +/// yet. Dropped while still armed — the gate refused, or the `begin` +/// future was dropped mid-await — it releases the claim as a run that did +/// not go ahead, so no caller is left owing a [`TxoReconciler::finish`] +/// it was never told about. +struct ProvisionalClaim<'a> { + schedule: &'a Mutex, + wallet_id: WalletId, + armed: bool, +} + +impl<'a> ProvisionalClaim<'a> { + /// Claim `wallet_id`; `None` when a run is already in flight. + fn take( + schedule: &'a Mutex, + wallet_id: WalletId, + now: Instant, + ) -> Option { + // Built only once the claim is taken: an armed value dropped on the + // refusal would release the claim of the run already in flight. + if !lock_schedule(schedule).try_begin(wallet_id, now) { + return None; + } + Some(Self { + schedule, + wallet_id, + armed: true, + }) + } + + /// The caller now holds the claim and releases it with + /// [`TxoReconciler::finish`]. + fn hand_over(mut self) { + self.armed = false; + } +} + +impl Drop for ProvisionalClaim<'_> { + fn drop(&mut self) { + if self.armed { + lock_schedule(self.schedule).finish(&self.wallet_id, None); + } + } +} + +/// A manager's reconcile schedule together with the state its gates read. +/// Cheap to clone; obtained from [`PlatformWalletManager::txo_reconciler`] +/// so a caller can wait on SPV without borrowing the manager. +#[derive(Clone)] +pub struct TxoReconciler { + schedule: Arc>, + spv: Arc, + wallet_manager: Arc>>, + wallets: Arc>>>, + sync_fault: Arc, +} + +impl TxoReconciler { + /// The wallets to reconcile now — see [`TxoReconcileSchedule::due_wallets`]. + /// SPV not running counts as not steady, which arms the next rising + /// edge. Call on every progress poll, running or not. + pub async fn due_wallets(&self) -> Vec { + let session = self.spv.run_session(); + let steady = self.scan_progress().await.is_some_and(|p| p.is_steady()); + let wallets: Vec = self.wallets.load().keys().copied().collect(); + lock_schedule(&self.schedule).due_wallets(steady, session, wallets, Instant::now()) + } + + /// Claim `wallet_id` for one run and check that it may start, in order: + /// no run already in flight, SPV in steady state with a scan tip, no + /// latched sync fault, and the wallet's scan watermark within + /// [`TXO_RECONCILE_TIP_MARGIN`] blocks of the tip. Returns the scan tip + /// the run's heal verdicts are measured against ([`txo_heal_page`]). + /// On `Ok` the claim holds until [`Self::finish`]; a refusal releases + /// it, and so does dropping this future before it completes. + pub async fn begin(&self, wallet_id: &WalletId) -> Result { + self.claim_across(wallet_id, self.gate(wallet_id)).await + } + + /// Claim `wallet_id`, then await `gate` holding the claim provisionally: + /// it passes to the caller only with `gate`'s `Ok`. `gate` is not + /// polled before the claim is taken. + async fn claim_across( + &self, + wallet_id: &WalletId, + gate: impl Future>, + ) -> Result { + let claim = ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) + .ok_or(TxoReconcileSkip::AlreadyRunning)?; + let tip = gate.await?; + claim.hand_over(); + Ok(tip) + } + + /// [`classify_outpoints`] for the run `wallet_id` holds a claim for + /// ([`Self::begin`]), answered under a read guard on the wallet manager + /// that is kept when this returns: it comes back as a + /// [`TxoReconcileHeldVerdicts`], which the host drops once it has + /// written the verdicts to its store. [`Self::finish`] and the manager's + /// shutdown release it too. Only [`OutpointClass::KnownUncredited`] + /// marks a row spent, and a mempool spend never earns it. + /// + /// The guard is what makes a verdict safe to write. The host's own + /// fences see its persistence rounds, not the engine, and the engine + /// can move with no round at all: dash-spv follows a fork through + /// `WalletManager::truncate_above` under the write lock, which drops the + /// spenders above the fork and restores the coins they spent, and the + /// truncation reaches no persistence round (the wider gap of + /// truncations no host persists is dashpay/platform#5380). Marking the + /// row spent after such a fork would retire a live coin for good: no + /// later run looks at a spent row, and the restore stops handing it + /// back. With the guard held no writer gets in, so the spender a + /// verdict names is still there when the host writes it, whether or not + /// a ChainLock covers it. + /// + /// The guard is held for one page's apply, so it must not wait on + /// anything slow; see the module's lock order for what a host may do + /// while holding it. A run holds one guard at a time: the host drops a + /// page's before it asks about the next, and one it failed to drop is + /// released here before this waits. + /// + /// `None` — nothing classified, nothing held — when the wallet holds no + /// run claim, or its claim was released while this waited (a new claim + /// taken meanwhile does not get this guard). + pub async fn classify_held( + &self, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> Option<(Vec, TxoReconcileHeldVerdicts)> { + let claim_id = { + let mut schedule = lock_schedule(&self.schedule); + let claim = schedule.in_flight.get_mut(wallet_id)?; + if let Some(previous) = claim.held.take() { + release_slot(&previous); + } + claim.id + }; + let engine = Arc::clone(&self.wallet_manager).read_owned().await; + let classes = classify_outpoints(&engine, wallet_id, queries); + let held = TxoReconcileHeldVerdicts { + slot: Arc::new(Mutex::new(Some(engine))), + }; + let mut schedule = lock_schedule(&self.schedule); + let claim = schedule + .in_flight + .get_mut(wallet_id) + .filter(|claim| claim.id == claim_id)?; + claim.held = Some(Arc::clone(&held.slot)); + Some((classes, held)) + } + + /// Claim `wallet_id` as a granted [`Self::begin`] would, with no gate: + /// for tests that need a run without a steady SPV client. + #[cfg(any(test, feature = "test-utils"))] + pub fn claim_without_gate(&self, wallet_id: &WalletId) -> bool { + lock_schedule(&self.schedule).try_begin(*wallet_id, Instant::now()) + } + + /// Release the claim [`Self::begin`] granted, and the engine guard it + /// may still hold. `ran` is whether the run went ahead (even if it + /// stopped early): that restarts the wallet's cadence from now. + pub fn finish(&self, wallet_id: &WalletId, ran: bool) { + lock_schedule(&self.schedule).finish(wallet_id, ran.then(Instant::now)); + } + + async fn gate(&self, wallet_id: &WalletId) -> Result { + let tip = txo_reconcile_scan_gate( + self.scan_progress().await, + self.sync_fault.load(Ordering::Relaxed), + )?; + let synced_height = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .map(|info| info.core_wallet.metadata.synced_height) + }; + txo_reconcile_wallet_gate(synced_height, tip)?; + Ok(tip) + } + + /// The client's progress, or `None` when it is not running. + async fn scan_progress(&self) -> Option { + if !self.spv.is_running() { + return None; + } + self.spv + .sync_progress() + .await + .map(|progress| ScanProgress::from_sync_progress(&progress)) + } +} + +impl PlatformWalletManager

{ + /// This manager's TXO-store reconcile schedule and gates. + pub fn txo_reconciler(&self) -> TxoReconciler { + TxoReconciler { + schedule: Arc::clone(&self.txo_reconcile_schedule), + spv: Arc::clone(&self.spv_manager), + wallet_manager: Arc::clone(&self.wallet_manager), + wallets: Arc::clone(&self.wallets), + sync_fault: Arc::clone(&self.sync_fault), + } + } + + /// Drop a removed wallet's reconcile cadence. A run still applying + /// keeps its engine guard: the removal waits for that one page. + pub(super) fn forget_txo_reconcile(&self, wallet_id: &WalletId) { + lock_schedule(&self.txo_reconcile_schedule).forget(wallet_id); + } + + /// Release every engine guard a reconcile run still holds, so a host + /// that failed to release one cannot hold teardown's writers off. + pub(super) fn release_txo_reconcile_holds(&self) { + let schedule = lock_schedule(&self.txo_reconcile_schedule); + for slot in schedule + .in_flight + .values() + .filter_map(|claim| claim.held.as_ref()) + { + release_slot(slot); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::future::pending; + use std::pin::pin; + use std::task::{Context, Waker}; + + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::test_utils::TestWalletContext; + use key_wallet_manager::WalletInterface; + use tokio::time::timeout; + + use crate::events::PlatformEventManager; + use crate::manager::accessors::txo_inventory_tests::{ + bip44_account_0, collateral_burn, funding, in_block, manager_with, query, + }; + + const TIP: u32 = 2_535_898; + + fn progress(state: SyncState, filters: Option<(u32, u32)>, headers: u32) -> ScanProgress { + ScanProgress { + state, + filters: filters.map(|(current, target)| PhaseHeights { current, target }), + headers: Some(PhaseHeights { + current: headers, + target: headers, + }), + } + } + + fn steady() -> ScanProgress { + progress(SyncState::WaitForEvents, Some((TIP, TIP)), TIP) + } + + fn syncing() -> ScanProgress { + progress(SyncState::Syncing, Some((199_999, TIP)), TIP) + } + + /// What a client that is not running reports. + fn not_running() -> ScanProgress { + ScanProgress { + state: SyncState::WaitForEvents, + filters: None, + headers: None, + } + } + + #[test] + fn the_steady_state_gate_refuses_an_unfinished_scan() { + assert!(!syncing().is_steady()); + assert!(!progress(SyncState::WaitingForConnections, None, TIP).is_steady()); + assert!(!progress(SyncState::Error, Some((TIP, TIP)), TIP).is_steady()); + assert!( + !progress(SyncState::WaitForEvents, Some((2_535_800, TIP)), TIP).is_steady(), + "waiting for events with the filter phase behind its target is a scan still running" + ); + assert!( + !progress(SyncState::WaitForEvents, None, TIP).is_steady(), + "no filter phase at all proves nothing" + ); + assert!( + !progress(SyncState::WaitForEvents, Some((0, 0)), TIP).is_steady(), + "a filter phase with no target proves nothing" + ); + assert!(!not_running().is_steady()); + assert!(progress(SyncState::Synced, Some((TIP, TIP)), TIP).is_steady()); + assert!(steady().is_steady(), "dash-spv's fully synced steady state"); + } + + #[test] + fn the_tip_is_the_filter_height_falling_back_to_the_header_tip() { + assert_eq!(steady().scan_tip(), Some(TIP)); + assert_eq!( + progress(SyncState::Synced, None, TIP).scan_tip(), + Some(TIP), + "falls back to the header tip" + ); + assert_eq!( + progress(SyncState::Synced, Some((0, 0)), 2_535_000).scan_tip(), + Some(2_535_000), + "a filter phase at height 0 falls back too" + ); + assert_eq!(not_running().scan_tip(), None); + } + + #[test] + fn a_default_progress_read_is_not_steady_and_has_no_tip() { + let read = ScanProgress::from_sync_progress(&SyncProgress::default()); + assert_eq!(read, not_running()); + } + + #[test] + fn the_scan_gate_checks_steady_state_then_the_tip_then_the_fault() { + assert_eq!(txo_reconcile_scan_gate(Some(steady()), false), Ok(TIP)); + assert_eq!( + txo_reconcile_scan_gate(None, false), + Err(TxoReconcileSkip::NotSteadyState), + "a client that is not running is not steady" + ); + assert_eq!( + txo_reconcile_scan_gate(Some(syncing()), true), + Err(TxoReconcileSkip::NotSteadyState) + ); + let synced_without_heights = ScanProgress { + state: SyncState::Synced, + filters: None, + headers: None, + }; + assert_eq!( + txo_reconcile_scan_gate(Some(synced_without_heights), true), + Err(TxoReconcileSkip::TipUnavailable) + ); + assert_eq!( + txo_reconcile_scan_gate(Some(steady()), true), + Err(TxoReconcileSkip::SyncFaultDetected), + "a rejected round means rows are missing by design" + ); + } + + #[test] + fn the_wallet_gate_uses_the_engines_watermark_within_the_tip_margin() { + // `synced_height + margin >= tip`. + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_211), 1_565_211), + Ok(()) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_205), 1_565_211), + Ok(()) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_204), 1_565_211), + Err(TxoReconcileSkip::WalletBehindTip) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(0), 1_565_211), + Err(TxoReconcileSkip::WalletBehindTip), + "a wallet added behind the tip is still being scanned" + ); + assert_eq!( + txo_reconcile_wallet_gate(None, 3), + Err(TxoReconcileSkip::WalletUnknown) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(u32::MAX), u32::MAX), + Ok(()), + "the margin saturates rather than overflowing" + ); + } + + fn row(height: u32, is_confirmed: bool) -> WalletUtxoRow { + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend([0x5b; 20]); + script.extend([0x88, 0xac]); + WalletUtxoRow { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + outpoint: OutPoint { + txid: Txid::from_byte_array([0x11; 32]), + vout: 0, + }, + value_duffs: 19_549, + script_pubkey: script, + address: "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd".to_owned(), + height, + is_confirmed, + is_instantlocked: false, + is_coinbase: false, + is_locked: false, + } + } + + #[test] + fn a_coin_is_healed_only_at_one_hundred_engine_confirmations() { + let tip = 1_000_000; + assert_eq!( + txo_heal_verdict(&row(tip - 99, true), tip), + TxoHealVerdict::Eligible, + "the tip block counts: 100 confirmations" + ); + assert_eq!( + txo_heal_verdict(&row(tip - 98, true), tip), + TxoHealVerdict::Immature, + "99 confirmations ages into a later run" + ); + assert_eq!( + txo_heal_verdict(&row(tip - 500, false), tip), + TxoHealVerdict::Immature, + "a coin the engine does not call confirmed is never healed, however deep" + ); + assert_eq!( + txo_heal_verdict(&row(0, true), tip), + TxoHealVerdict::Immature, + "no height" + ); + assert_eq!( + txo_heal_verdict(&row(tip + 1, true), tip), + TxoHealVerdict::Immature, + "above the tip" + ); + } + + #[test] + fn a_coin_the_store_could_not_file_is_invalid() { + let tip = 1_000_000; + let mut no_script = row(500_000, true); + no_script.script_pubkey.clear(); + assert_eq!(txo_heal_verdict(&no_script, tip), TxoHealVerdict::Invalid); + let mut no_address = row(500_000, true); + no_address.address.clear(); + assert_eq!(txo_heal_verdict(&no_address, tip), TxoHealVerdict::Invalid); + let mut immature_and_invalid = row(tip, false); + immature_and_invalid.address.clear(); + assert_eq!( + txo_heal_verdict(&immature_and_invalid, tip), + TxoHealVerdict::Invalid, + "validity is judged first" + ); + } + + #[test] + fn an_unknown_wallet_is_an_empty_terminal_heal_page() { + let wm = WalletManager::::new(dashcore::Network::Testnet); + let (rows, has_more) = txo_heal_page(&wm, &[0xEE; 32], None, 10, TIP); + assert!(rows.is_empty()); + assert!(!has_more); + } + + #[test] + fn a_stale_page_is_retried_five_times_in_a_row_then_the_run_stops() { + for stale_in_a_row in 1..=5 { + assert!(txo_reconcile_retry_stale_page(stale_in_a_row)); + } + assert!(!txo_reconcile_retry_stale_page(6)); + assert_eq!(TXO_RECONCILE_MAX_STALE_RETRIES, 5); + } + + const A: WalletId = [0xA1; 32]; + const B: WalletId = [0xB2; 32]; + + /// What a host does with the wallets it is told are due: claims each at + /// `at` and finishes it there. + fn run(schedule: &mut TxoReconcileSchedule, due: &[WalletId], at: Instant) { + for wallet_id in due { + assert!(schedule.try_begin(*wallet_id, at)); + schedule.finish(wallet_id, Some(at)); + } + } + + #[test] + fn every_wallet_runs_on_the_rising_edge_into_steady_state() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert!(schedule.due_wallets(false, 1, [A, B], t0).is_empty()); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A, B], t0); + assert!( + schedule + .due_wallets(true, 1, [A, B], t0 + Duration::from_secs(1)) + .is_empty(), + "the next poll inside the cadence schedules nothing" + ); + } + + #[test] + fn a_steady_client_runs_each_wallet_again_on_its_own_cadence() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let half = TXO_RECONCILE_CADENCE / 2; + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + // A wallet loaded while steady never ran: it is due at once, on its + // own clock. + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0 + half), vec![B]); + run(&mut schedule, &[B], t0 + half); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + TXO_RECONCILE_CADENCE), + vec![A] + ); + run(&mut schedule, &[A], t0 + TXO_RECONCILE_CADENCE); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + half + TXO_RECONCILE_CADENCE), + vec![B] + ); + run(&mut schedule, &[B], t0 + half + TXO_RECONCILE_CADENCE); + // A finished run restarts that wallet's cadence from its finish. + let a_ran = t0 + TXO_RECONCILE_CADENCE + half + half / 2; + assert!(schedule.try_begin(A, a_ran - Duration::from_secs(60))); + schedule.finish(&A, Some(a_ran)); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + 2 * TXO_RECONCILE_CADENCE) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], a_ran + TXO_RECONCILE_CADENCE), + vec![A, B] + ); + } + + #[test] + fn a_wallet_with_a_run_in_flight_is_never_scheduled_or_started_again() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert!(schedule.try_begin(A, t0)); + assert!( + !schedule.try_begin(A, t0), + "a second run for the same wallet is refused" + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0), + vec![B], + "not even on the rising edge" + ); + schedule.finish(&A, None); + assert!(schedule.try_begin(A, t0)); + } + + #[test] + fn a_wallet_the_host_skips_is_due_again_on_the_next_poll() { + // The host learns of a wallet the engine already holds before it + // can run it (its own wrapper is still being built), and skips it. + // Being reported spent none of its cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A], t0); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + second), + vec![B], + "the skipped wallet is reported again" + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + 2 * second), + vec![B], + "and again, until a run claims it" + ); + run(&mut schedule, &[B], t0 + 2 * second); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + 3 * second) + .is_empty()); + } + + #[test] + fn a_claim_the_gate_refuses_still_restarts_the_cadence() { + // A refused run is not retried on every poll: the claim itself + // restarts the cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + assert!(schedule.try_begin(A, t0)); + schedule.finish(&A, None); + assert!(schedule + .due_wallets(true, 1, [A], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + TXO_RECONCILE_CADENCE), + vec![A] + ); + } + + #[test] + fn leaving_steady_state_arms_the_next_rising_edge() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + assert!(schedule + .due_wallets(false, 1, [A], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + Duration::from_secs(2)), + vec![A], + "back in steady state: due again, cadence or not" + ); + } + + #[test] + fn a_restart_between_two_polls_is_a_new_steady_state() { + // SPV stops and restarts straight into steady state between two + // polls: no poll saw it stopped, but the run session moved. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A, B], t0); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 2, [A, B], t0 + Duration::from_secs(2)), + vec![A, B], + "the first steady read after the restart is a new steady state" + ); + run(&mut schedule, &[A, B], t0 + Duration::from_secs(2)); + assert!( + schedule + .due_wallets(true, 2, [A, B], t0 + Duration::from_secs(3)) + .is_empty(), + "and only the first" + ); + } + + #[test] + fn a_forgotten_wallet_is_due_at_once() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + schedule.forget(&A); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + Duration::from_secs(1)), + vec![A], + "a re-added wallet is due at once" + ); + } + + #[test] + fn a_run_finishing_after_its_wallet_was_forgotten_restores_no_cadence() { + // The wallet is removed mid-run; the host stops the run and still + // reports it as having gone ahead. The same wallet id, re-added + // while SPV stays steady, must not wait out the old run's cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + assert!(schedule.try_begin(A, t0)); + schedule.forget(&A); + assert!( + !schedule.try_begin(A, t0 + second), + "the old run holds the wallet until it exits" + ); + assert!(schedule.due_wallets(true, 1, [A], t0 + second).is_empty()); + schedule.finish(&A, Some(t0 + 2 * second)); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + 3 * second), + vec![A], + "the forgotten lifecycle's finish brought no history back" + ); + assert!(schedule.try_begin(A, t0 + 3 * second)); + schedule.finish(&A, Some(t0 + 4 * second)); + assert!( + schedule + .due_wallets(true, 1, [A], t0 + 5 * second) + .is_empty(), + "the new lifecycle's own run restarts its cadence" + ); + } + + fn reconciler() -> TxoReconciler { + reconciler_over(WalletManager::::new( + dashcore::Network::Testnet, + )) + } + + fn reconciler_over(wallet_manager: WalletManager) -> TxoReconciler { + let wallet_manager = Arc::new(RwLock::new(wallet_manager)); + let spv = Arc::new(SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(PlatformEventManager::new(Vec::new())), + )); + TxoReconciler { + schedule: Arc::default(), + spv, + wallet_manager, + wallets: Arc::new(ArcSwap::from_pointee(BTreeMap::new())), + sync_fault: Arc::new(AtomicBool::new(false)), + } + } + + #[tokio::test] + async fn a_stopped_client_schedules_nothing_and_refuses_a_run() { + let reconciler = reconciler(); + assert!(reconciler.due_wallets().await.is_empty()); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState) + ); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState), + "a refused run releases its claim" + ); + } + + #[tokio::test] + async fn a_granted_claim_holds_until_finish() { + let reconciler = reconciler(); + assert!(lock_schedule(&reconciler.schedule).try_begin(A, Instant::now())); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::AlreadyRunning) + ); + reconciler.finish(&A, true); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState) + ); + } + + #[tokio::test] + async fn a_begin_dropped_mid_gate_releases_its_claim() { + let reconciler = reconciler(); + { + let mut blocked = pin!(reconciler.claim_across(&A, pending())); + let mut cx = Context::from_waker(Waker::noop()); + assert!(blocked.as_mut().poll(&mut cx).is_pending()); + assert!( + !lock_schedule(&reconciler.schedule).try_begin(A, Instant::now()), + "the gate is awaited holding the claim" + ); + } + assert!( + lock_schedule(&reconciler.schedule).try_begin(A, Instant::now()), + "dropping the blocked begin released it" + ); + } + + #[tokio::test] + async fn only_a_gate_that_passes_hands_the_claim_over() { + let reconciler = reconciler(); + assert_eq!( + reconciler + .claim_across(&A, async { Err(TxoReconcileSkip::WalletBehindTip) }) + .await, + Err(TxoReconcileSkip::WalletBehindTip) + ); + assert_eq!( + reconciler.claim_across(&A, async { Ok(TIP) }).await, + Ok(TIP), + "the refusal released its claim" + ); + assert_eq!( + reconciler.claim_across(&A, async { Ok(TIP) }).await, + Err(TxoReconcileSkip::AlreadyRunning), + "the granted claim outlives the begin that took it" + ); + reconciler.finish(&A, true); + assert_eq!( + reconciler.claim_across(&A, async { Ok(TIP) }).await, + Ok(TIP) + ); + } + + /// The ChainLock the fork tests park on the wallet. + const CHAIN_LOCKED: u32 = 100_010; + + /// A wallet with three coins, each spent by a mined record: one block + /// below the ChainLock, at it, and one block above it. The ChainLock is + /// parked on the wallet without the promotion pass, which in a default + /// build (`keep-finalized-transactions` off) would drop the records it + /// covers. + async fn wallet_with_three_spent_coins() -> ( + WalletManager, + WalletId, + Vec, + ) { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + let mut queries = Vec::new(); + for (seed, spender_height) in [ + (31u8, CHAIN_LOCKED - 1), + (32, CHAIN_LOCKED), + (33, CHAIN_LOCKED + 1), + ] { + let funded = funding(script.clone(), seed, 7_000); + assert!( + ctx.check_transaction(&funded, in_block(100_000)) + .await + .is_relevant + ); + let coin = OutPoint { + txid: funded.txid(), + vout: 0, + }; + assert!( + ctx.check_transaction(&collateral_burn(coin), in_block(spender_height)) + .await + .is_relevant + ); + queries.push(query(bip44_account_0(), coin, &script)); + } + let (mut wm, wallet_id) = manager_with(ctx); + wm.update_wallet_last_processed_height(&wallet_id, CHAIN_LOCKED + 10); + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet") + .core_wallet + .metadata + .last_applied_chain_lock = Some(ChainLock::dummy(CHAIN_LOCKED)); + (wm, wallet_id, queries) + } + + /// A fork at the ChainLock, as dash-spv's `handle_fork` runs it: the + /// write lock, then `truncate_above`. + fn spawn_fork(reconciler: &TxoReconciler) -> tokio::task::JoinHandle<()> { + let engine = Arc::clone(&reconciler.wallet_manager); + tokio::spawn(async move { + engine + .write() + .await + .truncate_above(CHAIN_LOCKED) + .expect("a fork at the ChainLock is followed"); + }) + } + + /// How long a test waits to see that a fork has NOT gone ahead. A fork + /// that could go ahead would do so at once; one that cannot never + /// does, however long this is. + const BLOCKED: Duration = Duration::from_millis(50); + /// How long a test waits for a fork it expects to finish. + const PROCEEDS: Duration = Duration::from_secs(10); + + /// Classify under the run's claim, keeping the held verdicts. + async fn classify( + reconciler: &TxoReconciler, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> (Vec, TxoReconcileHeldVerdicts) { + reconciler + .classify_held(wallet_id, queries) + .await + .expect("the wallet holds a run claim") + } + + fn holds_engine(reconciler: &TxoReconciler, wallet_id: &WalletId) -> bool { + lock_schedule(&reconciler.schedule).in_flight[wallet_id] + .held + .as_ref() + .is_some_and(|slot| slot.lock().expect("slot").is_some()) + } + + #[tokio::test] + async fn a_classification_needs_the_runs_claim() { + let reconciler = reconciler(); + assert!(reconciler.classify_held(&A, &[]).await.is_none()); + assert!(reconciler.claim_without_gate(&A)); + let (classes, held) = classify(&reconciler, &A, &[]).await; + assert!(classes.is_empty()); + assert!(holds_engine(&reconciler, &A)); + drop(held); + assert!( + !holds_engine(&reconciler, &A), + "dropping the verdicts released the guard" + ); + reconciler.finish(&A, true); + } + + /// The race the guard closes: verdicts read, then a fork drops a + /// spender and restores its coin before the host writes them, with no + /// persistence round for the host's fences to see. The fork now waits + /// for the write. + #[tokio::test] + async fn a_fork_waits_until_the_held_verdicts_are_released() { + use OutpointClass::{KnownUncredited, Unspent}; + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id)); + + let (classes, held) = classify(&reconciler, &wallet_id, &queries).await; + assert_eq!( + classes, + vec![KnownUncredited; 3], + "a spender above the ChainLock is acted on: the guard keeps it in place" + ); + let mut fork = spawn_fork(&reconciler); + assert!( + timeout(BLOCKED, &mut fork).await.is_err(), + "the fork waits while the verdicts are held" + ); + + drop(held); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once they are released") + .expect("fork task"); + let wm = reconciler.wallet_manager.read().await; + assert_eq!( + classify_outpoints(&wm, &wallet_id, &queries), + vec![KnownUncredited, KnownUncredited, Unspent], + "the fork restored the coin whose spender sat above the ChainLock" + ); + } + + #[tokio::test] + async fn finishing_the_run_releases_its_held_verdicts() { + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id)); + let (_, held) = classify(&reconciler, &wallet_id, &queries).await; + let mut fork = spawn_fork(&reconciler); + assert!(timeout(BLOCKED, &mut fork).await.is_err()); + + reconciler.finish(&wallet_id, true); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once the run finished") + .expect("fork task"); + drop(held); + } + + /// tokio's `RwLock` queues a reader behind a waiting writer. A run that + /// asked for its next page while still holding the last one would wait + /// behind a fork that waits for it. A guard the host failed to release + /// is released first, so the fork goes ahead and the next page sees its + /// outcome; the stale verdicts' later drop is harmless. + #[tokio::test] + async fn the_next_page_does_not_deadlock_behind_a_waiting_fork() { + use OutpointClass::{KnownUncredited, Unspent}; + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id)); + let (_, stale) = classify(&reconciler, &wallet_id, &queries).await; + let mut fork = spawn_fork(&reconciler); + assert!(timeout(BLOCKED, &mut fork).await.is_err()); + + let (next, held) = timeout(PROCEEDS, classify(&reconciler, &wallet_id, &queries)) + .await + .expect("the next page is classified, not deadlocked"); + assert_eq!( + next, + vec![KnownUncredited, KnownUncredited, Unspent], + "the queued fork went first" + ); + timeout(PROCEEDS, fork) + .await + .expect("fork finished") + .expect("fork task"); + drop(stale); + assert!( + holds_engine(&reconciler, &wallet_id), + "dropping the stale verdicts leaves the new page's guard alone" + ); + drop(held); + assert!(!holds_engine(&reconciler, &wallet_id)); + } + + /// A classification that waits for the engine across its run's finish + /// and a new begin does not hand its guard to the new run. + #[tokio::test] + async fn a_classification_outliving_its_claim_holds_nothing() { + let reconciler = reconciler(); + assert!(reconciler.claim_without_gate(&A)); + let writer = Arc::clone(&reconciler.wallet_manager).write_owned().await; + let mut waiting = pin!(reconciler.classify_held(&A, &[])); + let mut cx = Context::from_waker(Waker::noop()); + assert!(waiting.as_mut().poll(&mut cx).is_pending()); + + reconciler.finish(&A, true); + assert!( + reconciler.claim_without_gate(&A), + "a new run for the wallet" + ); + drop(writer); + assert!( + timeout(PROCEEDS, waiting) + .await + .expect("classified") + .is_none(), + "the old run's claim is gone" + ); + assert!(!holds_engine(&reconciler, &A), "the new run holds no guard"); + } +} diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 9366ca45d7d..1f54482e5ff 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -1062,6 +1062,10 @@ impl PlatformWalletManager

{ } } + // A same-id wallet added later is reconciled at once rather than + // waiting out the removed one's cadence. + self.forget_txo_reconcile(wallet_id); + // Still under the generation's teardown gate: any deferred state naming // this generation is dropped in the same critical section as the removal // itself, so no payment operation can observe the wallet as live and then diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index eac5c60bcff..b9b34603297 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -2,7 +2,7 @@ use std::future::Future; use std::pin::pin; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; @@ -86,6 +86,8 @@ pub struct SpvRuntime { /// a lock other callers hold while they wait for `shutdown` would cycle. owner_released: AtomicBool, peer_tracker: Arc, + /// Successful [`start`](Self::start)s so far; see [`Self::run_session`]. + run_sessions: AtomicU64, /// Woken (`notify_waiters`) on every transition that can make /// [`Self::is_broadcast_ready`] true: a client stored by `start`, and a /// `PeersUpdated` snapshot stored by `peer_tracker`. Losing readiness @@ -133,6 +135,7 @@ impl SpvRuntime { shutdown: tokio::sync::Mutex::new(Shutdown::Idle), owner_released: AtomicBool::new(false), peer_tracker: Arc::new(PeerTracker::new(Arc::clone(&readiness_changed))), + run_sessions: AtomicU64::new(0), readiness_changed, } } @@ -276,6 +279,7 @@ impl SpvRuntime { let mut client = self.client.write().await; *client = Some(spv_client); *self.last_config.write().await = Some(retained_config); + self.run_sessions.fetch_add(1, Ordering::Relaxed); // Release the client before waking readiness waiters so they can // read it. drop(client); @@ -284,6 +288,14 @@ impl SpvRuntime { Ok(()) } + /// Identifies the current run of the client: it changes on every + /// successful [`start`](Self::start), so two reads that return the same + /// value saw no stop and restart between them, however quickly a host + /// cycled the client. 0 before the first start. + pub fn run_session(&self) -> u64 { + self.run_sessions.load(Ordering::Relaxed) + } + /// Refuse restart until all previous startup and shutdown work is joined. fn ensure_no_live_run_loop(&self) -> Result<(), PlatformWalletError> { let shutdown = self.shutdown.try_lock().map_err(|_| { diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e4a7fcad504..4622e92cfe2 100644 --- a/packages/rs-unified-sdk-jni/Cargo.toml +++ b/packages/rs-unified-sdk-jni/Cargo.toml @@ -18,6 +18,14 @@ platform-wallet-ffi = { path = "../rs-platform-wallet-ffi" } key-wallet-ffi = { workspace = true } dash-network = { workspace = true, features = ["ffi"] } log = "0.4" +# Serialization for the TXO-reconcile transport (the paged wallet +# inventory and the outpoint-classification batch). Both exports are +# shims over one platform-wallet call each: serde shapes the JSON +# against the same field names the Kotlin side decodes, so a renamed +# key fails the decode loudly instead of defaulting a row. +serde = { version = "1", features = ["derive"] } +serde_json = "1" +hex = "0.4" zeroize = "1" [target.'cfg(target_os = "android")'.dependencies] diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index da5d40e8f04..44f0c05adf6 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -60,8 +60,8 @@ use platform_wallet_ffi::{ PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + UnresolvedAssetLockTxRecordFFI, UtxoCreditVerdictFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, + WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -315,6 +315,13 @@ fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { on_persist_wallet_changeset_chain_lock_height_fn: Some( tramp_persist_wallet_changeset_chain_lock_height, ), + // Wired for every bridge: the base class's no-op body is exactly + // the behaviour of a host without the slot (verdicts only ever + // refine what the changeset callback writes), so there is no + // capability to strip the way the sweep slot has. + on_persist_wallet_changeset_utxo_verdicts_fn: Some( + tramp_persist_wallet_changeset_utxo_verdicts, + ), on_persist_wallet_dashpay_backfill_fn: if overrides.header && overrides.backfill { Some(tramp_persist_wallet_dashpay_backfill) } else { @@ -1027,6 +1034,59 @@ unsafe extern "C" fn tramp_persist_wallet_changeset_chain_lock_height( }) } +/// Descriptor of `NativePersistenceBridge.onWalletChangesetUtxoVerdicts`: +/// `(walletId, outpoints, verdicts, spentAtHeights, count)`. Outpoints ride +/// as ONE flat `byte[]` of 36-byte keys (`pack_outpoint_key`, the shape the +/// handler stores them in), the verdict codes as one `byte[]` and the +/// observed-spent heights as one `int[]`, all N long — the same flat packing +/// as the sweep batches, since the count is chain-controlled and this runs +/// inside the atomic persistence callback. +const WALLET_CHANGESET_UTXO_VERDICTS_DESCRIPTOR: &str = "([B[B[B[II)I"; + +/// Deliver the round's credit verdicts (see +/// `PersistWalletChangesetUtxoVerdictsFn`): every `Received` / `Change` +/// output of the round's records the engine did NOT credit, with the +/// reason. Fired by `store()` inside the begin/end bracket BEFORE the +/// changeset callback and only on rounds that carry a verdict, so the +/// handler has them in hand when the round's `utxos_added` entries arrive. +/// One bridge call for the whole set, in the slot's outpoint order. +unsafe extern "C" fn tramp_persist_wallet_changeset_utxo_verdicts( + context: *mut c_void, + wallet_id: *const u8, + verdicts: *const UtxoCreditVerdictFFI, + verdicts_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let entries = slice_or_empty(verdicts, verdicts_count); + let mut packed_outpoints = Vec::with_capacity(entries.len() * 36); + let mut codes = Vec::with_capacity(entries.len()); + let mut heights = Vec::with_capacity(entries.len()); + for entry in entries { + packed_outpoints.extend_from_slice(&pack_outpoint_key(&entry.outpoint)); + codes.push(entry.verdict); + heights.push(jint_height(entry.spent_at_height)?); + } + let outpoints_arr = env.byte_array_from_slice(&packed_outpoints)?; + let codes_arr = env.byte_array_from_slice(&codes)?; + let heights_arr = env.new_int_array(heights.len() as i32)?; + env.set_int_array_region(&heights_arr, 0, &heights)?; + env.call_method( + bridge, + "onWalletChangesetUtxoVerdicts", + WALLET_CHANGESET_UTXO_VERDICTS_DESCRIPTOR, + &[ + (&wid).into(), + (&outpoints_arr).into(), + (&codes_arr).into(), + (&heights_arr).into(), + JValue::Int(entries.len() as i32), + ], + )? + .i() + }) +} + /// Wire size of one covered contact in the flat `covered` array handed to /// `onWalletChangesetDashPayBackfill` and read back from /// `WalletRestoreData.dashPayBackfillCovered`: owner (32) ‖ contact (32) ‖ @@ -4902,6 +4962,13 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ // literal at the `call_method` site in // `tramp_persist_wallet_changeset_chain_lock_height`. ("onWalletChangesetChainLockHeight", "([BI)I"), + // Same drift risk again: this slot fires only on rounds that carry a + // credit verdict. The same constant is bound at the `call_method` site + // in `tramp_persist_wallet_changeset_utxo_verdicts`. + ( + "onWalletChangesetUtxoVerdicts", + WALLET_CHANGESET_UTXO_VERDICTS_DESCRIPTOR, + ), ( "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, @@ -5199,6 +5266,14 @@ mod tests { assert_eq!(WALLET_CHANGESET_SWEEPS_DESCRIPTOR, "([B[BI[B[BIZI)I"); } + #[test] + fn utxo_verdicts_callback_descriptor_ships_flat_arrays_and_a_count() { + // walletId, packed 36-byte outpoints, verdict codes, observed-spent + // heights, count — must match + // `NativePersistenceBridge.onWalletChangesetUtxoVerdicts`. + assert_eq!(WALLET_CHANGESET_UTXO_VERDICTS_DESCRIPTOR, "([B[B[B[II)I"); + } + #[test] fn vtable_layout_remains_independent_of_capability_declaration() { let callbacks = build_vtable(ptr::null_mut()); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 99eba41dbd0..dba7151b4a2 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3221,6 +3221,698 @@ fn core_selection_strategy( } } +// ── TXO-store reconcile ─────────────────────────────────────────────── +// +// Each export bridges exactly ONE platform-wallet call: the schedule +// (`platform_wallet_manager_txo_reconcile_due_wallets`), the start gate and +// its release (`…_txo_reconcile_begin` / `…_finish`), the heal page +// (`platform_wallet_txo_reconcile_heal_page`), the spend verdicts and the +// engine guard they are held under +// (`platform_wallet_txo_reconcile_classify_outpoints` / +// `…_txo_reconcile_held_verdicts_free`) and the stale-page retry +// (`platform_wallet_txo_reconcile_retry_stale_page`). The cadence, the +// gates, the account ordering, the cursor semantics, the page bound, the +// heal verdicts and every classification rule live in `platform-wallet`. +// Nothing here decides anything; it moves values and serde-shaped JSON in +// and out. + +/// The account tuple that owns one inventory row — the raw `AccountSpecFFI` +/// tag layout minus the xpub, in the field names the Kotlin `EngineUtxoRow` +/// and `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(Clone, Debug, PartialEq, 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), + } + } + + /// The two 32-byte identity halves, or `None` when a hex string the + /// host handed back is malformed — a tuple this layer did not emit. + fn identity_ids(&self) -> Option<([u8; 32], [u8; 32])> { + 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(( + id32(&self.user_identity_id)?, + id32(&self.friend_identity_id)?, + )) + } +} + +/// One row of a heal page — the typed contract the Kotlin +/// `EngineUtxoRow` decodes, strictly (an unknown or missing key throws +/// there rather than healing a defaulted row). `txid` is lower hex in the +/// same byte order the changeset path hands Kotlin, so hex→bytes +/// reproduces the `txos.txid` blob; `address` is the engine's own +/// Base58Check rendering, empty when the script has no address form; +/// `healVerdict` is the engine's `TXO_HEAL_VERDICT_*` for the row. +#[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_confirmed: bool, + is_instantlocked: bool, + is_coinbase: bool, + is_locked: bool, + heal_verdict: u8, +} + +impl UtxoPageRow { + /// The row for one heal-page entry, carrying the engine's own address, + /// flags and verdict. + /// + /// # Safety + /// + /// `r.utxo.address` must be null or a valid NUL-terminated string, and + /// `r.utxo.script_pubkey` null or valid for `r.utxo.script_pubkey_len` + /// bytes — the heal page's contract for every row it returns. + unsafe fn from_heal_row(r: &platform_wallet_ffi::TxoHealRowFFI) -> Self { + let e = &r.utxo; + let script: &[u8] = if e.script_pubkey.is_null() || e.script_pubkey_len == 0 { + &[] + } else { + std::slice::from_raw_parts(e.script_pubkey, e.script_pubkey_len) + }; + // Never null per the accessor's contract; treated as empty if it + // ever is, which the Kotlin heal skips rather than filing a row it + // cannot key an address by. + let address = if e.address.is_null() { + String::new() + } else { + CStr::from_ptr(e.address).to_string_lossy().into_owned() + }; + 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_confirmed: e.is_confirmed, + is_instantlocked: e.is_instantlocked, + is_coinbase: e.is_coinbase, + is_locked: e.is_locked, + heal_verdict: r.verdict, + } + } +} + +/// 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, +} + +impl UtxoPage { + /// The page for `utxos`. 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. Fails only if the cursor cannot be encoded. + fn new(utxos: Vec, has_more: bool) -> serde_json::Result { + let cursor = match utxos.last() { + Some(last) if has_more => Some(serde_json::to_string(&UtxoPageCursor::after(last))?), + _ => None, + }; + Ok(UtxoPage { + has_more: has_more && cursor.is_some(), + cursor, + utxos, + }) + } +} + +/// Where a paged inventory sweep left off — the last row's account tuple +/// and outpoint, which is exactly what `platform_wallet_txo_reconcile_heal_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, +} + +impl UtxoPageCursor { + /// The cursor that resumes the sweep after `last`. + fn after(last: &UtxoPageRow) -> Self { + UtxoPageCursor { + account: last.account.clone(), + txid: last.txid.clone(), + vout: last.vout, + } + } + + /// The resume point a cursor string names, or `None` when it is not + /// one a previous page returned (unparseable, or a malformed identity + /// half or txid). + fn decode(raw: &str) -> Option { + let c = serde_json::from_str::(raw).ok()?; + let (user_identity_id, friend_identity_id) = c.account.identity_ids()?; + let txid: [u8; 32] = hex::decode(&c.txid).ok()?.try_into().ok()?; + Some(platform_wallet_ffi::WalletUtxoCursorFFI { + type_tag: c.account.type_tag, + standard_tag: c.account.standard_tag, + index: c.account.index, + registration_index: c.account.registration_index, + key_class: c.account.key_class, + user_identity_id, + friend_identity_id, + outpoint: platform_wallet_ffi::OutPointFFI { txid, vout: c.vout }, + }) + } +} + +/// One store row the host asks the engine to classify: the account the +/// STORE files the coin under, the outpoint, and the script the store +/// recorded. The engine checks that claim against its own pools rather +/// than trusting it, which is why the tuple and script travel with every +/// query instead of the outpoint alone. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct OutpointQuery { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, + vout: u32, + #[serde(default)] + script_hex: String, +} + +impl OutpointQuery { + /// The FFI query for this row and the script bytes it must point at. + /// The script pointer is left null: the caller fills it once the + /// owned scripts have stopped moving. The error names the malformed + /// field, as the SDK exception the batch fails with. + fn to_ffi( + &self, + ) -> Result<(platform_wallet_ffi::OutpointOwnershipQueryFFI, Vec), &'static str> { + let (user_identity_id, friend_identity_id) = self + .account + .identity_ids() + .ok_or("outpoint query carries a malformed identity id")?; + let txid: [u8; 32] = hex::decode(&self.txid) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or("outpoint query carries a malformed txid")?; + let script = hex::decode(&self.script_hex) + .map_err(|_| "outpoint query carries a malformed script")?; + let query = platform_wallet_ffi::OutpointOwnershipQueryFFI { + type_tag: self.account.type_tag, + standard_tag: self.account.standard_tag, + index: self.account.index, + registration_index: self.account.registration_index, + key_class: self.account.key_class, + user_identity_id, + friend_identity_id, + outpoint: platform_wallet_ffi::OutPointFFI { + txid, + vout: self.vout, + }, + script_pubkey: ptr::null(), + script_pubkey_len: 0, + }; + Ok((query, script)) + } +} + +/// The wallets whose TXO-store reconcile is due now, as a flat `byte[]` of +/// concatenated 32-byte ids. Bridges +/// `platform_wallet_manager_txo_reconcile_due_wallets`: the steady-state +/// edge and the 30-minute cadence live there. Reporting a wallet consumes +/// none of its cadence, so one the host skips is reported again. Called on +/// every SPV progress poll, running or not. Backs `PlatformWalletManager`'s +/// progress poll. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileDueWallets( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let mut out = IdentifierArray { + items: ptr::null_mut(), + count: 0, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_txo_reconcile_due_wallets( + manager_handle as Handle, + &mut out as *mut IdentifierArray, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + identifier_array_to_flat(env, out) + }) +} + +/// Claim one wallet for a TXO-store reconcile run and check that it may +/// start. Returns `TXO_RECONCILE_GATE_RUN` (0) or a `TXO_RECONCILE_SKIP_*` +/// reason; on 0, `outTipHeight[0]` is the scan tip to pass to +/// `walletManagerTxoHealPageJson`, and the claim holds until +/// `walletManagerTxoReconcileFinish`. Bridges +/// `platform_wallet_manager_txo_reconcile_begin`, where every gate lives. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileBegin( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + out_tip_height: jni::sys::jintArray, +) -> jint { + guard(&mut env, -1, |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return -1; + }; + if out_tip_height.is_null() { + throw_sdk_exception(env, 1, "outTipHeight must be an IntArray(1)"); + return -1; + } + let mut gate = 0u8; + let mut tip_height = 0u32; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_txo_reconcile_begin( + manager_handle as Handle, + wid.as_ptr(), + &mut gate as *mut u8, + &mut tip_height as *mut u32, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + // SAFETY: Kotlin allocates `outTipHeight` as an IntArray(1). + let out = unsafe { jni::objects::JIntArray::from_raw(out_tip_height) }; + // A tip past i32::MAX cannot cross as a jint; no Dash height is near it. + let tip = jint::try_from(tip_height).unwrap_or(jint::MAX); + if env.set_int_array_region(&out, 0, &[tip]).is_err() { + return -1; + } + jint::from(gate) + }) +} + +/// Release the claim a 0 from `walletManagerTxoReconcileBegin` granted, +/// and any engine guard its classification still holds; [ran] is whether +/// the run went ahead. Bridges +/// `platform_wallet_manager_txo_reconcile_finish`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileFinish( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + ran: jboolean, +) { + guard(&mut env, (), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return; + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_txo_reconcile_finish( + manager_handle as Handle, + wid.as_ptr(), + ran != JNI_FALSE, + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + +/// Release the engine guard `walletManagerTxoReconcileClassifyOutpoints` +/// handed out as `outHeld[0]`, once its page is applied. Takes no manager +/// handle and touches no handle registry, so it completes whatever is +/// queued there. 0 is a no-op; each value is released once. Bridges +/// `platform_wallet_txo_reconcile_held_verdicts_free`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_txoReconcileHeldVerdictsFree( + mut env: JNIEnv, + _class: JClass, + held: jlong, +) { + guard(&mut env, (), |_env| unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_held_verdicts_free( + held as *mut platform_wallet_ffi::TxoReconcileHeldVerdictsFFI, + ) + }) +} + +/// Whether a TXO-store reconcile run classifies a page again after it came +/// back stale for the `staleInARow`-th time in a row. Bridges +/// `platform_wallet_txo_reconcile_retry_stale_page`, which holds the limit. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_txoReconcileRetryStalePage( + mut env: JNIEnv, + _class: JClass, + stale_in_a_row: jint, +) -> jboolean { + guard(&mut env, JNI_FALSE, |_env| { + let count = u32::try_from(stale_in_a_row).unwrap_or(0); + if platform_wallet_ffi::platform_wallet_txo_reconcile_retry_stale_page(count) { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + +/// One bounded page of the engine's UTXO inventory across every funds +/// account of one wallet, each row with the engine's heal verdict at +/// `tipHeight` — 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). +/// Bridges `platform_wallet_txo_reconcile_heal_page`. +/// +/// 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 steady-state transition and +/// every cadence pass. Nothing bigger than one page is ever formatted, +/// copied across JNI, or parsed. +/// +/// 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 passes 0, which means +/// the engine's own default, and the engine clamps its own maximum — this +/// layer holds no copy of either bound. `tipHeight` is the tip +/// `walletManagerTxoReconcileBegin` returned. +/// +/// Rows carry the engine's own address, flags and verdict. Nothing is +/// derived here: an address computed in this shim could disagree with the +/// one the engine credited the coin under, and the store keys its address +/// rows by that string. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoHealPageJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + cursor: JString, + limit: jint, + tip_height: 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 Ok(tip_height) = u32::try_from(tip_height) else { + throw_sdk_exception(env, 1, "tipHeight must be non-negative"); + return ptr::null_mut(); + }; + // 0 means "the engine's default page"; the engine also clamps its + // own maximum, so a host asking for more simply gets less. + let page_limit = if limit <= 0 { 0usize } else { limit as usize }; + + // Resume point, decoded from the opaque cursor. Kept alive across + // the FFI call — it is passed by pointer. + let resume: Option = 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(); + } + }; + match UtxoPageCursor::decode(&raw) { + 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::TxoHealRowFFI = ptr::null(); + let mut count: usize = 0; + let mut has_more = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_heal_page( + manager_handle as Handle, + wid.as_ptr(), + resume.as_ref().map_or(ptr::null(), |c| c as *const _), + page_limit, + tip_height, + &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 r in items { + // SAFETY: every row the heal page returns satisfies the + // pointer contract `from_heal_row` documents. + rows.push(unsafe { UtxoPageRow::from_heal_row(r) }); + } + unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_heal_page_free( + entries as *mut platform_wallet_ffi::TxoHealRowFFI, + count, + ) + }; + } + let page = match UtxoPage::new(rows, has_more) { + Ok(p) => p, + Err(e) => { + throw_sdk_exception(env, 1, &format!("inventory cursor encode failed: {e}")); + return ptr::null_mut(); + } + }; + 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 store rows against the engine's live state — the +/// reverse half of the reconcile transport, and the reason the paged +/// inventory carries no 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. +/// +/// `queriesJson` is a JSON array of store rows: the account tuple the +/// store files each coin under, its `txid` (lower hex, wire order) and +/// `vout`, and the `scriptHex` the store recorded for it. The account and +/// script are the store's CLAIM — the engine checks both against its own +/// pools rather than trusting them, which is why a bare outpoint is not +/// enough. +/// +/// Returns one verdict byte per query, positionally: see the +/// `OUTPOINT_CLASS_*` constants (0 unknown, 1 unspent, 2 known-uncredited, +/// 3 not-owned). A query whose account tag this build cannot map keeps +/// `unknown` and the rest are still answered. +/// +/// The answers come back with the engine's wallet-manager read lock still +/// held for the wallet's reconcile run, so no fork can drop a spender they +/// name before the page is applied. The guard is `outHeld[0]` (0 when +/// nothing is held), written last, only once the answers are ready to +/// return; release it with `txoReconcileHeldVerdictsFree` before the next +/// call that takes this manager handle. Call outside the persistence +/// callback exclusion. Bridges +/// `platform_wallet_txo_reconcile_classify_outpoints`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileClassifyOutpoints( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + queries_json: JString, + out_held: jni::sys::jlongArray, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + if out_held.is_null() { + throw_sdk_exception(env, 1, "outHeld must be a LongArray(1)"); + return ptr::null_mut(); + } + let raw = match env.get_string(&queries_json) { + Ok(s) => String::from(s), + Err(_) => { + throw_sdk_exception(env, 1, "outpoint queries must be a String"); + return ptr::null_mut(); + } + }; + let parsed: Vec = match serde_json::from_str(&raw) { + Ok(q) => q, + Err(e) => { + throw_sdk_exception(env, 1, &format!("malformed outpoint queries: {e}")); + return ptr::null_mut(); + } + }; + if parsed.is_empty() { + return env + .byte_array_from_slice(&[]) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()); + } + // Scripts are owned here for the duration of the call — the query + // struct borrows them by pointer. + let mut scripts: Vec> = Vec::with_capacity(parsed.len()); + let mut queries: Vec = + Vec::with_capacity(parsed.len()); + for q in &parsed { + match q.to_ffi() { + Ok((query, script)) => { + scripts.push(script); + // Its script pointer is filled below, once `scripts` + // has stopped reallocating. + queries.push(query); + } + Err(message) => { + throw_sdk_exception(env, 1, message); + return ptr::null_mut(); + } + } + } + for (query, script) in queries.iter_mut().zip(scripts.iter()) { + query.script_pubkey = if script.is_empty() { + ptr::null() + } else { + script.as_ptr() + }; + query.script_pubkey_len = script.len(); + } + + // Caller-allocated answers, one byte per query. + let mut classes = vec![platform_wallet_ffi::OUTPOINT_CLASS_UNKNOWN; queries.len()]; + let mut held: *mut platform_wallet_ffi::TxoReconcileHeldVerdictsFFI = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_classify_outpoints( + manager_handle as Handle, + wid.as_ptr(), + queries.as_ptr(), + queries.len(), + classes.as_mut_ptr(), + &mut held, + ) + }; + // From here every exit — an error, a failed write, a panic that + // `guard` catches — frees the guard, until Kotlin owns it. + let held = UnsentHeldVerdicts::new( + held, + platform_wallet_ffi::platform_wallet_txo_reconcile_held_verdicts_free, + ); + // `scripts` must outlive the call above; this keeps that explicit + // against a future reorder. + drop(scripts); + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let Ok(answers) = env.byte_array_from_slice(&classes).map(|a| a.into_raw()) else { + return ptr::null_mut(); + }; + // SAFETY: Kotlin allocates `outHeld` as a LongArray(1). + let out = unsafe { jni::objects::JLongArray::from_raw(out_held) }; + if env + .set_long_array_region(&out, 0, &[held.as_ptr() as jlong]) + .is_err() + { + return ptr::null_mut(); + } + held.hand_over(); + answers + }) +} + +/// A held-verdicts guard `walletManagerTxoReconcileClassifyOutpoints` has +/// not handed to Kotlin yet. Dropped before [`Self::hand_over`] — an early +/// return, or a panic unwinding into `guard` — it frees the guard at once. +/// Left to `walletManagerTxoReconcileFinish` instead, it would be released +/// only by a call that resolves the manager handle in the native registry +/// while the guard is still held, the registry/engine lock cycle the token +/// exists to avoid. +struct UnsentHeldVerdicts { + held: *mut T, + free: unsafe extern "C" fn(*mut T), +} + +impl UnsentHeldVerdicts { + fn new(held: *mut T, free: unsafe extern "C" fn(*mut T)) -> Self { + Self { held, free } + } + + fn as_ptr(&self) -> *mut T { + self.held + } + + /// Kotlin owns the guard now and frees it with + /// `txoReconcileHeldVerdictsFree`. + fn hand_over(mut self) { + self.held = ptr::null_mut(); + } +} + +impl Drop for UnsentHeldVerdicts { + fn drop(&mut self) { + if !self.held.is_null() { + // SAFETY: `held` came from the native classify and was never + // handed over, so nothing else frees it. + unsafe { (self.free)(self.held) }; + } + } +} /// 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]> { @@ -4003,3 +4695,503 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_s } }) } + +#[cfg(test)] +mod tests { + //! The TXO-reconcile transport codecs against the golden payloads the + //! Kotlin `PlatformWalletPersistenceHandlerTest` reads too, so the + //! serde models here and the kotlinx models there cannot drift apart + //! silently. + + use super::*; + use platform_wallet_ffi::{ + AccountTypeTagFFI, StandardAccountTypeTagFFI, TxoHealRowFFI, WalletUtxoEntryFFI, + TXO_HEAL_VERDICT_ELIGIBLE, TXO_HEAL_VERDICT_IMMATURE, TXO_HEAL_VERDICT_INVALID, + }; + use std::ffi::CString; + + /// The checked-in transport payloads, shared byte-for-byte with the + /// Kotlin tests (`theInventoryDecoderReadsTheGoldenRustPage`, + /// `classificationQueriesMatchTheGoldenRustInput`). Referenced from the + /// single canonical copy in the Kotlin SDK's test resources. + const GOLDEN: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json" + )); + + fn golden() -> serde_json::Value { + serde_json::from_str(GOLDEN).expect("golden fixture must parse") + } + + /// A txid whose bytes count up from `first`, so a byte-order slip shows. + fn txid(first: u8) -> [u8; 32] { + std::array::from_fn(|i| first + i as u8) + } + + fn p2pkh(fill: u8) -> Vec { + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend([fill; 20]); + script.extend([0x88, 0xac]); + script + } + + /// One heal-page row plus the buffers it points into, kept alive with it. + struct Coin { + row: TxoHealRowFFI, + _address: CString, + script: Vec, + } + + impl Coin { + fn entry(&self) -> &WalletUtxoEntryFFI { + &self.row.utxo + } + } + + struct CoinSpec { + type_tag: AccountTypeTagFFI, + standard_tag: StandardAccountTypeTagFFI, + index: u32, + registration_index: u32, + key_class: u32, + identities: [[u8; 32]; 2], + txid: [u8; 32], + vout: u32, + value: u64, + address: &'static str, + script: Vec, + height: u32, + /// confirmed, instantlocked, coinbase, locked. + flags: [bool; 4], + heal_verdict: u8, + } + + impl CoinSpec { + fn build(self) -> Coin { + let address = CString::new(self.address).expect("fixture address has no NUL"); + let script = self.script; + let entry = WalletUtxoEntryFFI { + 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: self.identities[0], + friend_identity_id: self.identities[1], + outpoint: platform_wallet_ffi::OutPointFFI { + txid: self.txid, + vout: self.vout, + }, + value_duffs: self.value, + address: address.as_ptr() as *mut c_char, + // Null when empty, as the accessor hands it over. + script_pubkey: if script.is_empty() { + ptr::null_mut() + } else { + script.as_ptr() as *mut u8 + }, + script_pubkey_len: script.len(), + height: self.height, + is_confirmed: self.flags[0], + is_instantlocked: self.flags[1], + is_coinbase: self.flags[2], + is_locked: self.flags[3], + }; + Coin { + row: TxoHealRowFFI { + utxo: entry, + verdict: self.heal_verdict, + }, + _address: address, + script, + } + } + } + + const NO_IDENTITY: [[u8; 32]; 2] = [[0u8; 32]; 2]; + const DASHPAY_IDENTITIES: [[u8; 32]; 2] = [[0xa1; 32], [0xb2; 32]]; + + /// The four golden coins: a zero-valued BIP44 account, non-default + /// account fields with every flag set, a registration index with no + /// address form, and a DashPay account with both identity halves. + fn golden_coins() -> Vec { + vec![ + CoinSpec { + type_tag: AccountTypeTagFFI::Standard, + standard_tag: StandardAccountTypeTagFFI::Bip44, + index: 0, + registration_index: 0, + key_class: 0, + identities: NO_IDENTITY, + txid: txid(0x00), + vout: 0, + value: 19_549, + address: "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + script: p2pkh(0x5b), + height: 1_534_921, + flags: [true, false, false, false], + heal_verdict: TXO_HEAL_VERDICT_ELIGIBLE, + } + .build(), + CoinSpec { + type_tag: AccountTypeTagFFI::Standard, + standard_tag: StandardAccountTypeTagFFI::Bip32, + index: 2, + registration_index: 0, + key_class: 0, + identities: NO_IDENTITY, + txid: txid(0x20), + vout: 7, + value: 5_000_000_000, + address: "yGoldenFixtureBip32Addr", + script: p2pkh(0xc3), + height: 1_400_000, + flags: [true, true, true, true], + heal_verdict: TXO_HEAL_VERDICT_ELIGIBLE, + } + .build(), + CoinSpec { + type_tag: AccountTypeTagFFI::IdentityTopUp, + standard_tag: StandardAccountTypeTagFFI::Bip44, + index: 0, + registration_index: 4, + key_class: 0, + identities: NO_IDENTITY, + txid: txid(0x40), + vout: 1, + value: 100_000, + address: "", + script: Vec::new(), + height: 0, + flags: [false, false, false, false], + heal_verdict: TXO_HEAL_VERDICT_INVALID, + } + .build(), + CoinSpec { + type_tag: AccountTypeTagFFI::DashpayReceivingFunds, + standard_tag: StandardAccountTypeTagFFI::Bip44, + index: 1, + registration_index: 0, + key_class: 0, + identities: DASHPAY_IDENTITIES, + txid: txid(0x60), + vout: 3, + value: 250_000, + address: "yGoldenFixtureDashPayAddr", + script: p2pkh(0xd4), + height: 1_534_000, + flags: [true, true, false, false], + heal_verdict: TXO_HEAL_VERDICT_IMMATURE, + } + .build(), + ] + } + + fn rows(coins: &[Coin]) -> Vec { + coins + .iter() + // SAFETY: every pointer in a `Coin` entry points into that coin's + // own live buffers. + .map(|c| unsafe { UtxoPageRow::from_heal_row(&c.row) }) + .collect() + } + + // ── Inventory page (Rust → Kotlin) ──────────────────────────────── + + #[test] + fn inventory_page_serializes_to_the_golden_page() { + let page = UtxoPage::new(rows(&golden_coins()), true).expect("page encodes"); + assert_eq!( + serde_json::to_value(&page).expect("page serializes"), + golden()["inventoryPage"], + "the inventory page drifted from the golden fixture the Kotlin decoder is tested against" + ); + } + + #[test] + fn a_last_or_empty_page_carries_no_cursor() { + let last = UtxoPage::new(rows(&golden_coins()), false).expect("page encodes"); + assert!(!last.has_more); + assert!(last.cursor.is_none()); + let value = serde_json::to_value(&last).expect("page serializes"); + assert_eq!(value["cursor"], serde_json::Value::Null); + assert_eq!(value["hasMore"], serde_json::Value::Bool(false)); + + // An empty page has nowhere to resume from, whatever the accessor said. + let empty = UtxoPage::new(Vec::new(), true).expect("page encodes"); + assert!(!empty.has_more); + assert!(empty.cursor.is_none()); + } + + #[test] + fn the_golden_cursor_resumes_after_the_last_row() { + let coins = golden_coins(); + let last = coins.last().expect("golden coins").entry(); + let raw = golden()["inventoryPage"]["cursor"] + .as_str() + .expect("golden cursor is a string") + .to_owned(); + + let resume = UtxoPageCursor::decode(&raw).expect("golden cursor decodes"); + + assert_eq!(resume.type_tag, last.type_tag as u8); + assert_eq!(resume.standard_tag, last.standard_tag as u8); + assert_eq!(resume.index, last.index); + assert_eq!(resume.registration_index, last.registration_index); + assert_eq!(resume.key_class, last.key_class); + assert_eq!(resume.user_identity_id, last.user_identity_id); + assert_eq!(resume.friend_identity_id, last.friend_identity_id); + assert_eq!(resume.outpoint.txid, last.outpoint.txid); + assert_eq!(resume.outpoint.vout, last.outpoint.vout); + } + + #[test] + fn a_cursor_round_trips_every_account_field() { + let coin = CoinSpec { + type_tag: AccountTypeTagFFI::PlatformPayment, + standard_tag: StandardAccountTypeTagFFI::Bip32, + index: 7, + registration_index: 9, + key_class: 2, + identities: [[0x0c; 32], [0x0d; 32]], + txid: txid(0x80), + vout: 65_535, + value: 1, + address: "yGoldenFixtureCursorAddr", + script: p2pkh(0x01), + height: 2, + flags: [false, true, false, true], + heal_verdict: TXO_HEAL_VERDICT_IMMATURE, + } + .build(); + let row = rows(std::slice::from_ref(&coin)).remove(0); + let raw = serde_json::to_string(&UtxoPageCursor::after(&row)).expect("cursor encodes"); + + let resume = UtxoPageCursor::decode(&raw).expect("own cursor decodes"); + + let e = coin.entry(); + assert_eq!( + ( + resume.type_tag, + resume.standard_tag, + resume.index, + resume.registration_index, + resume.key_class + ), + (14, 1, 7, 9, 2) + ); + assert_eq!(resume.user_identity_id, e.user_identity_id); + assert_eq!(resume.friend_identity_id, e.friend_identity_id); + assert_eq!(resume.outpoint.txid, e.outpoint.txid); + assert_eq!(resume.outpoint.vout, e.outpoint.vout); + } + + #[test] + fn a_cursor_this_layer_did_not_emit_is_rejected() { + let good: serde_json::Value = serde_json::from_str( + golden()["inventoryPage"]["cursor"] + .as_str() + .expect("golden cursor is a string"), + ) + .expect("golden cursor is JSON"); + let tampered = |key: &str, value: serde_json::Value| { + let mut c = good.clone(); + c[key] = value; + c.to_string() + }; + let mut missing_index = good.clone(); + missing_index + .as_object_mut() + .expect("cursor is an object") + .remove("index"); + + for (label, raw) in [ + ("not a cursor object", "7".to_owned()), + ("missing index", missing_index.to_string()), + ("short txid", tampered("txid", "00".repeat(31).into())), + ("non-hex txid", tampered("txid", "zz".repeat(32).into())), + ( + "short identity", + tampered("userIdentityId", "a1".repeat(31).into()), + ), + ( + "odd-length identity", + tampered("friendIdentityId", "b".repeat(63).into()), + ), + ] { + assert!( + UtxoPageCursor::decode(&raw).is_none(), + "{label} must be rejected" + ); + } + } + + // ── Classification queries (Kotlin → Rust) ──────────────────────── + + fn golden_queries() -> serde_json::Value { + golden()["outpointQueries"].clone() + } + + #[test] + fn golden_queries_decode_to_the_inventory_coins() { + let parsed: Vec = + serde_json::from_value(golden_queries()).expect("golden queries deserialize"); + let coins = golden_coins(); + assert_eq!(parsed.len(), coins.len()); + + for (q, coin) in parsed.iter().zip(&coins) { + let e = coin.entry(); + // The tuple the store sends is the tuple the inventory emitted. + assert_eq!(q.account, UtxoAccountTuple::from_entry(e)); + let (ffi, script) = q.to_ffi().expect("golden query converts"); + assert_eq!(ffi.type_tag, e.type_tag as u8); + assert_eq!(ffi.standard_tag, e.standard_tag as u8); + assert_eq!(ffi.index, e.index); + assert_eq!(ffi.registration_index, e.registration_index); + assert_eq!(ffi.key_class, e.key_class); + assert_eq!(ffi.user_identity_id, e.user_identity_id); + assert_eq!(ffi.friend_identity_id, e.friend_identity_id); + assert_eq!( + ffi.outpoint.txid, e.outpoint.txid, + "same byte order both ways" + ); + assert_eq!(ffi.outpoint.vout, e.outpoint.vout); + assert_eq!(script, coin.script); + assert!( + ffi.script_pubkey.is_null(), + "the caller fills the script pointer" + ); + } + } + + #[test] + fn a_query_missing_any_required_field_fails_the_batch() { + // Mirrors Kotlin's `FakeEngine.RUST_REQUIRED_QUERY_KEYS`: every field + // without a serde default, zero-valued ones included (kotlinx drops a + // property equal to its declared default, which once sent BIP44 + // account-0 queries without `standardTag`). + for key in [ + "typeTag", + "standardTag", + "index", + "registrationIndex", + "keyClass", + "txid", + "vout", + ] { + let mut batch = golden_queries(); + batch[0] + .as_object_mut() + .expect("query is an object") + .remove(key); + let err = serde_json::from_value::>(batch) + .err() + .unwrap_or_else(|| panic!("a query without `{key}` must not deserialize")); + assert!( + err.to_string().contains(&format!("missing field `{key}`")), + "{key}: {err}" + ); + } + } + + #[test] + fn malformed_query_fields_are_rejected_by_name() { + let dashpay = 3; + let cases: [(&str, serde_json::Value, &str); 5] = [ + ( + "userIdentityId", + "a1".repeat(31).into(), + "outpoint query carries a malformed identity id", + ), + ( + "friendIdentityId", + "zz".repeat(32).into(), + "outpoint query carries a malformed identity id", + ), + ( + "txid", + "60".repeat(31).into(), + "outpoint query carries a malformed txid", + ), + ( + "txid", + "6".repeat(64 + 1).into(), + "outpoint query carries a malformed txid", + ), + ( + "scriptHex", + "76a".into(), + "outpoint query carries a malformed script", + ), + ]; + for (key, value, expected) in cases { + let mut batch = golden_queries(); + batch[dashpay][key] = value; + let parsed: Vec = + serde_json::from_value(batch).expect("still well-formed JSON"); + assert_eq!( + parsed[dashpay].to_ffi().err(), + Some(expected), + "{key} must be rejected" + ); + } + } + + mod unsent_held_verdicts { + use std::ptr::NonNull; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::super::UnsentHeldVerdicts; + + /// A stand-in guard: the frees below only count, never dereference. + fn token() -> *mut u8 { + NonNull::::dangling().as_ptr() + } + + #[test] + fn an_unsent_guard_is_freed_once_on_drop() { + static FREED: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn free(_: *mut u8) { + FREED.fetch_add(1, Ordering::SeqCst); + } + drop(UnsentHeldVerdicts::new(token(), free)); + assert_eq!(FREED.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_guard_handed_to_kotlin_is_not_freed() { + static FREED: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn free(_: *mut u8) { + FREED.fetch_add(1, Ordering::SeqCst); + } + let held = UnsentHeldVerdicts::new(token(), free); + assert_eq!(held.as_ptr(), token()); + held.hand_over(); + assert_eq!(FREED.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_panic_before_the_hand_over_frees_the_guard() { + static FREED: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn free(_: *mut u8) { + FREED.fetch_add(1, Ordering::SeqCst); + } + let unwound = std::panic::catch_unwind(|| { + let _held = UnsentHeldVerdicts::new(token(), free); + panic!("writing outHeld failed"); + }); + assert!(unwound.is_err()); + assert_eq!(FREED.load(Ordering::SeqCst), 1, "freed while unwinding"); + } + + #[test] + fn nothing_held_is_nothing_freed() { + static FREED: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn free(_: *mut u8) { + FREED.fetch_add(1, Ordering::SeqCst); + } + drop(UnsentHeldVerdicts::new(std::ptr::null_mut(), free)); + assert_eq!(FREED.load(Ordering::SeqCst), 0); + } + } +}