From e5e34f5f7c453c20238546dcfcc7a082c9f7d1ee Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 18 Sep 2026 13:30:54 -0700 Subject: [PATCH 01/28] feat(unified-sdk-jni): bind the paged wallet inventory and outpoint classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4638 landed the wallet-wide paged UTXO inventory and the outpoint classifier in platform-wallet, and stopped at the C boundary — the Swift host reaches them, Kotlin does not. These two exports carry them across JNI and no further. Each is a serialization shim over exactly ONE platform-wallet call. The account ordering, the cursor semantics, the page bound, the address rendering and every classification rule stay in `platform-wallet`, so both hosts walk the identical inventory and get identical verdicts. Nothing here decides anything: serde shapes the JSON against the same field names the Kotlin side decodes, so a renamed key fails that decode loudly instead of healing a defaulted row. Paged rather than swept whole because inventory size is chain-controlled: anyone who knows a watched address can keep sending dust to it, and a periodic full-inventory read would let them decide how much a phone allocates on every pass. Classification queries carry the account tuple the STORE files each coin under and the script it recorded. The engine checks that claim against its own pools rather than trusting it, which is what lets it answer `not-owned` — so a bare outpoint cannot ask the question. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 3 + packages/rs-unified-sdk-jni/Cargo.toml | 8 + .../rs-unified-sdk-jni/src/wallet_manager.rs | 437 ++++++++++++++++++ 3 files changed, 448 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6b7b6179ace..d2387888b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6540,11 +6540,14 @@ dependencies = [ "dash-network", "dashcore", "dashpay-contract", + "hex", "jni 0.21.1", "key-wallet-ffi", "log", "platform-wallet-ffi", "rs-sdk-ffi", + "serde", + "serde_json", "zeroize", ] diff --git a/packages/rs-unified-sdk-jni/Cargo.toml b/packages/rs-unified-sdk-jni/Cargo.toml index e2604b7ab2d..b3ea2782f9e 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/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 10396a29939..b2efa517ae3 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3218,6 +3218,443 @@ fn core_selection_strategy( } } + +// ── TXO-store reconcile transport ───────────────────────────────────── +// +// Two exports, each a serialization shim over exactly ONE platform-wallet +// call: `platform_wallet_wallet_utxos_page` and +// `platform_wallet_classify_outpoints`. The account ordering, the cursor +// semantics, the page bound and every classification rule live in +// `platform-wallet`, so the Swift host walks the identical inventory and +// gets the identical verdicts. Nothing here decides anything; it moves +// 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(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 an inventory 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. +#[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, +} + +/// One page: the rows, the opaque resume cursor (absent on the last page) +/// and whether more pages follow. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPage { + utxos: Vec, + cursor: Option, + has_more: bool, +} + +/// Where a paged inventory sweep left off — the last row's account tuple +/// and outpoint, which is exactly what `platform_wallet_wallet_utxos_page` +/// resumes from. Serialized as JSON and handed to the host as an opaque +/// string; the host only ever gives back what a previous page returned. +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UtxoPageCursor { + #[serde(flatten)] + account: UtxoAccountTuple, + txid: String, + vout: u32, +} + +/// One 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, +} + +/// One bounded page of the engine's UTXO inventory across every funds +/// account of one wallet — the source of truth +/// `PlatformWalletManager.reconcileTxoStore` diffs against the Room `txos` +/// mirror (dropped change outputs of CoinJoin-funded sends leave the +/// mirror short; the engine reloads from that mirror on restart, so an +/// un-reconciled hole becomes a fund loss). +/// +/// Paged rather than swept whole because inventory size is +/// chain-controlled: anyone who knows a watched address can keep sending +/// dust outputs to it, and a periodic full-inventory read would let them +/// decide how much a phone allocates at every SYNCED transition and every +/// 30-minute pass. Nothing bigger than one page is ever formatted, copied +/// across JNI, or parsed. +/// +/// 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. +/// +/// Rows carry the engine's own address and flags. 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_walletManagerUtxosPageJson( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + cursor: JString, + limit: jni::sys::jint, +) -> jni::sys::jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return ptr::null_mut(); + }; + // 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(); + } + }; + let parsed = serde_json::from_str::(&raw) + .ok() + .and_then(|c| { + 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, + }, + }) + }); + match parsed { + Some(r) => Some(r), + None => { + throw_sdk_exception( + env, + 1, + "malformed inventory cursor: only a cursor returned by a previous page may be handed back", + ); + return ptr::null_mut(); + } + } + }; + + let mut entries: *const platform_wallet_ffi::WalletUtxoEntryFFI = ptr::null(); + let mut count: usize = 0; + let mut has_more = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_wallet_utxos_page( + manager_handle as Handle, + wid.as_ptr(), + resume.as_ref().map_or(ptr::null(), |c| c as *const _), + page_limit, + &mut entries, + &mut count, + &mut has_more, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let mut rows: Vec = Vec::with_capacity(count); + if !entries.is_null() && count > 0 { + let items = unsafe { std::slice::from_raw_parts(entries, count) }; + for e in items { + let script: &[u8] = if e.script_pubkey.is_null() || e.script_pubkey_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(e.script_pubkey, e.script_pubkey_len) } + }; + // 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 { + unsafe { CStr::from_ptr(e.address) } + .to_string_lossy() + .into_owned() + }; + rows.push(UtxoPageRow { + account: UtxoAccountTuple::from_entry(e), + txid: hex::encode(e.outpoint.txid), + vout: e.outpoint.vout, + amount: e.value_duffs, + address, + script_hex: hex::encode(script), + height: e.height, + is_confirmed: e.is_confirmed, + is_instantlocked: e.is_instantlocked, + is_coinbase: e.is_coinbase, + is_locked: e.is_locked, + }); + } + unsafe { + platform_wallet_ffi::platform_wallet_wallet_utxos_page_free( + entries as *mut platform_wallet_ffi::WalletUtxoEntryFFI, + count, + ) + }; + } + // The cursor is the last row itself; a page with no rows has + // nowhere to resume from, and the accessor reports no more in that + // case. + let cursor_json = if has_more { + match rows.last() { + Some(last) => { + let next = UtxoPageCursor { + account: UtxoAccountTuple { + type_tag: last.account.type_tag, + standard_tag: last.account.standard_tag, + index: last.account.index, + registration_index: last.account.registration_index, + key_class: last.account.key_class, + user_identity_id: last.account.user_identity_id.clone(), + friend_identity_id: last.account.friend_identity_id.clone(), + }, + txid: last.txid.clone(), + vout: last.vout, + }; + match serde_json::to_string(&next) { + Ok(c) => Some(c), + Err(e) => { + throw_sdk_exception( + env, + 1, + &format!("inventory cursor encode failed: {e}"), + ); + return ptr::null_mut(); + } + } + } + None => None, + } + } else { + None + }; + let page = UtxoPage { + utxos: rows, + has_more: has_more && cursor_json.is_some(), + cursor: cursor_json, + }; + 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. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerClassifyOutpoints( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + queries_json: JString, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + 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 { + let Some((user_identity_id, friend_identity_id)) = q.account.identity_ids() else { + throw_sdk_exception(env, 1, "outpoint query carries a malformed identity id"); + return ptr::null_mut(); + }; + let txid: [u8; 32] = match hex::decode(&q.txid).ok().and_then(|b| b.try_into().ok()) { + Some(t) => t, + None => { + throw_sdk_exception(env, 1, "outpoint query carries a malformed txid"); + return ptr::null_mut(); + } + }; + let script = match hex::decode(&q.script_hex) { + Ok(s) => s, + Err(_) => { + throw_sdk_exception(env, 1, "outpoint query carries a malformed script"); + return ptr::null_mut(); + } + }; + scripts.push(script); + queries.push(platform_wallet_ffi::OutpointOwnershipQueryFFI { + type_tag: q.account.type_tag, + standard_tag: q.account.standard_tag, + index: q.account.index, + registration_index: q.account.registration_index, + key_class: q.account.key_class, + user_identity_id, + friend_identity_id, + outpoint: platform_wallet_ffi::OutPointFFI { txid, vout: q.vout }, + // Filled below, once `scripts` has stopped reallocating. + script_pubkey: ptr::null(), + script_pubkey_len: 0, + }); + } + 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 result = unsafe { + platform_wallet_ffi::platform_wallet_classify_outpoints( + manager_handle as Handle, + wid.as_ptr(), + queries.as_ptr(), + queries.len(), + classes.as_mut_ptr(), + ) + }; + // `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(); + } + env.byte_array_from_slice(&classes) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the /// wrong length or a JNI error. fn read_id32(env: &mut JNIEnv, arr: &JByteArray) -> Option<[u8; 32]> { From f59bb8cac16f47abac43f297421e64e1cbee544d Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 18 Sep 2026 13:30:54 -0700 Subject: [PATCH 02/28] fix(kotlin-sdk): reconcile the TXO store against the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Room `txos` mirror is write-behind with no feedback loop: a changeset that fails to deliver an owned output leaves a permanent hole. Because the engine is REBUILT from this mirror on restart, that hole graduates to a fund loss on the next launch — observed in the field as the job-flower 106.43 -> 86.33 restart drop, where a rescan nondeterministically drops the change outputs of sends funded from CoinJoin-account outputs. Two bounded passes, sharing only the watch-only contact accounts they both exclude: * the insert pass walks the ENGINE's paged inventory and inserts the rows the store lacks, one Room transaction per page. Insert-only and idempotent, so a sweep is many small commits rather than one large one. Only outputs at least `minConfirmations` deep are healed; immature holes age into the next sweep. The engine's own isConfirmed / isInstantLocked / isCoinbase travel with each row and are stamped as given — a coinbase output filed as an ordinary one would misstate maturity in the mirror the engine reloads from. * the classification pass is inverted: it pages the STORE and asks the engine about one page at a time, so neither side builds a set over a whole inventory. Every verdict is log-only. `known-uncredited` is the one class that is positive evidence (the owning account knows the funding txid, owns the script, does not hold the coin, and a funds account holds a MINED record spending it), and even that is only counted: flipping spent state is the one direction where a reconciler bug spends a coin the user still owns. Acting on it is a separate, reviewable change. Watch-only DIP-15 contact coins are excluded on both sides — they are the contact's money. The engine's inventory omits those accounts outright; the store-side check resolves ownership through both the explicit account link and the `core_addresses` projection production writes actually use, because an accountId-only check silently classifies every contact row. Triggered inside the SDK on the SPV SYNCED transition and every 30 minutes while synced, rather than left to each host: the hole it repairs becomes a fund loss on the next engine reload if any host forgets to call it. Co-Authored-By: Claude Opus 5 --- .../dashsdk/ffi/WalletManagerNative.kt | 69 ++ .../PlatformWalletPersistenceHandler.kt | 1015 +++++++++++++++-- .../dashsdk/persistence/dao/TxoDao.kt | 17 + .../dashsdk/wallet/PlatformWalletManager.kt | 139 +++ .../PlatformWalletPersistenceHandlerTest.kt | 849 ++++++++++++++ 5 files changed, 1991 insertions(+), 98 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 58351c59fbb..4c0f6c0e77c 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,75 @@ internal object WalletManagerNative { gapLimit: Int, ) + /** + * One bounded page of the engine's UTXO inventory for one wallet, + * across every funds account, as JSON + * `{"utxos":[...],"cursor":,"hasMore":}` — the + * source of truth the TXO-store reconciler + * ([PlatformWalletManager.reconcileTxoStore]) diffs against the Room + * `txos` mirror. A serialization shim over one Rust call + * (`platform_wallet_wallet_utxos_page`): account ordering, cursor + * semantics, the page bound and the address rendering all live in + * `platform-wallet`, so the Swift host walks the identical inventory. + * + * Paged, not swept whole: a wallet's UTXO count is chain-controlled + * (anyone who knows a watched address can keep sending dust to it), so + * a full-inventory read would let a remote party decide how much this + * process allocates on every SYNCED transition and every 30-minute + * pass. Pass [cursor] `null` to start, then hand back the returned + * `cursor` verbatim while `hasMore` is true — a cursor this export did + * not produce throws. [limit] caps the rows in one page; non-positive + * 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, and the engine's own `isConfirmed` / + * `isInstantLocked` / `isCoinbase` / `isLocked` flags. + */ + external fun walletManagerUtxosPageJson( + managerHandle: Long, + walletId: ByteArray, + cursor: String?, + limit: 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. + */ + external fun walletManagerClassifyOutpoints( + managerHandle: Long, + walletId: ByteArray, + queriesJson: String, + ): ByteArray? + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 2f4ad4d12d5..b8e8b5af379 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -13,6 +13,16 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.AccountSpecData import org.dashfoundation.dashsdk.ffi.ContactProfileRestoreData @@ -1038,111 +1048,878 @@ class PlatformWalletPersistenceHandler( isLocked: Boolean, ): Int = guarded { stage(walletId) { db -> - val outpoint = makeOutpoint(txid, vout) - // Ensure a parent transaction row exists (stub if missing, so - // the TXO FK holds; the real tx upsert overwrites it later). - if (db.transactionDao().getByTxid(txid) == null) { - db.transactionDao().upsert( - TransactionEntity(txid = txid, transactionData = ByteArray(0)), + upsertUtxoRow( + db, walletId, txid, vout, amount, address, scriptPubKey, + height, isCoinbase, isConfirmed, isInstantLocked, isLocked, + ) + } + 0 + } + + /** + * The single TXO-insert discipline, shared by the changeset callback + * ([onWalletChangesetUtxoAdded]) and the reconcile sweep + * ([reconcileTxos]): stub the parent transaction row so the FK holds, + * upsert the TXO preserving any existing spend linkage, then drain + * pending-input rows staged before this funding TXO existed — a 1:1 + * port of the Swift upsertUtxo drain + * (PlatformWalletPersistenceHandler.swift). A spend that arrived first + * was deferred (see onWalletChangesetTransaction); now that the funding + * output is here, resolve the claim and clear the rows so the + * UTXO-restore path won't hand this consumed output back to Rust as + * spendable. + */ + private suspend fun upsertUtxoRow( + db: DashDatabase, + walletId: ByteArray, + txid: ByteArray, + vout: Int, + amount: Long, + address: String, + scriptPubKey: ByteArray, + height: Int, + isCoinbase: Boolean, + isConfirmed: Boolean, + isInstantLocked: Boolean, + isLocked: Boolean, + // The Room account this output belongs to, when the CALLER could + // resolve it (the reconcile resolves it from the engine inventory's + // account tags). Stamped on the row so ownership survives even when + // the address projection is absent — a heal into a store that lost + // BOTH the TXO and its address row must not produce a row the + // restore loader cannot attribute (it would be skipped at the next + // mirror-reload, recreating the fund loss the heal repaired). An + // existing row's accountId always wins; changeset callbacks pass + // null and keep their address-projection behavior. + resolvedAccountId: Long? = null, + ) { + val outpoint = makeOutpoint(txid, vout) + // Ensure a parent transaction row exists (stub if missing, so + // the TXO FK holds; the real tx upsert overwrites it later). + if (db.transactionDao().getByTxid(txid) == null) { + db.transactionDao().upsert( + TransactionEntity(txid = txid, transactionData = ByteArray(0)), + ) + } + val existing = db.txoDao().getByOutpoint(outpoint) + val coreAddressId = if (address.isNotEmpty()) address else null + // A materialised coin the wallet re-delivers unspent follows the + // wallet — the mirror of the SQLite store's upsert valve, which + // holds only never-materialised placeholders. The wallet knows + // this coin, and any network-final spender of a coin it knows is + // wallet-relevant by BIP158 prevout matching, so its own scan + // re-discovers the spend; refusing the re-delivery would lock a + // real coin out forever after a reorg of the winner, and on this + // side of the FFI a row at `isSpent = true` is never restored to + // Rust again. So an UNLINKED row — a sweep hold with its stamp, or + // a legacy flag with nothing behind it — is cleared, stamp + // included. A LINKED row keeps its flag and stamp: the link is + // this store's recorded spend attribution, the pending drain + // below and the sweep pass own that transition, and a spender + // that reached a block is confirmed evidence a re-delivery never + // displaces. + val linked = existing?.spendingTxid != null + val row = TxoEntity( + outpoint = outpoint, + vout = vout, + amount = amount, + address = address, + scriptPubKey = scriptPubKey, + height = height, + isCoinbase = isCoinbase, + isConfirmed = isConfirmed, + isInstantLocked = isInstantLocked, + isLocked = isLocked, + isSpent = linked && existing!!.isSpent, + walletId = walletId, + txid = txid, + spendingTxid = existing?.spendingTxid, + spendingInputIndex = existing?.spendingInputIndex, + accountId = existing?.accountId ?: resolvedAccountId, + coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), + createdAt = existing?.createdAt ?: java.util.Date(), + lastUpdated = now(), + supersededByTxid = if (linked) existing!!.supersededByTxid else null, + ) + db.txoDao().upsert(row) + // Drain any pending-input rows staged before this funding TXO + // existed — a port of the Swift `upsertUtxo` drain + // (PlatformWalletPersistenceHandler.swift). A spend that arrived + // first was deferred (see onWalletChangesetTransaction); now that + // the funding output is here, resolve the claim and clear the rows + // so the UTXO-restore path won't hand this consumed output back to + // Rust as spendable. + val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) + if (pending.isNotEmpty()) { + // A tombstone outranks every ordinary row regardless of age: + // ordinary rows are competing *observations*, a tombstone is + // the sweep's settled verdict that its winner consumed this + // coin. Prefer the tombstone tagged with the delivering + // wallet; failing that any tombstone on the outpoint still + // holds — the stamp is a txid fact, not a per-wallet one. + val tombstones = pending.filter { it.isSweptTombstone } + val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } + .maxByOrNull { it.createdAt } + ?: tombstones.maxByOrNull { it.createdAt } + if (tombstone != null) { + // A drained tombstone STAMPS, it never mints a spender + // link: the winner need not have its own `transactions` + // row, and a link would make the coin non-releasable + // (the release pass frees stamped, unlinked rows) when a + // later sweep proves the winner never took it. The + // existing link, if any, is carried as it was. + db.txoDao().upsert( + row.copy( + isSpent = true, + supersededByTxid = tombstone.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + // Competing ordinary observations: a network-final spender + // outranks a newer mempool one (its row is the settled + // claim the link guard protects); among equals the newest + // wins, as before (reorg / double-spend: newest wins). + val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } + val (chosen, spending) = ranked.maxWithOrNull( + compareBy>( + { it.second?.context ?: 0 }, + { it.first.createdAt }, + ), + )!! + val spendingContext = spending?.context ?: 0 + val keepExistingLink = + keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) + db.txoDao().upsert( + linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), ) } - val existing = db.txoDao().getByOutpoint(outpoint) - val coreAddressId = if (address.isNotEmpty()) address else null - // A materialised coin the wallet re-delivers unspent follows the - // wallet — the mirror of the SQLite store's upsert valve, which - // holds only never-materialised placeholders. The wallet knows - // this coin, and any network-final spender of a coin it knows is - // wallet-relevant by BIP158 prevout matching, so its own scan - // re-discovers the spend; refusing the re-delivery would lock a - // real coin out forever after a reorg of the winner, and on this - // side of the FFI a row at `isSpent = true` is never restored to - // Rust again. So an UNLINKED row — a sweep hold with its stamp, or - // a legacy flag with nothing behind it — is cleared, stamp - // included. A LINKED row keeps its flag and stamp: the link is - // this store's recorded spend attribution, the pending drain - // below and the sweep pass own that transition, and a spender - // that reached a block is confirmed evidence a re-delivery never - // displaces. - val linked = existing?.spendingTxid != null - val row = TxoEntity( - outpoint = outpoint, - vout = vout, - amount = amount, - address = address, - scriptPubKey = scriptPubKey, - height = height, - isCoinbase = isCoinbase, - isConfirmed = isConfirmed, - isInstantLocked = isInstantLocked, - isLocked = isLocked, - isSpent = linked && existing!!.isSpent, - walletId = walletId, - txid = txid, - spendingTxid = existing?.spendingTxid, - spendingInputIndex = existing?.spendingInputIndex, - accountId = existing?.accountId, - coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), - createdAt = existing?.createdAt ?: java.util.Date(), - lastUpdated = now(), - supersededByTxid = if (linked) existing!!.supersededByTxid else null, + for (p in pending) db.documentDao().deletePendingInput(p) + } + } + + /** + * One row of the engine's paged UTXO inventory, exactly as + * `walletManagerUtxosPageJson` serializes it (rs-unified-sdk-jni + * `UtxoPageRow`, serde on that side, kotlinx on this one). The contract + * is typed on BOTH ends so a renamed or missing key fails the decode + * loudly instead of healing a row with a defaulted owner or a zero + * amount into the mirror the engine reloads from. The account tuple + * (`typeTag`…`friendIdentityId`) is the routing context [fetchAccount] + * resolves the owning Room account by; the DashPay identity halves are + * absent on every non-DashPay account. + */ + @Serializable + data class EngineUtxoRow( + val typeTag: Int, + val standardTag: Int = 0, + val index: Int = 0, + val registrationIndex: Int = 0, + val keyClass: Int = 0, + val userIdentityId: String? = null, + val friendIdentityId: String? = null, + val txid: String, + val vout: Int, + val amount: Long, + val address: String = "", + val scriptHex: String = "", + val height: Int, + /** 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, + ) + + /** + * One store row handed to `walletManagerClassifyOutpoints`: 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. + */ + @Serializable + data class OutpointQuery( + val typeTag: Int, + val standardTag: Int = 0, + val index: Int = 0, + val registrationIndex: Int = 0, + val keyClass: Int = 0, + val userIdentityId: String? = null, + val friendIdentityId: String? = null, + val txid: String, + val vout: Int, + val 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, + ) + + /** + * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] + * are the healed holes; a non-zero value after a completed sync means + * a changeset failed to deliver an owned output (the + * CoinJoin-funded-send change-drop class) and would have become a + * fund-loss on the next engine reload from this store. + */ + data class TxoReconcileReport( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + /** Healed TXOs whose pre-existing record's netAmount MAY be short by + * the healed amount. LOG-ONLY: the record can already carry the + * corrected net (a corrective callback racing this sweep), and + * blind addition double-credits. The event pipeline owns net + * correctness. */ + val netAmountSuspects: Int, + /** Healed rows whose owning Room account could not be resolved from + * the inventory's account tuple — ownership rides on the address + * projection alone, and if that row is also missing the healed TXO + * will not survive the next mirror-reload. */ + val healedUnowned: Int = 0, + val skippedImmature: Int, + val skippedNoAddress: Int, + /** Store rows marked unspent for which the engine holds a MINED + * record spending the outpoint — the lost-spend-update class + * (dashpay/platform#4425), on durable evidence rather than a bare + * absence. LOG-ONLY all the same: flipping spent state is the one + * direction where a reconciler bug spends a coin the user still + * owns, and this pass is insert-only by contract. */ + val wouldFlipSpent: Int = 0, + val wouldFlipSpentDuffs: Long = 0, + /** Store rows marked unspent the engine has no verdict for — + * swept/abandoned residue (pre-rust-dashcore#971 stores), a + * funding transaction this session never processed, or an engine + * gap. After a restart the engine's finalized set is empty, so + * this is the ordinary answer rather than the alarming one. + * LOG-ONLY: counted and named in the log, never removed. */ + val wouldRemove: Int = 0, + val wouldRemoveDuffs: Long = 0, + /** Store rows filed under an account whose pools do not monitor the + * row's script — the engine could never have credited the coin + * there, so this is a store attribution error rather than a + * missing coin. LOG-ONLY: counted, never re-filed. */ + val misattributed: Int = 0, + val misattributedDuffs: Long = 0, + /** Unspent store rows whose owning account could not be resolved + * from either the explicit link or the address projection. They + * carry no claim for the engine to check, so they are counted + * rather than asked about — and the same missing link is what + * would stop such a row surviving the next mirror-reload. */ + val unresolvedAccount: Int = 0, + /** Watch-only DIP-15 contact coins excluded on both sides — engine + * rows the insert pass refused to heal as ours, and unspent store + * rows the reverse pass did not classify (the engine's own accounts + * never report them, so their absence is expected, not + * divergence). */ + val skippedForeign: Int = 0, + /** Store rows marked spent for a coin the engine lists UNSPENT — + * either a released coin from a swept transaction whose release + * event a pre-rust-dashcore#971 build lost, or a live spend the + * store wrote moments before the engine settled. The two cannot + * be told apart safely, so this is LOG-ONLY: un-marking a coin + * mid-payment would let the wallet double-spend it. */ + val stuckSpent: Int = 0, + val stuckSpentDuffs: Long = 0, + /** Transport reads that failed mid-sweep — an engine inventory page + * after the first, or an outpoint-classification batch, that came + * back null. The pass stops at the first one; whatever it already + * applied stands (insert-only, idempotent) and the rest waits for + * the next cadence tick. A persistently non-zero value means the + * sweep never finishes, so the report's other counters are a + * partial view. */ + val transportFailures: Int = 0, + ) + + /** The insert pass's half of a [TxoReconcileReport]. */ + private data class HealPass( + val engineUtxos: Int, + val inserted: Int, + val insertedDuffs: Long, + val netAmountSuspects: Int, + val healedUnowned: Int, + val skippedImmature: Int, + val skippedNoAddress: Int, + val skippedForeign: Int, + val transportFailures: Int, + ) + + /** The classification pass's half of a [TxoReconcileReport]. */ + private data class ClassifyPass( + val wouldFlipSpent: Int, + val wouldFlipSpentDuffs: Long, + val wouldRemove: Int, + val wouldRemoveDuffs: Long, + val stuckSpent: Int, + val stuckSpentDuffs: Long, + val misattributed: Int, + val misattributedDuffs: Long, + val unresolvedAccount: Int, + val skippedForeign: Int, + val transportFailures: Int, + ) + + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop: a changeset that fails to + * deliver an owned output leaves a permanent hole, and because the + * engine is REBUILT from this mirror on restart (buildUtxoRestoreData), + * the hole graduates to a fund-loss on the next launch. Observed in the + * field as the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs. + * + * Two passes, each bounded, sharing only the set of watch-only contact + * accounts they both exclude: + * + * * [healMissingTxos] pages the ENGINE ([engineUtxoPage]: `cursor` null + * to start, then the cursor the previous page returned while its + * `hasMore` is true) and inserts the rows the store lacks, one Room + * transaction per page. Insert-only and idempotent, so a sweep is + * many small commits rather than one giant one — that is the point. + * * [classifyStoreRows] pages the STORE and asks [classifyOutpoints] + * about one page at a time (0 unknown, 1 unspent, 2 spent). It + * writes nothing; every verdict is log-only. + * + * Neither side ever holds a set over a whole inventory: a wallet's UTXO + * count is chain-controlled (anyone who knows a watched address can + * keep sending dust to it), so a pass that materialized it would hand a + * remote party control over how much this process allocates on every + * SYNCED transition and every cadence tick. + * + * Rows the mirror holds and the engine lacks are LEFT ALONE (the mirror + * may legitimately be ahead — a live spend marks rows spent here before + * the engine's map settles — and it also carries watch-only contact + * outputs the engine's own accounts never report). Spent-state repair + * is deliberately out of scope. + * + * [minConfirmations] (default 100): the engine snapshot cannot carry + * `isCoinbase`/`isInstantLocked`, so inserted rows get + * `isConfirmed=true` and both flags false — inert for any output at or + * beyond coinbase maturity, which the gate guarantees. Fresher holes + * age into a later sweep. + * + * `netAmount` is reported, never repaired: a record born blind to one + * of its own outputs persisted a net short by exactly that output's + * value, but the record may equally have been corrected already by a + * callback racing this sweep, and blind addition double-credits. + * + * Returns null when the FIRST engine page is unavailable — there is + * nothing to reconcile against, so there is no report to make. A page + * or classification batch failing later truncates the sweep instead, + * which the report's `transportFailures` records. A page that does not + * decode as an [EngineUtxoPage] is a contract violation between the JNI + * emitter and this reader, and it throws rather than being absorbed. + * + * Must NOT be called from the handler's own [dispatcher] (it takes + * [callbackExclusion] and runs Room transactions). + */ + suspend fun reconcileTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + ): TxoReconcileReport? { + val limit = pageSize.coerceAtLeast(1) + + // Watch-only DIP-15 contact (external) accounts, resolved once up + // front because BOTH passes need the exclusion and the account set + // does not move under a sweep. The engine's UTXO inventory export + // includes these accounts' coins — it tracks them to show payments + // TO contacts — but they are the CONTACT's money and must never be + // healed into the store as ours. Before this check lived on the + // insert pass, a fresh restore's post-backfill reconcile healed + // every contact-payment coin into the store (12 rows / 0.05692493 + // tDASH on the large-wallet validation run of 2026-08-25) while the + // reverse pass — the only place the exclusion existed — dutifully + // counted the same rows as foreign. + val foreignAccountIds = database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + + val heal = healMissingTxos( + walletId, tipHeight, minConfirmations, limit, foreignAccountIds, engineUtxoPage, + ) ?: return null + val classify = classifyStoreRows(walletId, limit, foreignAccountIds, classifyOutpoints) + + val report = TxoReconcileReport( + engineUtxos = heal.engineUtxos, + inserted = heal.inserted, + insertedDuffs = heal.insertedDuffs, + netAmountSuspects = heal.netAmountSuspects, + healedUnowned = heal.healedUnowned, + skippedImmature = heal.skippedImmature, + skippedNoAddress = heal.skippedNoAddress, + wouldFlipSpent = classify.wouldFlipSpent, + wouldFlipSpentDuffs = classify.wouldFlipSpentDuffs, + wouldRemove = classify.wouldRemove, + wouldRemoveDuffs = classify.wouldRemoveDuffs, + misattributed = classify.misattributed, + misattributedDuffs = classify.misattributedDuffs, + unresolvedAccount = classify.unresolvedAccount, + skippedForeign = heal.skippedForeign + classify.skippedForeign, + stuckSpent = classify.stuckSpent, + stuckSpentDuffs = classify.stuckSpentDuffs, + transportFailures = heal.transportFailures + classify.transportFailures, + ) + if (report.inserted > 0 || report.wouldFlipSpent > 0 || report.wouldRemove > 0 || + report.stuckSpent > 0 || report.misattributed > 0 || + report.unresolvedAccount > 0 || report.transportFailures > 0 + ) { + Log.w( + TAG, + "txos reconcile: healed ${report.inserted} missing TXO(s) " + + "(${report.insertedDuffs} duffs), " + + "${report.netAmountSuspects} netAmount suspect(s) (log-only), " + + "healedUnowned=${report.healedUnowned}, " + + "wouldFlipSpent=${report.wouldFlipSpent} " + + "(${report.wouldFlipSpentDuffs} duffs, log-only), " + + "wouldRemove=${report.wouldRemove} " + + "(${report.wouldRemoveDuffs} duffs, log-only), " + + "stuckSpent=${report.stuckSpent} " + + "(${report.stuckSpentDuffs} duffs, log-only), " + + "misattributed=${report.misattributed} " + + "(${report.misattributedDuffs} duffs, log-only), " + + "unresolvedAccount=${report.unresolvedAccount}, " + + "engine=${report.engineUtxos} " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} " + + "foreign=${report.skippedForeign} " + + "transportFailures=${report.transportFailures} — a non-zero " + + "heal after a completed sync means a changeset dropped an owned output", ) - db.txoDao().upsert(row) - // Drain any pending-input rows staged before this funding TXO - // existed — a port of the Swift `upsertUtxo` drain - // (PlatformWalletPersistenceHandler.swift). A spend that arrived - // first was deferred (see onWalletChangesetTransaction); now that - // the funding output is here, resolve the claim and clear the rows - // so the UTXO-restore path won't hand this consumed output back to - // Rust as spendable. - val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) - if (pending.isNotEmpty()) { - // A tombstone outranks every ordinary row regardless of age: - // ordinary rows are competing *observations*, a tombstone is - // the sweep's settled verdict that its winner consumed this - // coin. Prefer the tombstone tagged with the delivering - // wallet; failing that any tombstone on the outpoint still - // holds — the stamp is a txid fact, not a per-wallet one. - val tombstones = pending.filter { it.isSweptTombstone } - val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } - .maxByOrNull { it.createdAt } - ?: tombstones.maxByOrNull { it.createdAt } - if (tombstone != null) { - // A drained tombstone STAMPS, it never mints a spender - // link: the winner need not have its own `transactions` - // row, and a link would make the coin non-releasable - // (the release pass frees stamped, unlinked rows) when a - // later sweep proves the winner never took it. The - // existing link, if any, is carried as it was. - db.txoDao().upsert( - row.copy( - isSpent = true, - supersededByTxid = tombstone.spendingTxid, - lastUpdated = now(), - ), - ) - } else { - // Competing ordinary observations: a network-final spender - // outranks a newer mempool one (its row is the settled - // claim the link guard protects); among equals the newest - // wins, as before (reorg / double-spend: newest wins). - val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } - val (chosen, spending) = ranked.maxWithOrNull( - compareBy>( - { it.second?.context ?: 0 }, - { it.first.createdAt }, - ), - )!! - val spendingContext = spending?.context ?: 0 - val keepExistingLink = - keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) - db.txoDao().upsert( - linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), + } else { + Log.i( + TAG, + "txos reconcile: mirror consistent (${report.engineUtxos} engine UTXOs, " + + "skipped immature=${report.skippedImmature} " + + "noAddress=${report.skippedNoAddress} foreign=${report.skippedForeign})", + ) + } + return report + } + + /** + * Insert pass of [reconcileTxos]: one engine page at a time, one Room + * transaction each. Each page is fetched OUTSIDE the exclusion lock — + * the fetch is a native call into the engine, and the lock exists to + * keep changeset callbacks out of our writes, not out of the engine. + * Returns null when the first page is unavailable (nothing to reconcile + * against); a later null page truncates the pass and is counted. + */ + private suspend fun healMissingTxos( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int, + limit: Int, + foreignAccountIds: Set, + engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, + ): HealPass? { + var engineUtxos = 0 + var inserted = 0 + var insertedDuffs = 0L + var netAmountSuspects = 0 + var healedUnowned = 0 + var skippedImmature = 0 + var skippedNoAddress = 0 + var skippedForeign = 0 + var transportFailures = 0 + + var cursor: String? = null + while (true) { + val pageJson = engineUtxoPage(cursor, limit) + if (pageJson == null) { + // No first page: nothing to reconcile against, no report. + if (cursor == null) return null + // The transport died mid-sweep. Everything already applied + // stands (insert-only, idempotent); the rest waits for the + // next cadence tick. + transportFailures++ + break + } + // Strict decode: an unknown key, a missing required field or a + // mistyped value throws — the row would otherwise be healed with + // a defaulted owner or a zero amount. + val page = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + engineUtxos += page.utxos.size + + if (page.utxos.isNotEmpty()) { + callbackExclusion.withLock { + database.withTransaction { + for (row in page.utxos) { + when (healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds)) { + HealOutcome.SKIPPED_IMMATURE -> skippedImmature++ + HealOutcome.SKIPPED_NO_ADDRESS -> skippedNoAddress++ + HealOutcome.SKIPPED_FOREIGN -> skippedForeign++ + HealOutcome.ALREADY_PRESENT -> {} + HealOutcome.HEALED -> { + inserted++ + insertedDuffs += row.amount + } + HealOutcome.HEALED_UNOWNED -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + } + HealOutcome.HEALED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + netAmountSuspects++ + } + HealOutcome.HEALED_UNOWNED_NET_SUSPECT -> { + inserted++ + insertedDuffs += row.amount + healedUnowned++ + netAmountSuspects++ + } + } + } + } + } + } + if (!page.hasMore || page.cursor == null) break + cursor = page.cursor + } + return HealPass( + engineUtxos = engineUtxos, + inserted = inserted, + insertedDuffs = insertedDuffs, + netAmountSuspects = netAmountSuspects, + healedUnowned = healedUnowned, + skippedImmature = skippedImmature, + skippedNoAddress = skippedNoAddress, + skippedForeign = skippedForeign, + transportFailures = transportFailures, + ) + } + + private enum class HealOutcome { + SKIPPED_IMMATURE, + SKIPPED_NO_ADDRESS, + SKIPPED_FOREIGN, + ALREADY_PRESENT, + HEALED, + HEALED_UNOWNED, + HEALED_NET_SUSPECT, + HEALED_UNOWNED_NET_SUSPECT, + } + + /** + * Apply one engine inventory row to the store (inside the caller's + * exclusion lock and Room transaction). Provable-only discipline: it + * neither mutates nor suppresses on guesswork. + */ + private suspend fun healEngineRow( + walletId: ByteArray, + row: EngineUtxoRow, + tipHeight: Int, + minConfirmations: Int, + foreignAccountIds: Set, + ): HealOutcome { + if (row.height <= 0 || tipHeight - row.height + 1 < minConfirmations) { + return HealOutcome.SKIPPED_IMMATURE + } + if (row.address.isEmpty()) return HealOutcome.SKIPPED_NO_ADDRESS + // The inventory tags every UTXO with its owning account tuple. The + // tag is the authoritative foreign check — a watch-only external + // account's coin is the CONTACT's money whether or not its address + // row survived persistence. The address-based check (through + // core_addresses.accountId, the same second path rowIsForeign uses + // for store rows) stays as a fallback. An unresolvable address is + // NOT provably foreign — those proceed. + if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) return HealOutcome.SKIPPED_FOREIGN + val addressOwner = database.coreAddressDao().getByAddress(row.address)?.accountId + if (addressOwner != null && addressOwner in foreignAccountIds) { + return HealOutcome.SKIPPED_FOREIGN + } + val txid = row.txid.hexToByteArray() + require(txid.size == 32) { "engine inventory row carries a ${txid.size}-byte txid" } + if (database.txoDao().getByOutpoint(makeOutpoint(txid, row.vout)) != null) { + return HealOutcome.ALREADY_PRESENT + } + // Resolve the Room account from the tuple and stamp it on the + // healed row. Ownership must not depend on the address projection: + // the two things persistence loses together are the TXO and its + // address row, and a healed row with neither link is skipped by + // the restore loader at the next mirror-reload — recreating the + // fund loss the heal repaired. + val ownerAccountId = fetchAccount( + database, walletId, row.typeTag, row.index, row.standardTag, + row.registrationIndex, row.keyClass, + row.userIdentityId?.hexToByteArray() ?: ByteArray(32), + row.friendIdentityId?.hexToByteArray() ?: ByteArray(32), + )?.id + if (ownerAccountId == null) { + // Heal anyway — the address projection may still attribute it — + // but surface the unresolved owner: if the address row is also + // gone, this row will not survive the next mirror-reload. + Log.w( + TAG, + "txos reconcile: healing TXO with UNRESOLVED account " + + "(typeTag=${row.typeTag} index=${row.index} address=${row.address}) — " + + "ownership rides on the address projection alone", + ) + } + upsertUtxoRow( + database, walletId, txid, row.vout, row.amount, row.address, + row.scriptHex.hexToByteArray(), row.height, + isCoinbase = row.isCoinbase, + isConfirmed = row.isConfirmed, + isInstantLocked = row.isInstantlocked, + isLocked = row.isLocked, + resolvedAccountId = ownerAccountId, + ) + // netAmount is NOT mutated here. The record's net may already be + // correct (a corrective record callback can land while its TXO + // delivery races this sweep), and adding the healed amount to an + // already-corrected net double-credits. The event pipeline owns + // net correctness; this pass only reports the suspicion. + val priorTx = database.transactionDao().getByTxid(txid) + val netSuspect = priorTx != null && priorTx.transactionData.isNotEmpty() + if (netSuspect) { + Log.w( + TAG, + "txos reconcile: healed TXO ${row.txid}:${row.vout} (${row.amount} duffs) " + + "has a pre-existing record whose netAmount may be short by that " + + "amount — LOG-ONLY, storedNet=${priorTx?.netAmount}", + ) + } + return when { + ownerAccountId == null && netSuspect -> HealOutcome.HEALED_UNOWNED_NET_SUSPECT + ownerAccountId == null -> HealOutcome.HEALED_UNOWNED + netSuspect -> HealOutcome.HEALED_NET_SUSPECT + else -> HealOutcome.HEALED + } + } + + /** + * Classification pass of [reconcileTxos] (the widened scope from the + * #4425 / pre-#971 review). Inverted relative to the insert pass — it + * pages the STORE and asks the engine about each page — so that neither + * side has to hold a set over a whole inventory. + * + * Every query carries the account tuple the store files the coin under + * and the script the store recorded. Those 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 and why a row + * whose owning account this store cannot resolve is counted rather + * than asked about. + * + * Read-only by construction: it writes nothing, so it runs outside both + * the exclusion lock and any transaction. A row a concurrent callback + * moves under it is at worst a stale log line, and every verdict here + * is log-only anyway. + * + * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. + * Production changeset writes leave txos.accountId null and route + * ownership through coreAddressId -> core_addresses.accountId, so the + * exclusion resolves BOTH paths — an accountId-only check silently + * classifies every contact row. + */ + private suspend fun classifyStoreRows( + walletId: ByteArray, + limit: Int, + foreignAccountIds: Set, + classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + ): ClassifyPass { + var wouldFlipSpent = 0 + var wouldFlipSpentDuffs = 0L + var wouldRemove = 0 + var wouldRemoveDuffs = 0L + var stuckSpent = 0 + var stuckSpentDuffs = 0L + var misattributed = 0 + var misattributedDuffs = 0L + var unresolvedAccount = 0 + var skippedForeign = 0 + var transportFailures = 0 + + // Owning Room account for a store row: the explicit link first, + // then the address projection production writes actually use. + suspend fun ownerAccount( + row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity, + ): org.dashfoundation.dashsdk.persistence.entities.AccountEntity? { + row.accountId?.let { return database.accountDao().getById(it) } + val addr = row.coreAddressId ?: return null + val ownerId = database.coreAddressDao().getByAddress(addr)?.accountId ?: return null + return database.accountDao().getById(ownerId) + } + // An empty BLOB sorts before every real outpoint, so this starts at + // the first row. + var after = ByteArray(0) + while (true) { + val storeRows = database.txoDao().pageByWallet(walletId, after, limit) + if (storeRows.isEmpty()) break + after = storeRows.last().outpoint + + // Rows worth asking the engine about. A row still mid-insert + // (no txid yet) is not classifiable, a foreign row's absence + // from the engine is expected rather than divergence — counted, + // as before, only when it is unspent — and a row whose owning + // account cannot be resolved carries no claim to check. + val classifiable = + ArrayList( + storeRows.size, + ) + val queries = ArrayList(storeRows.size) + for (row in storeRows) { + if (row.txid == null || row.outpoint.size != OUTPOINT_BYTES) continue + val owner = ownerAccount(row) + if (owner != null && owner.id in foreignAccountIds) { + if (!row.isSpent) skippedForeign++ + continue + } + if (owner == null) { + // No account to name in the query, so the engine has + // nothing to check the claim against. Reported rather + // than guessed: the same missing link is what makes a + // healed row fail to survive the next mirror-reload. + if (!row.isSpent) unresolvedAccount++ + continue + } + classifiable.add(row) + queries.add( + 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 = row.txid.toHex(), + vout = row.vout, + scriptHex = row.scriptPubKey.toHex(), + ), + ) + } + if (classifiable.isEmpty()) continue + + val verdicts = classifyOutpoints( + engineJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(OutpointQuery.serializer()), queries), + ) + if (verdicts == null || verdicts.size != classifiable.size) { + // No verdicts, no classification. Log-only either way, so + // the sweep stops rather than guessing. + transportFailures++ + break + } + for ((i, row) in classifiable.withIndex()) { + val verdict = verdicts[i] + val key = "${row.txid?.toHex()}:${row.vout}" + if (verdict == OUTPOINT_CLASS_NOT_OWNED) { + // The account this 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 — and the one verdict that says nothing + // about spentness, so it is reported for spent and + // unspent rows alike. + misattributed++ + misattributedDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row filed under an account that does " + + "not own its script outpoint=$key amount=${row.amount} " + + "accountId=${row.accountId} — LOG-ONLY, not re-filed", ) + continue + } + if (row.isSpent) { + // Rows marked spent for coins the engine still lists + // unspent. Either lost-release residue (pre-#971) or a + // live spend racing the engine — never un-marked, only + // reported: un-marking a coin mid-payment would let the + // wallet double-spend it. + if (verdict == OUTPOINT_CLASS_UNSPENT) { + stuckSpent++ + stuckSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row spent but engine lists it " + + "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + + "(lost release, or a live spend racing the engine)", + ) + } + continue + } + when (verdict) { + OUTPOINT_CLASS_UNSPENT -> {} + OUTPOINT_CLASS_KNOWN_UNCREDITED -> { + // Lost spend update (#4425), on durable evidence: + // this verdict requires the owning account to know + // the funding txid, to recognise the script, not to + // hold the coin, AND a funds account to hold a MINED + // record spending the outpoint — so unlike the old + // spent-set answer it cannot be a mempool artifact. + // Still LOG-ONLY here: flipping spent state is the + // one direction where a reconciler bug spends a + // coin the user still owns, and this pass is + // insert-only by contract. Acting on it is a + // separate, reviewable change. + wouldFlipSpent++ + wouldFlipSpentDuffs += row.amount + Log.w( + TAG, + "txos reconcile: store row unspent but the engine holds a " + + "mined record spending it outpoint=$key " + + "amount=${row.amount} — LOG-ONLY, not flipped", + ) + } + else -> { + // OUTPOINT_CLASS_UNKNOWN: the engine has no opinion. + // Swept/abandoned residue (pre-#971 stores), a + // funding transaction this session never processed, + // or an engine gap — and after a restart the + // finalized set is empty, so this is the common + // answer rather than the alarming one. Deliberately + // LOG-ONLY: removal by reconciliation is the one + // direction where a bug destroys user-visible data. + wouldRemove++ + wouldRemoveDuffs += row.amount + Log.w( + TAG, + "txos reconcile: engine has no verdict for store row " + + "outpoint=$key amount=${row.amount} — ambiguous " + + "(swept/abandoned residue, funding not seen this " + + "session, or a dropped engine record) — LOG-ONLY, " + + "not removed", + ) + } } - for (p in pending) db.documentDao().deletePendingInput(p) } } - 0 + return ClassifyPass( + wouldFlipSpent = wouldFlipSpent, + wouldFlipSpentDuffs = wouldFlipSpentDuffs, + wouldRemove = wouldRemove, + wouldRemoveDuffs = wouldRemoveDuffs, + stuckSpent = stuckSpent, + stuckSpentDuffs = stuckSpentDuffs, + misattributed = misattributed, + misattributedDuffs = misattributedDuffs, + unresolvedAccount = unresolvedAccount, + skippedForeign = skippedForeign, + transportFailures = transportFailures, + ) } override fun onWalletChangesetUtxoSpent( @@ -4014,6 +4791,48 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { + /** `AccountTypeTagFFI::DashpayExternalAccount` — watch-only DIP-15 + * contact accounts the engine's inventories never report. */ + internal const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 + + /** Bytes of a `txos.outpoint` key: 32-byte txid (wire order) plus + * the vout as a little-endian `Int`. Also the wire format of the + * reconcile's outpoint-classification batch. */ + internal const val OUTPOINT_BYTES = 36 + + /** + * Rows per page in both directions of [reconcileTxos] — engine + * UTXOs coming in, store rows going out for classification. + * + * The number itself is not delicate; that there IS one is the + * point. Inventory size is chain-controlled, so an unpaged sweep + * would let anyone who knows a watched address decide how much a + * phone allocates at every SYNCED transition and cadence tick. + */ + const val TXO_RECONCILE_PAGE_SIZE = 512 + + /** + * Decoder for the engine inventory transport. Deliberately the + * strict default (`ignoreUnknownKeys = false`, no coercion): the JNI + * emitter and [EngineUtxoPage] are one contract, and a drift between + * them must fail the decode, not heal a defaulted row. + */ + private val engineJson = Json + + /** [reconcileTxos] classification verdicts, mirroring + * `platform_wallet::manager::accessors::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 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index ac484917836..67b9b6de722 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -21,6 +21,23 @@ interface TxoDao { @Query("SELECT * FROM txos WHERE walletId = :walletId") fun observeByWallet(walletId: ByteArray): Flow> + /** + * One outpoint-ordered page of a wallet's TXOs, for a pass that must + * not hold the whole table at once (the store reconcile's reverse + * half). Pass an empty [after] to start — an empty BLOB sorts before + * every real 36-byte outpoint — then the previous page's last + * `outpoint` to continue. + * + * `outpoint` is the primary key, so the order is an index walk and the + * cursor is exact: no row can be visited twice or skipped because + * another one was inserted or deleted mid-sweep. + */ + @Query( + "SELECT * FROM txos WHERE walletId = :walletId AND outpoint > :after " + + "ORDER BY outpoint LIMIT :limit", + ) + suspend fun pageByWallet(walletId: ByteArray, after: ByteArray, limit: Int): List + /** WalletMemoryExplorer: `txo.walletId == walletId && txo.isSpent == false`. */ @Query("SELECT * FROM txos WHERE walletId = :walletId AND isSpent = 0") fun observeUnspentByWallet(walletId: ByteArray): Flow> diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index bce8324ef49..f881371fa91 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 @@ -1257,6 +1257,81 @@ class PlatformWalletManager( mapNativeErrors { DashpayNative.walletManagerAccountBalances(managerHandle, walletId) } } + /** + * Reconcile the Room `txos` mirror against the engine's live UTXO + * inventory, healing rows a changeset failed to deliver. The mirror is + * write-behind with no other feedback loop, and the engine is REBUILT + * from it on restart — an unhealed hole becomes a fund-loss on the next + * launch (the job-flower 106.43→86.33 restart drop: rescan + * nondeterministically drops the change outputs of sends funded from + * CoinJoin-account outputs). Insert-only; never flips spent state or + * deletes. + * + * Both directions of the sweep are paged, so neither this process nor + * the engine ever holds a whole wallet's inventory: UTXO counts are + * chain-controlled, and a periodic full-inventory read would let anyone + * who knows a watched address decide how much a phone allocates. See + * [PlatformWalletPersistenceHandler.reconcileTxos]. + * + * Call it after the L1 scan settles and again on a slow cadence; + * [tipHeight] is the synced chain height — only outputs at least + * [minConfirmations] deep are healed (immature holes age into the next + * sweep). Returns null when the engine inventory read failed at the + * FIRST page: there is nothing to reconcile against, so there is no + * report to make. A page or classification batch failing later + * truncates the sweep instead, which the report's `transportFailures` + * records. Native failures surface through [mapNativeErrors] as + * exceptions; both transport lambdas turn them into the null the + * handler's truncate-and-report contract is written against (and log + * them), so a mid-sweep fault yields a partial report rather than + * discarding the sweep. A malformed page is NOT absorbed: the handler's + * strict decode throws, and that propagates. + */ + suspend fun reconcileTxoStore( + walletId: ByteArray, + tipHeight: Int, + minConfirmations: Int = 100, + ): PlatformWalletPersistenceHandler.TxoReconcileReport? = + persistenceHandler.reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + engineUtxoPage = { cursor, limit -> + withContext(Dispatchers.IO) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, cursor, limit, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: engine inventory page failed (cursor=$cursor)", + t, + ) + }.getOrNull() + } + }, + classifyOutpoints = { queriesJson -> + withContext(Dispatchers.IO) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, queriesJson, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: outpoint classification failed", + t, + ) + }.getOrNull() + } + }, + ) + /** * Refresh the persisted DashPay payment history for one identity: * one FFI read (`managed_identity_get_dashpay_payments`) + one Room @@ -2160,6 +2235,7 @@ class PlatformWalletManager( if (running) { runCatching { spvSyncProgress() }.getOrNull()?.let { next -> if (next != _spvProgress.value) _spvProgress.value = next + maybeReconcileTxoStores(next) } runCatching { spvTipUnixSeconds() }.getOrNull()?.let { tip -> if (tip != _spvTipUnixSeconds.value) _spvTipUnixSeconds.value = tip @@ -2173,6 +2249,61 @@ class PlatformWalletManager( } } + private var lastTxoReconcileAtMs = 0L + private var txoReconcileWasSynced = false + + /** + * SDK-internal trigger for [reconcileTxoStore] — runs on the SYNCED + * transition of the SPV progress poll and again every + * [TXO_RECONCILE_INTERVAL_MS] while synced, for every loaded wallet. + * Lives here rather than in the host apps so Android and iOS-parity + * hosts both get the heal without wiring anything: the mirror hole it + * repairs (rescan dropping change outputs of CoinJoin-funded sends) + * becomes a fund-loss on the next engine reload if any host forgets + * to call it. Failures are logged and re-tried on the next cadence + * tick — never allowed to kill the progress poll. + */ + private fun maybeReconcileTxoStores(progress: SpvSyncProgressData) { + val synced = progress.overallState == SpvSyncState.SYNCED + val transitioned = synced && !txoReconcileWasSynced + txoReconcileWasSynced = synced + if (!synced) return + val now = System.currentTimeMillis() + if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return + // The filter sub-phase is the wallet-relevant height; if dash-spv + // reports SYNCED without one (filter sync disabled, or the phase + // dropped after completion) fall back to the header tip rather than + // silently skipping the heal this exists to deliver. + val tipHeight = ( + progress.filters?.currentHeight?.takeIf { it > 0L } + ?: progress.headers?.currentHeight + ?: 0L + ).toInt() + if (tipHeight <= 0) { + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile skipped: SYNCED progress carries no tip height " + + "(filters=${progress.filters?.currentHeight} headers=${progress.headers?.currentHeight})", + ) + return + } + val walletIds = wallets.value.values.map { it.walletId } + if (walletIds.isEmpty()) return + lastTxoReconcileAtMs = now + scope.launch { + for (walletId in walletIds) { + runCatching { reconcileTxoStore(walletId, tipHeight) } + .onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile failed for wallet ${walletId.toHex()}", + t, + ) + } + } + } + } + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2678,6 +2809,14 @@ class PlatformWalletManager( /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L + /** + * Cadence of the steady-state TXO-store reconcile + * ([maybeReconcileTxoStores]) while SPV reports SYNCED. The + * SYNCED transition itself always triggers a pass regardless of + * this interval. + */ + const val TXO_RECONCILE_INTERVAL_MS = 30 * 60 * 1_000L + /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ const val PWFFI_INVALID_PARAMETER = 2 } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index b7b1bfd63e9..47450dcc74e 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 @@ -6223,6 +6227,66 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isSweptTombstone) } + // ── TXO-store reconcile (the job-flower change-drop repair) ─────── + + private val changeTxid = ByteArray(32) { 7 } + private val reconcileTip = 1_536_950 + + private fun engineUtxoJson( + txidHex: String, + vout: Int, + amount: Long, + address: String = "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd", + height: Int = 1_534_921, + isConfirmed: Boolean = true, + isCoinbase: Boolean = false, + ): String = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"76a914000088ac",""" + + """"height":$height,"isConfirmed":$isConfirmed,"isInstantlocked":false,""" + + """"isCoinbase":$isCoinbase,"isLocked":false}]}""" + + private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } + + @Test + fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + // A send record born blind to its own change output: netAmount + // persisted as the full input value (the job-flower 6cef55ab… + // shape) and NO txos row for the change. + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1, 2, 3), + netAmount = -1_000_010_000L, + ), + ) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.inserted) + assertEquals(989_009_773L, report.insertedDuffs) + assertEquals(1, report.netAmountSuspects) + + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) + assertNotNull(row) + assertFalse(row!!.isSpent) + assertEquals(989_009_773L, row.amount) + assertTrue(row.isConfirmed) + + // The stored netAmount is NOT mutated: the record may already carry + // the corrected net (a corrective callback racing this sweep), and + // blind addition double-credits. The suspicion is logged; the event + // pipeline owns net correctness. + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + @Test fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { // A chained sweep that re-points a still-unfunded claim to a new @@ -6727,6 +6791,791 @@ class PlatformWalletPersistenceHandlerTest { chainLockHeightRound(handler, 600) assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) } + + @Test + fun reconcileIsIdempotentAndNeverDoubleCredits() = runTest { + db.transactionDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.TransactionEntity( + txid = changeTxid, + transactionData = byteArrayOf(1), + netAmount = -1_000_010_000L, + ), + ) + val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) + + handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, second.inserted) + assertEquals(0, second.netAmountSuspects) + assertEquals( + -1_000_010_000L, + db.transactionDao().getByTxid(changeTxid)!!.netAmount, + ) + } + + @Test + fun reconcileSkipsImmatureOutputsAndPreservesSpentRows() = runTest { + // Immature: inside the 100-conf gate (flags on the engine snapshot + // can't carry coinbase/IS-lock, so fresh rows wait for a later + // sweep) — nothing inserted. + val fresh = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3), + tipHeight = reconcileTip, + ) + assertEquals(0, fresh.inserted) + assertEquals(1, fresh.skippedImmature) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 0))) + + // A row the mirror already holds — even marked spent while the + // engine still lists it — is left untouched: reconcile is + // insert-only and never flips spend state. + assertEquals( + 0, + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 2, 42L, "yTestAddr", byteArrayOf(0x51), 1_500_000, + false, true, false, false, + ), + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), + tipHeight = reconcileTip, + ) + assertEquals(0, report.inserted) + assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) + } + + /** Engine inventory JSON with both halves: unspent rows and spent outpoints. */ + private fun engineInventoryJson(unspent: List>, spent: List>): String { + val utxoRows = unspent.joinToString(",") { (txid, vout, amount) -> + """{"typeTag":0,"standardTag":0,"index":0,"txid":"$txid","vout":$vout,""" + + """"amount":$amount,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd",""" + + """"scriptHex":"76a914000088ac","height":1400000,"isConfirmed":true,""" + + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false}""" + } + val spentRows = spent.joinToString(",") { (txid, vout) -> + """{"txid":"$txid","vout":$vout}""" + } + return """{"utxos":[$utxoRows],"spent":[$spentRows]}""" + } + + @Test + fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { + // A store row still marked unspent for a coin the engine records as + // spent (dashpay/platform#4425). The engine's spent set includes + // MEMPOOL spends and carries no context, so persisting the flip + // would settle an unconfirmed spend — counted and logged only. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldFlipSpent) + assertEquals(500_000L, report.wouldFlipSpentDuffs) + assertEquals(0, report.wouldRemove) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! + assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) + assertNull(row.spendingTxid) + } + + @Test + fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + // A store row for a coin the engine has in NEITHER inventory — + // residue of a swept/abandoned transaction (pre-rust-dashcore#971 + // stores). Counted and logged, NEVER removed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 4, 250_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.wouldRemove) + assertEquals(250_000L, report.wouldRemoveDuffs) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 4))!! + assertFalse(row.isSpent) + assertEquals(250_000L, row.amount) + } + + @Test + fun 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, and the old transport had no + // field to carry the truth in. + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson( + changeTxid.toHexLower(), + vout = 20, + amount = 5_000_000_000L, + isConfirmed = true, + isCoinbase = true, + ), + tipHeight = reconcileTip, + ) + + 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) + } + + @Test + fun reconcileReportsRowsFiledUnderAnAccountThatDoesNotOwnThem() = 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. That is a store attribution error rather than a missing + // coin, and it is the one verdict that says nothing about + // spentness — so it is reported on its own counter 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, tipHeight = reconcileTip) + + assertEquals(1, report.misattributed) + assertEquals(777_000L, report.misattributedDuffs) + assertEquals("a not-owned row is not also counted as removable", 0, report.wouldRemove) + assertEquals(0, report.wouldFlipSpent) + val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 21))!! + assertFalse("the row is left alone — the verdict is log-only", row.isSpent) + assertEquals(777_000L, row.amount) + } + + @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 — and the same missing link is + // what would stop the row surviving the next mirror-reload. + 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()), + tipHeight = reconcileTip, + ) + + assertEquals(1, report.unresolvedAccount) + assertEquals("an unasked row is not counted as engine-unknown", 0, report.wouldRemove) + } + + @Test + fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + // Rows the engine also holds unspent — including a YOUNG coin the + // insert pass would skip as immature — are consistent, not + // divergence. Every reverse-pass counter must be zero. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 5, 42L, "yTestAddr", byteArrayOf(0x51), reconcileTip - 3, + false, true, false, false, + ) + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + + """"address":"yTestAddr","scriptHex":"51",""" + + """"height":${reconcileTip - 3},"isConfirmed":true,""" + + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false}],"spent":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + assertEquals(0, report.wouldFlipSpent) + assertEquals(0, report.wouldRemove) + assertEquals(1, report.skippedImmature) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 5))!!.isSpent) + } + + @Test + fun reconcileExcludesWatchOnlyContactRowsFromReversePass() = runTest { + // Watch-only DIP-15 contact rows are never in the engine's + // inventories; flagging them would be a false positive on every + // wallet with contact payments. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 0, + accountTypeName = "DashpayExternalAccount", + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 6, 1_230_000L, "yContactAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!! + db.txoDao().upsert(seeded.copy(accountId = foreignAccountId)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) + } + + @Test + fun reconcileResolvesContactOwnershipThroughCoreAddressId() = runTest { + // Production changeset writes leave txos.accountId null and route + // ownership through coreAddressId -> core_addresses.accountId. The + // exclusion must resolve that path, or every contact row gets + // classified as divergence. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 1, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactRouted", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/0", + isUsed = true, + accountId = foreignAccountId, + ), + ) + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 8, 990_000L, "yContactRouted", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! + assertNull("production shape: accountId is null", seeded.accountId) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson(unspent = emptyList(), spent = emptyList()), + tipHeight = reconcileTip, + ) + assertEquals(1, report.skippedForeign) + assertEquals(0, report.wouldRemove) + } + + @Test + fun reconcileInsertPassNeverHealsContactAccountCoins() = runTest { + // The engine's UTXO inventory export includes the watch-only DIP-15 + // external accounts' coins — it tracks them to show payments TO + // contacts, but they are the CONTACT's money. With the foreign + // exclusion living only on the reverse pass, a fresh restore's + // post-backfill reconcile healed every contact-payment coin into the + // store as an ownerless row (12 rows / 5,692,493 duffs on the + // 2026-08-25 large-wallet validation run) while the reverse pass + // counted the very same rows as foreign — and the mirror-reload path + // hands such rows back to the engine on the next launch. The insert + // pass must skip any engine UTXO whose address resolves to an + // external account, and count it as foreign, not healed. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val foreignAccountId = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountIndex = 2, + accountTypeName = "DashpayExternalAccount", + ), + ) + db.coreAddressDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( + address = "yContactPaid", + publicKey = ByteArray(33), + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/9'/1'/15'/0'/x/y/1", + isUsed = true, + accountId = foreignAccountId, + ), + ) + + val json = + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + + """"address":"yContactPaid","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(0L, report.insertedDuffs) + assertEquals(1, report.skippedForeign) + assertNull( + "the contact's coin must not enter the store", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 9)), + ) + } + + @Test + fun reconcileStampsResolvedAccountOnHealedRows() = runTest { + // The blocking scenario from review: persistence lost BOTH the TXO + // and its address row. The heal must resolve the owning Room account + // from the inventory's account tuple and stamp it on the inserted + // row — a healed row with neither accountId nor a resolvable address + // is skipped by the restore loader at the next mirror-reload, + // recreating the fund loss the heal repaired. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + // Production shape: onPersistAccountRegistration stores the FFI's + // 32-zero-byte identity ids verbatim (the entity ctor default of an + // EMPTY array never occurs on persisted rows). + val bip44Id = db.accountDao().insert( + org.dashfoundation.dashsdk.persistence.entities.AccountEntity( + walletId = walletId, + accountType = 0, + accountIndex = 0, + accountTypeName = "standardBip44", + userIdentityId = ByteArray(32), + friendIdentityId = ByteArray(32), + ), + ) + // Deliberately NO core_addresses row for this address. + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 11, amount = 70_000L, address = "yOrphanAddr"), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(0, report.healedUnowned) + assertEquals( + "the healed row must carry the account resolved from the inventory tuple", + bip44Id, db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 11))!!.accountId, + ) + } + + @Test + fun reconcileCountsHealsWhoseAccountCannotBeResolved() = runTest { + // No matching Room account row at all (a store damaged past the + // account registrations): the heal proceeds — the address projection + // may still attribute it — but the unresolved owner is surfaced. + db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) + val report = handler.reconcileFromInventory( + walletId, + engineUtxoJson(changeTxid.toHexLower(), vout = 12, amount = 5_000L), + tipHeight = reconcileTip, + ) + assertEquals(1, report.inserted) + assertEquals(1, report.healedUnowned) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))!!.accountId) + } + + @Test + fun reconcileForeignSkipKeysOffTheInventoryTagWithoutAddressRow() = runTest { + // The tag is the authoritative foreign check: a contact's coin must + // be skipped even when its address row never survived persistence + // (the case the address-based fallback cannot see). + val json = + """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,""" + + """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + + """"address":"yContactNoRow","scriptHex":"51",""" + + """"height":1400000,"isLocked":false}],"spent":[]}""" + val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + + assertEquals(0, report.inserted) + assertEquals(1, report.skippedForeign) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 13))) + } + + @Test + fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { + // A row marked spent while the engine lists the coin unspent: either + // a lost release event (pre-rust-dashcore#971) or a live spend the + // store wrote before the engine settled. Un-marking a coin + // mid-payment would let the wallet double-spend it, so this is + // counted and logged but NEVER changed. + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, 7, 77_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!! + db.txoDao().upsert(seeded.copy(isSpent = true)) + + val report = handler.reconcileFromInventory( + walletId, + engineInventoryJson( + unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), + spent = emptyList(), + ), + tipHeight = reconcileTip, + ) + assertEquals(1, report.stuckSpent) + assertEquals(77_000L, report.stuckSpentDuffs) + assertTrue( + "the row must stay spent — un-marking is never done by reconciliation", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 7))!!.isSpent, + ) + } + + // ── Paged reconcile transport ───────────────────────────────────── + + @Test + fun reconcileWalksEveryPageOfTheEngineInventory() = runTest { + // The engine inventory is chain-controlled in size, so the sweep + // reads it a page at a time. Every page must be applied — a sweep + // that healed only the first one would leave most of a damaged + // mirror unrepaired, and silently. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 5).map { Triple(changeTxid.toHexLower(), 20 + it, 1_000L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + )!! + + assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) + assertEquals(5, report.engineUtxos) + assertEquals(5, report.inserted) + assertEquals(5_000L, report.insertedDuffs) + for (vout in 20 until 25) { + assertNotNull( + "the row at vout=$vout must be healed whichever page carried it", + db.txoDao().getByOutpoint(makeOutpoint(changeTxid, vout)), + ) + } + } + + @Test + fun reconcileStopsAtAFailedPageAndKeepsWhatItAlreadyHealed() = runTest { + // A transport that dies mid-sweep must not discard the pages that + // already landed — the pass is insert-only and idempotent, so they + // are already correct — and must not report a clean run either. + val engine = FakeEngine( + engineInventoryJson( + unspent = (0 until 4).map { Triple(changeTxid.toHexLower(), 30 + it, 500L) }, + spent = emptyList(), + ), + ) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 2, + engineUtxoPage = { cursor, limit -> + if (cursor == null) engine.page(cursor, limit) else null + }, + classifyOutpoints = engine::classify, + )!! + + assertEquals(1, report.transportFailures) + assertEquals("only the page that arrived", 2, report.inserted) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 30))) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 32))) + } + + @Test + fun reconcileClassifiesStoreRowsInBoundedBatches() = runTest { + // The reverse direction is inverted: the STORE is paged and the + // engine is asked about one page at a time, so neither side builds + // a set over a whole inventory. Every batch must still be answered + // and counted. + for (vout in 40 until 43) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + // These rows must carry a resolvable owner: a query names the + // account the store filed the coin under, and a row without one is + // counted rather than sent to the classifier. + attributeUnownedTxos(walletId) + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + )!! + + assertEquals("one classification batch per store page", 3, engine.batches) + assertEquals("every store row reached the classifier", 3, engine.classified) + assertEquals(3, report.wouldRemove) + assertEquals(300L, report.wouldRemoveDuffs) + } + + @Test + fun reconcileStopsWhenAClassificationBatchFails() = runTest { + // No verdicts, no classification: the reverse pass stops rather + // than guessing at rows it could not ask the engine about. + for (vout in 50 until 53) { + handler.onWalletChangesetUtxoAdded( + walletId, changeTxid, vout, 100L, "yTestAddr", byteArrayOf(0x51), 1_400_000, + false, true, false, false, + ) + } + // These rows must carry a resolvable owner: a query names the + // account the store filed the coin under, and a row without one is + // counted rather than sent to the classifier. + attributeUnownedTxos(walletId) + val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + pageSize = 1, + engineUtxoPage = engine::page, + classifyOutpoints = { null }, + )!! + + assertEquals(1, report.transportFailures) + assertEquals(0, report.wouldRemove) + } + + @Test + fun reconcileReturnsNoReportWhenTheFirstPageIsUnavailable() = runTest { + // No first page means nothing to reconcile against — the contract + // callers had before the sweep was paged, now enforced inside the + // handler rather than by a prefetch in the caller. + var classifyCalls = 0 + val report = handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> null }, + classifyOutpoints = { queriesJson -> + classifyCalls++ + ByteArray( + kotlinx.serialization.json.Json.parseToJsonElement(queriesJson).jsonArray.size, + ) + }, + ) + assertNull("a dead transport yields no report", report) + assertEquals("and the classification pass never runs", 0, classifyCalls) + } + + @Test + fun reconcileRejectsAMalformedInventoryRowInsteadOfHealingIt() = runTest { + // The transport is a typed contract on both ends. A row missing its + // amount (or carrying a key this reader does not know) must fail the + // decode loudly — the alternative is a defaulted row written into + // the mirror the engine reloads from. + val malformed = """{"utxos":[{"typeTag":0,"txid":"${changeTxid.toHexLower()}",""" + + """"vout":40,"address":"yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd","height":1400000}],""" + + """"cursor":null,"hasMore":false}""" + val thrown = runCatching { + handler.reconcileTxos( + walletId = walletId, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> malformed }, + classifyOutpoints = { 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)), + ) + } + + /** + * Drive the paged reconcile from one whole-inventory JSON blob — the + * shape these tests describe an engine in, and the shape the native + * side used to hand over in a single unbounded call. + * + * The blob is served the way the transport now serves it: sliced into + * bounded pages behind an opaque cursor, with a separate positional + * classifier for the outpoints the store asks about. [pageSize] + * defaults to 2, so a test describing more than a couple of rows walks + * the real cursor loop rather than a single page. + */ + private suspend fun PlatformWalletPersistenceHandler.reconcileFromInventory( + walletId: ByteArray, + inventoryJson: String, + tipHeight: Int, + minConfirmations: Int = 100, + pageSize: Int = 2, + ): PlatformWalletPersistenceHandler.TxoReconcileReport { + attributeUnownedTxos(walletId) + val engine = FakeEngine(inventoryJson) + return checkNotNull( + reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ), + ) { "the fake engine always serves a first page" } + } + + /** + * File every still-unattributed TXO of [walletId] under a default + * BIP44 account, seeding that account on first use. + * + * The classification pass asks the engine about a row by naming the + * account the STORE filed it under — the engine checks that claim + * against its own pools rather than trusting it — so a row with no + * resolvable owner carries no claim and is counted instead of asked + * about. 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 two 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 + + 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 queries = kotlinx.serialization.json.Json + .parseToJsonElement(queriesJson).jsonArray + classified += queries.size + val verdicts = ByteArray(queries.size) + for ((i, query) in queries.withIndex()) { + val q = query.jsonObject + // 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. + requireNotNull(q["typeTag"]) { "classification query carries no account tag" } + 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" + + private companion object { + /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ + const val OUTPOINT_SIZE = 36 + } + } } /** From 85693844bf0d2582bbb3eb1d4cd796103a00a260 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 1 Oct 2026 15:56:05 -0700 Subject: [PATCH 03/28] fix(kotlin-sdk): send the full account tuple in TXO classification queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kotlinx's default Json omits a property equal to its default value, and OutpointQuery defaulted standardTag/index/registrationIndex/keyClass to 0. For the common BIP44 account-0 store row every one of those is 0, so the query went out as {"typeTag":0,"txid":…,"vout":…,"scriptHex":…} and rs-unified-sdk-jni's OutpointQuery (whose flattened UtxoAccountTuple requires those fields) rejected every batch: malformed outpoint queries: missing field `standardTag` at line 1 column 162 The reverse (store→engine) half of reconcileTxoStore therefore never classified a single row on device. Drop the defaults so the fields are always encoded; only the identity halves stay optional, matching the native tuple. The test FakeEngine now rejects a batch missing any key the native deserializer requires, which is why the existing reconcile tests did not catch this. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 26 ++++++--- .../PlatformWalletPersistenceHandlerTest.kt | 54 +++++++++++++++++-- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index b8e8b5af379..c70d777a180 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 @@ -1241,19 +1241,25 @@ class PlatformWalletPersistenceHandler( * 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 = 0, - val index: Int = 0, - val registrationIndex: Int = 0, - val keyClass: Int = 0, + 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 = "", + val scriptHex: String, ) /** @@ -1815,7 +1821,7 @@ class PlatformWalletPersistenceHandler( if (classifiable.isEmpty()) continue val verdicts = classifyOutpoints( - engineJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(OutpointQuery.serializer()), queries), + encodeOutpointQueries(queries), ) if (verdicts == null || verdicts.size != classifiable.size) { // No verdicts, no classification. Log-only either way, so @@ -4819,6 +4825,14 @@ class PlatformWalletPersistenceHandler( */ private val engineJson = Json + /** One classification batch, as `walletManagerClassifyOutpoints` + * deserializes it (rs-unified-sdk-jni `OutpointQuery`). */ + internal fun encodeOutpointQueries(queries: List): String = + engineJson.encodeToString( + kotlinx.serialization.builtins.ListSerializer(OutpointQuery.serializer()), + queries, + ) + /** [reconcileTxos] classification verdicts, mirroring * `platform_wallet::manager::accessors::OutpointClass`. Only * [OUTPOINT_CLASS_KNOWN_UNCREDITED] is positive evidence: the 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 47450dcc74e..175edfcc4da 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 @@ -7416,6 +7416,42 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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"]) + } + /** * Drive the paged reconcile from one whole-inventory JSON blob — the * shape these tests describe an engine in, and the shape the native @@ -7552,8 +7588,12 @@ class PlatformWalletPersistenceHandlerTest { val q = query.jsonObject // 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. - requireNotNull(q["typeTag"]) { "classification query carries no account tag" } + // 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, @@ -7571,7 +7611,15 @@ class PlatformWalletPersistenceHandlerTest { private fun key(txidHex: String, vout: Int) = "$txidHex:$vout" - private companion object { + 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", + ) + /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ const val OUTPOINT_SIZE = 36 } From 52af68f40915ea4cd939b66c8deace7cbae8797e Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 16:17:35 -0700 Subject: [PATCH 04/28] fix(kotlin-sdk): drop the netAmount-suspects log from the TXO reconcile The heal pass counted and logged healed coins whose transaction record might carry a short netAmount. It was log-only and is not part of dashpay/platform#4638, which this branch ports; it can follow on its own. The heal still never touches netAmount, and the tests keep checking that. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 51 +++---------------- .../PlatformWalletPersistenceHandlerTest.kt | 8 ++- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index c70d777a180..059fc984364 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 @@ -1284,12 +1284,6 @@ class PlatformWalletPersistenceHandler( val engineUtxos: Int, val inserted: Int, val insertedDuffs: Long, - /** Healed TXOs whose pre-existing record's netAmount MAY be short by - * the healed amount. LOG-ONLY: the record can already carry the - * corrected net (a corrective callback racing this sweep), and - * blind addition double-credits. The event pipeline owns net - * correctness. */ - val netAmountSuspects: Int, /** Healed rows whose owning Room account could not be resolved from * the inventory's account tuple — ownership rides on the address * projection alone, and if that row is also missing the healed TXO @@ -1354,7 +1348,6 @@ class PlatformWalletPersistenceHandler( val engineUtxos: Int, val inserted: Int, val insertedDuffs: Long, - val netAmountSuspects: Int, val healedUnowned: Int, val skippedImmature: Int, val skippedNoAddress: Int, @@ -1418,10 +1411,10 @@ class PlatformWalletPersistenceHandler( * beyond coinbase maturity, which the gate guarantees. Fresher holes * age into a later sweep. * - * `netAmount` is reported, never repaired: a record born blind to one - * of its own outputs persisted a net short by exactly that output's - * value, but the record may equally have been corrected already by a - * callback racing this sweep, and blind addition double-credits. + * `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 sweep, and blind addition double-credits. * * Returns null when the FIRST engine page is unavailable — there is * nothing to reconcile against, so there is no report to make. A page @@ -1469,7 +1462,6 @@ class PlatformWalletPersistenceHandler( engineUtxos = heal.engineUtxos, inserted = heal.inserted, insertedDuffs = heal.insertedDuffs, - netAmountSuspects = heal.netAmountSuspects, healedUnowned = heal.healedUnowned, skippedImmature = heal.skippedImmature, skippedNoAddress = heal.skippedNoAddress, @@ -1493,7 +1485,6 @@ class PlatformWalletPersistenceHandler( TAG, "txos reconcile: healed ${report.inserted} missing TXO(s) " + "(${report.insertedDuffs} duffs), " + - "${report.netAmountSuspects} netAmount suspect(s) (log-only), " + "healedUnowned=${report.healedUnowned}, " + "wouldFlipSpent=${report.wouldFlipSpent} " + "(${report.wouldFlipSpentDuffs} duffs, log-only), " + @@ -1541,7 +1532,6 @@ class PlatformWalletPersistenceHandler( var engineUtxos = 0 var inserted = 0 var insertedDuffs = 0L - var netAmountSuspects = 0 var healedUnowned = 0 var skippedImmature = 0 var skippedNoAddress = 0 @@ -1584,17 +1574,6 @@ class PlatformWalletPersistenceHandler( insertedDuffs += row.amount healedUnowned++ } - HealOutcome.HEALED_NET_SUSPECT -> { - inserted++ - insertedDuffs += row.amount - netAmountSuspects++ - } - HealOutcome.HEALED_UNOWNED_NET_SUSPECT -> { - inserted++ - insertedDuffs += row.amount - healedUnowned++ - netAmountSuspects++ - } } } } @@ -1607,7 +1586,6 @@ class PlatformWalletPersistenceHandler( engineUtxos = engineUtxos, inserted = inserted, insertedDuffs = insertedDuffs, - netAmountSuspects = netAmountSuspects, healedUnowned = healedUnowned, skippedImmature = skippedImmature, skippedNoAddress = skippedNoAddress, @@ -1623,8 +1601,6 @@ class PlatformWalletPersistenceHandler( ALREADY_PRESENT, HEALED, HEALED_UNOWNED, - HEALED_NET_SUSPECT, - HEALED_UNOWNED_NET_SUSPECT, } /** @@ -1696,23 +1672,8 @@ class PlatformWalletPersistenceHandler( // correct (a corrective record callback can land while its TXO // delivery races this sweep), and adding the healed amount to an // already-corrected net double-credits. The event pipeline owns - // net correctness; this pass only reports the suspicion. - val priorTx = database.transactionDao().getByTxid(txid) - val netSuspect = priorTx != null && priorTx.transactionData.isNotEmpty() - if (netSuspect) { - Log.w( - TAG, - "txos reconcile: healed TXO ${row.txid}:${row.vout} (${row.amount} duffs) " + - "has a pre-existing record whose netAmount may be short by that " + - "amount — LOG-ONLY, storedNet=${priorTx?.netAmount}", - ) - } - return when { - ownerAccountId == null && netSuspect -> HealOutcome.HEALED_UNOWNED_NET_SUSPECT - ownerAccountId == null -> HealOutcome.HEALED_UNOWNED - netSuspect -> HealOutcome.HEALED_NET_SUSPECT - else -> HealOutcome.HEALED - } + // net correctness. + return if (ownerAccountId == null) HealOutcome.HEALED_UNOWNED else HealOutcome.HEALED } /** 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 175edfcc4da..75508ca7d5f 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 @@ -6249,7 +6249,7 @@ class PlatformWalletPersistenceHandlerTest { private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } @Test - fun reconcileHealsMissingChangeTxoAndRepairsNetAmount() = runTest { + 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. @@ -6269,7 +6269,6 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(1, report.inserted) assertEquals(989_009_773L, report.insertedDuffs) - assertEquals(1, report.netAmountSuspects) val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 1)) assertNotNull(row) @@ -6279,8 +6278,8 @@ class PlatformWalletPersistenceHandlerTest { // The stored netAmount is NOT mutated: the record may already carry // the corrected net (a corrective callback racing this sweep), and - // blind addition double-credits. The suspicion is logged; the event - // pipeline owns net correctness. + // blind addition double-credits. The event pipeline owns net + // correctness. assertEquals( -1_000_010_000L, db.transactionDao().getByTxid(changeTxid)!!.netAmount, @@ -6807,7 +6806,6 @@ class PlatformWalletPersistenceHandlerTest { val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, second.inserted) - assertEquals(0, second.netAmountSuspects) assertEquals( -1_000_010_000L, db.transactionDao().getByTxid(changeTxid)!!.netAmount, From 642fa992f99052a5ed655e219c8f0a97f28e20a7 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 16:25:30 -0700 Subject: [PATCH 05/28] feat(unified-sdk-jni): deliver the engine's credit verdicts to Kotlin Register the persistence-extension slot on_persist_wallet_changeset_utxo_verdicts_fn that dashpay/platform#4638 added and the JNI layer left unset. The new trampoline packs the round's verdicts into flat arrays (36-byte outpoint keys, verdict codes, observed- spent heights) and calls NativePersistenceBridge.onWalletChangesetUtxoVerdicts, whose default body ignores them, so a bridge that does not override it writes exactly what it wrote before. The descriptor is in the bridge verification table and pinned by a test. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/NativePersistenceBridge.kt | 25 ++++++ .../rs-unified-sdk-jni/src/persistence.rs | 79 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) 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 76759d63ed3..ebe21621060 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 + // ── Identities ──────────────────────────────────────────────────── /** diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index fea73af90dc..1c6fc3c4be2 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -59,8 +59,8 @@ use platform_wallet_ffi::{ InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, - TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, - WalletChangeSetFFI, WalletRestoreEntryFFI, + TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoCreditVerdictFFI, UtxoEntryFFI, + UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -217,6 +217,13 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence 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, + ), ..Default::default() } } @@ -850,6 +857,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() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -4548,6 +4608,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, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -4735,6 +4802,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()); From 141408750a2fdd234487542f0550141fb3e9b47b Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 16:25:31 -0700 Subject: [PATCH 06/28] fix(kotlin-sdk): write TXOs the engine did not credit as spent at creation Kotlin port of dashpay/platform#4638 Part 1. The handler stages the round's credit verdicts (they arrive before the changeset callback) and upsertUtxoRow applies them as Swift's upsertUtxo does: - observed_spent / doomed: the row is written spent, with no spender link; - any verdict vetoes the redelivery recovery clear (flag and sweep stamp); - uncredited changes nothing else; - verdicts live in the round buffer, so a rolled-back round leaves none. One log line per round that applied a verdict, counts only. Tests port BornSpentTxoPersistTests, including a reopened on-disk store restoring zero coins for a verdict-spent row. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 145 +++++++- .../PlatformWalletPersistenceHandlerTest.kt | 329 ++++++++++++++++++ 2 files changed, 472 insertions(+), 2 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 059fc984364..150e5857ffe 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 @@ -240,6 +240,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). */ @@ -485,16 +526,34 @@ class PlatformWalletPersistenceHandler( // and the round's pending-key state changes are now true. 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 @@ -1034,6 +1093,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, @@ -1047,10 +1140,17 @@ 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 -> upsertUtxoRow( db, walletId, txid, vout, amount, address, scriptPubKey, height, isCoinbase, isConfirmed, isInstantLocked, isLocked, + creditVerdict = creditVerdict, + creditTally = round?.utxoCreditTally, ) } 0 @@ -1092,6 +1192,12 @@ class PlatformWalletPersistenceHandler( // 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 @@ -1118,7 +1224,30 @@ class PlatformWalletPersistenceHandler( // 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, @@ -1130,7 +1259,7 @@ class PlatformWalletPersistenceHandler( isConfirmed = isConfirmed, isInstantLocked = isInstantLocked, isLocked = isLocked, - isSpent = linked && existing!!.isSpent, + isSpent = alreadySpent || bornSpent, walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -1139,7 +1268,7 @@ class PlatformWalletPersistenceHandler( coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), - supersededByTxid = if (linked) existing!!.supersededByTxid else null, + supersededByTxid = if (keepSpentState) existing!!.supersededByTxid else null, ) db.txoDao().upsert(row) // Drain any pending-input rows staged before this funding TXO @@ -4767,6 +4896,18 @@ class PlatformWalletPersistenceHandler( * 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. 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 75508ca7d5f..98cccb9ab60 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 @@ -7622,6 +7622,335 @@ class PlatformWalletPersistenceHandlerTest { const val OUTPOINT_SIZE = 36 } } + + // ── 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) + } + } } /** From b26d93f5b01baaf4227dafcd065ffd7c12ac6d92 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 16:33:54 -0700 Subject: [PATCH 07/28] style(unified-sdk-jni): rustfmt wallet_manager.rs cargo fmt --check failed on the inventory/classifier bindings carried from #4439 (f5fed8c9be). Formatting only. Co-Authored-By: Claude Opus 5.5 --- packages/rs-unified-sdk-jni/src/wallet_manager.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index b2efa517ae3..fc23f74599f 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3218,7 +3218,6 @@ fn core_selection_strategy( } } - // ── TXO-store reconcile transport ───────────────────────────────────── // // Two exports, each a serialization shim over exactly ONE platform-wallet @@ -3271,7 +3270,10 @@ impl UtxoAccountTuple { Some(h) => hex::decode(h).ok()?.try_into().ok(), } } - Some((id32(&self.user_identity_id)?, id32(&self.friend_identity_id)?)) + Some(( + id32(&self.user_identity_id)?, + id32(&self.friend_identity_id)?, + )) } } @@ -3406,10 +3408,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w key_class: c.account.key_class, user_identity_id, friend_identity_id, - outpoint: platform_wallet_ffi::OutPointFFI { - txid, - vout: c.vout, - }, + outpoint: platform_wallet_ffi::OutPointFFI { txid, vout: c.vout }, }) }); match parsed { From fb2f641cd7a74caa509311d6ea79a5597a8854bb Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 20:09:53 -0700 Subject: [PATCH 08/28] feat(kotlin-sdk): count committed persistence rounds and page unspent TXOs Groundwork for the dashpay/platform#4638 store reconcile, which asks the engine about a page of store rows outside the callback lock and applies the verdicts under it: - committedRoundGeneration, bumped when a changeset round's Room transaction commits (and on a standalone callback write), so a verdict read before a round that may have re-credited the coin is refused; - TxoDao.pageUnspentByWallet, the outpoint-cursor walk over a wallet's isSpent = 0 rows that the classify pass pages through. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 16 ++++++++++++++++ .../dashsdk/persistence/dao/TxoDao.kt | 13 +++++++++++++ 2 files changed, 29 insertions(+) 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 150e5857ffe..b05ab2100bc 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 @@ -468,9 +468,24 @@ 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. 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()] @@ -524,6 +539,7 @@ 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) 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 67b9b6de722..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 @@ -38,6 +38,19 @@ interface TxoDao { ) 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> From f09723050b2f2d63ad0dc6c8e0933c3e58d1ac9c Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 20:10:18 -0700 Subject: [PATCH 09/28] fix(kotlin-sdk): reconcile the TXO store the way Swift does Brings PlatformWalletPersistenceHandler.reconcileTxos in line with Swift's runCoreTxoReconcile from dashpay/platform#4638 Part 2; the Kotlin pass was carried over from #4439 and differed from it. Heal pass (engine -> store), Swift's gates in Swift's order: malformed row (txid not 32 bytes, no script, no address) is invalid; a coin the engine does not call confirmed, or under 100 confirmations, is immature; a coin already in the store is present; a coin whose seven-field account tuple names no store account is refused (no longer inserted unowned). Inserted rows go through upsertUtxoRow with isConfirmed = true, and a row the pending-input drain wrote spent is counted as healedSpent. The contact (DashpayExternalAccount) skip stays. Classify pass (store -> engine): only the wallet's isSpent = 0 rows are asked about, by outpoint cursor. A KnownUncredited verdict marks the row spent (no spender link, lastUpdated bumped) and drops the pending-input rows on it; Unspent, Unknown and NotOwned are counted only. Each page is read with committedRoundGeneration, classified outside the callback lock, and applied under it only if no round committed in between; otherwise the page is re-read, at most 5 times in a row. Every store step re-checks a caller cancellation flag under the lock. A failed engine read, the first page included, now yields an incomplete report instead of null; a failed store step rolls back and is counted. The report mirrors Swift's CoreTxoReconcileReport, and the logs carry counts and SHA-256 reference digests only: one summary line per run ("txos reconcile: mirror consistent (...)" when nothing changed) and one item line per healed / healed-spent / flipped row. Tests updated to the new semantics and ported from CoreTxoReconcileTests, CoreTxoReconcileShutdownTests and CoreTxoReconcilePrivacyTests. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 1145 ++++++++++------- .../PlatformWalletPersistenceHandlerTest.kt | 1008 ++++++++++++--- 2 files changed, 1459 insertions(+), 694 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index b05ab2100bc..3d79d2cb628 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 @@ -481,8 +481,8 @@ class PlatformWalletPersistenceHandler( * 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. Read - * and written only under [callbackExclusion]. + * so the verdicts are applied only while the count is unchanged — see + * [classifyStoreRows]. Read and written only under [callbackExclusion]. */ private var committedRoundGeneration = 0L @@ -1350,9 +1350,9 @@ class PlatformWalletPersistenceHandler( * is typed on BOTH ends so a renamed or missing key fails the decode * loudly instead of healing a row with a defaulted owner or a zero * amount into the mirror the engine reloads from. The account tuple - * (`typeTag`…`friendIdentityId`) is the routing context [fetchAccount] - * resolves the owning Room account by; the DashPay identity halves are - * absent on every non-DashPay account. + * (`typeTag`…`friendIdentityId`) is the routing context + * [findReconcileAccount] resolves the owning Room account by; the + * DashPay identity halves are absent on every non-DashPay account. */ @Serializable data class EngineUtxoRow( @@ -1419,252 +1419,274 @@ class PlatformWalletPersistenceHandler( ) /** - * Outcome of one [reconcileTxos] sweep. [inserted]/[insertedDuffs] - * are the healed holes; a non-zero value after a completed sync means - * a changeset failed to deliver an owned output (the - * CoinJoin-funded-send change-drop class) and would have become a - * fund-loss on the next engine reload from this store. + * 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( - val engineUtxos: Int, - val inserted: Int, - val insertedDuffs: Long, - /** Healed rows whose owning Room account could not be resolved from - * the inventory's account tuple — ownership rides on the address - * projection alone, and if that row is also missing the healed TXO - * will not survive the next mirror-reload. */ - val healedUnowned: Int = 0, - val skippedImmature: Int, - val skippedNoAddress: Int, - /** Store rows marked unspent for which the engine holds a MINED - * record spending the outpoint — the lost-spend-update class - * (dashpay/platform#4425), on durable evidence rather than a bare - * absence. LOG-ONLY all the same: flipping spent state is the one - * direction where a reconciler bug spends a coin the user still - * owns, and this pass is insert-only by contract. */ - val wouldFlipSpent: Int = 0, - val wouldFlipSpentDuffs: Long = 0, - /** Store rows marked unspent the engine has no verdict for — - * swept/abandoned residue (pre-rust-dashcore#971 stores), a - * funding transaction this session never processed, or an engine - * gap. After a restart the engine's finalized set is empty, so - * this is the ordinary answer rather than the alarming one. - * LOG-ONLY: counted and named in the log, never removed. */ - val wouldRemove: Int = 0, - val wouldRemoveDuffs: Long = 0, - /** Store rows filed under an account whose pools do not monitor the - * row's script — the engine could never have credited the coin - * there, so this is a store attribution error rather than a - * missing coin. LOG-ONLY: counted, never re-filed. */ - val misattributed: Int = 0, - val misattributedDuffs: Long = 0, - /** Unspent store rows whose owning account could not be resolved - * from either the explicit link or the address projection. They - * carry no claim for the engine to check, so they are counted - * rather than asked about — and the same missing link is what - * would stop such a row surviving the next mirror-reload. */ - val unresolvedAccount: Int = 0, + // 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 does not call confirmed, or below the + * confirmation gate — 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 store could not validate (txid not 32 bytes, no + * script, no address). */ + val skippedInvalid: Int = 0, /** Watch-only DIP-15 contact coins excluded on both sides — engine - * rows the insert pass refused to heal as ours, and unspent store - * rows the reverse pass did not classify (the engine's own accounts - * never report them, so their absence is expected, not - * divergence). */ + * coins the heal pass refused to file as ours, and unspent store + * rows the classify pass did not ask about (the engine's own + * accounts never report them, so their absence is expected). */ val skippedForeign: Int = 0, - /** Store rows marked spent for a coin the engine lists UNSPENT — - * either a released coin from a swept transaction whose release - * event a pre-rust-dashcore#971 build lost, or a live spend the - * store wrote moments before the engine settled. The two cannot - * be told apart safely, so this is LOG-ONLY: un-marking a coin - * mid-payment would let the wallet double-spend it. */ - val stuckSpent: Int = 0, - val stuckSpentDuffs: Long = 0, - /** Transport reads that failed mid-sweep — an engine inventory page - * after the first, or an outpoint-classification batch, that came - * back null. The pass stops at the first one; whatever it already - * applied stands (insert-only, idempotent) and the rest waits for - * the next cadence tick. A persistently non-zero value means the - * sweep never finishes, so the report's other counters are a - * partial view. */ + // 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. */ + 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. */ + 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 insert pass's half of a [TxoReconcileReport]. */ - private data class HealPass( - val engineUtxos: Int, - val inserted: Int, - val insertedDuffs: Long, - val healedUnowned: Int, - val skippedImmature: Int, - val skippedNoAddress: Int, - val skippedForeign: Int, - val transportFailures: Int, - ) + /** 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 skippedForeign = 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 - /** The classification pass's half of a [TxoReconcileReport]. */ - private data class ClassifyPass( - val wouldFlipSpent: Int, - val wouldFlipSpentDuffs: Long, - val wouldRemove: Int, - val wouldRemoveDuffs: Long, - val stuckSpent: Int, - val stuckSpentDuffs: Long, - val misattributed: Int, - val misattributedDuffs: Long, + fun toReport() = TxoReconcileReport( + engineRows = engineRows, + alreadyPresent = alreadyPresent, + inserted = inserted, + insertedDuffs = insertedDuffs, + healedSpent = healedSpent, + skippedImmature = skippedImmature, + skippedUnresolvedAccount = skippedUnresolvedAccount, + skippedInvalid = skippedInvalid, + skippedForeign = skippedForeign, + 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 + var skippedForeign = 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 skippedForeign: Int, - val transportFailures: 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: nothing was + * written and the page is classified again. */ + var staleGeneration = false + val flippedOutpoints = ArrayList() + } + /** - * Reconcile the Room `txos` mirror against the engine's live UTXO - * inventory, healing rows a changeset failed to deliver. The mirror is - * write-behind with no other feedback loop: a changeset that fails to - * deliver an owned output leaves a permanent hole, and because the - * engine is REBUILT from this mirror on restart (buildUtxoRestoreData), - * the hole graduates to a fund-loss on the next launch. Observed in the - * field as the job-flower 106.43→86.33 restart drop: rescan - * nondeterministically drops the change outputs of sends funded from - * CoinJoin-account outputs. - * - * Two passes, each bounded, sharing only the set of watch-only contact - * accounts they both exclude: + * 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 the rows the store lacks, one Room - * transaction per page. Insert-only and idempotent, so a sweep is - * many small commits rather than one giant one — that is the point. - * * [classifyStoreRows] pages the STORE and asks [classifyOutpoints] - * about one page at a time (0 unknown, 1 unspent, 2 spent). It - * writes nothing; every verdict is log-only. + * `hasMore` is true) and inserts each coin the store lacks — validated, + * owned, confirmed by the engine and at least [minConfirmations] deep. + * * [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. * - * Neither side ever holds a set over a whole inventory: a wallet's UTXO - * count is chain-controlled (anyone who knows a watched address can - * keep sending dust to it), so a pass that materialized it would hand a - * remote party control over how much this process allocates on every - * SYNCED transition and every cadence tick. + * 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. * - * Rows the mirror holds and the engine lacks are LEFT ALONE (the mirror - * may legitimately be ahead — a live spend marks rows spent here before - * the engine's map settles — and it also carries watch-only contact - * outputs the engine's own accounts never report). Spent-state repair - * is deliberately out of scope. + * 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`. * - * [minConfirmations] (default 100): the engine snapshot cannot carry - * `isCoinbase`/`isInstantLocked`, so inserted rows get - * `isConfirmed=true` and both flags false — inert for any output at or - * beyond coinbase maturity, which the gate guarantees. Fresher holes - * age into a later sweep. + * 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 sweep, and blind addition double-credits. - * - * Returns null when the FIRST engine page is unavailable — there is - * nothing to reconcile against, so there is no report to make. A page - * or classification batch failing later truncates the sweep instead, - * which the report's `transportFailures` records. A page that does not - * decode as an [EngineUtxoPage] is a contract violation between the JNI - * emitter and this reader, and it throws rather than being absorbed. + * 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, tipHeight: Int, - minConfirmations: Int = 100, + minConfirmations: Int = TXO_RECONCILE_MIN_CONFIRMATIONS, pageSize: Int = TXO_RECONCILE_PAGE_SIZE, + isCancelled: () -> Boolean = { false }, engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, - ): TxoReconcileReport? { + ): TxoReconcileReport { val limit = pageSize.coerceAtLeast(1) + val walletRef = logReference(walletId) + val tally = ReconcileTally() // Watch-only DIP-15 contact (external) accounts, resolved once up // front because BOTH passes need the exclusion and the account set - // does not move under a sweep. The engine's UTXO inventory export - // includes these accounts' coins — it tracks them to show payments - // TO contacts — but they are the CONTACT's money and must never be - // healed into the store as ours. Before this check lived on the - // insert pass, a fresh restore's post-backfill reconcile healed - // every contact-payment coin into the store (12 rows / 0.05692493 - // tDASH on the large-wallet validation run of 2026-08-25) while the - // reverse pass — the only place the exclusion existed — dutifully - // counted the same rows as foreign. - val foreignAccountIds = database.accountDao() - .observeByWallet(walletId).first() - .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } - .map { it.id } - .toSet() - - val heal = healMissingTxos( - walletId, tipHeight, minConfirmations, limit, foreignAccountIds, engineUtxoPage, - ) ?: return null - val classify = classifyStoreRows(walletId, limit, foreignAccountIds, classifyOutpoints) - - val report = TxoReconcileReport( - engineUtxos = heal.engineUtxos, - inserted = heal.inserted, - insertedDuffs = heal.insertedDuffs, - healedUnowned = heal.healedUnowned, - skippedImmature = heal.skippedImmature, - skippedNoAddress = heal.skippedNoAddress, - wouldFlipSpent = classify.wouldFlipSpent, - wouldFlipSpentDuffs = classify.wouldFlipSpentDuffs, - wouldRemove = classify.wouldRemove, - wouldRemoveDuffs = classify.wouldRemoveDuffs, - misattributed = classify.misattributed, - misattributedDuffs = classify.misattributedDuffs, - unresolvedAccount = classify.unresolvedAccount, - skippedForeign = heal.skippedForeign + classify.skippedForeign, - stuckSpent = classify.stuckSpent, - stuckSpentDuffs = classify.stuckSpentDuffs, - transportFailures = heal.transportFailures + classify.transportFailures, - ) - if (report.inserted > 0 || report.wouldFlipSpent > 0 || report.wouldRemove > 0 || - report.stuckSpent > 0 || report.misattributed > 0 || - report.unresolvedAccount > 0 || report.transportFailures > 0 - ) { - Log.w( - TAG, - "txos reconcile: healed ${report.inserted} missing TXO(s) " + - "(${report.insertedDuffs} duffs), " + - "healedUnowned=${report.healedUnowned}, " + - "wouldFlipSpent=${report.wouldFlipSpent} " + - "(${report.wouldFlipSpentDuffs} duffs, log-only), " + - "wouldRemove=${report.wouldRemove} " + - "(${report.wouldRemoveDuffs} duffs, log-only), " + - "stuckSpent=${report.stuckSpent} " + - "(${report.stuckSpentDuffs} duffs, log-only), " + - "misattributed=${report.misattributed} " + - "(${report.misattributedDuffs} duffs, log-only), " + - "unresolvedAccount=${report.unresolvedAccount}, " + - "engine=${report.engineUtxos} " + - "skipped immature=${report.skippedImmature} " + - "noAddress=${report.skippedNoAddress} " + - "foreign=${report.skippedForeign} " + - "transportFailures=${report.transportFailures} — a non-zero " + - "heal after a completed sync means a changeset dropped an owned output", - ) - } else { - Log.i( - TAG, - "txos reconcile: mirror consistent (${report.engineUtxos} engine UTXOs, " + - "skipped immature=${report.skippedImmature} " + - "noAddress=${report.skippedNoAddress} foreign=${report.skippedForeign})", + // does not move under a run. The engine tracks these accounts' coins + // to show payments TO contacts, but they are the CONTACT's money and + // must never be healed into the store as ours. Before the check + // lived on the insert pass, a fresh restore's post-backfill reconcile + // healed every contact-payment coin into the store (12 rows / + // 0.05692493 tDASH on the large-wallet validation run of 2026-08-25). + val foreignAccountIds = reconcileStoreStep(tally, walletRef, "accounts", isCancelled) { + database.accountDao() + .observeByWallet(walletId).first() + .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } + .map { it.id } + .toSet() + } + if (foreignAccountIds != null && + healMissingTxos( + walletId, tipHeight, minConfirmations, limit, foreignAccountIds, + isCancelled, engineUtxoPage, tally, walletRef, ) + ) { + classifyStoreRows(walletId, limit, foreignAccountIds, isCancelled, classifyOutpoints, tally, walletRef) } + val report = tally.toReport() + logReconcileSummary(report, walletRef) return report } /** - * Insert pass of [reconcileTxos]: one engine page at a time, one Room - * transaction each. Each page is fetched OUTSIDE the exclusion lock — - * the fetch is a native call into the engine, and the lock exists to - * keep changeset callbacks out of our writes, not out of the engine. - * Returns null when the first page is unavailable (nothing to reconcile - * against); a later null page truncates the pass and is counted. + * 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, @@ -1672,86 +1694,83 @@ class PlatformWalletPersistenceHandler( minConfirmations: Int, limit: Int, foreignAccountIds: Set, + isCancelled: () -> Boolean, engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, - ): HealPass? { - var engineUtxos = 0 - var inserted = 0 - var insertedDuffs = 0L - var healedUnowned = 0 - var skippedImmature = 0 - var skippedNoAddress = 0 - var skippedForeign = 0 - var transportFailures = 0 - + 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) { - // No first page: nothing to reconcile against, no report. - if (cursor == null) return null - // The transport died mid-sweep. Everything already applied - // stands (insert-only, idempotent); the rest waits for the - // next cadence tick. - transportFailures++ - break + // 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 = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) - engineUtxos += page.utxos.size + tally.engineRows += page.utxos.size if (page.utxos.isNotEmpty()) { - callbackExclusion.withLock { + val counts = reconcileStoreStep(tally, walletRef, "heal", isCancelled) { database.withTransaction { + val c = HealCounts() for (row in page.utxos) { - when (healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds)) { - HealOutcome.SKIPPED_IMMATURE -> skippedImmature++ - HealOutcome.SKIPPED_NO_ADDRESS -> skippedNoAddress++ - HealOutcome.SKIPPED_FOREIGN -> skippedForeign++ - HealOutcome.ALREADY_PRESENT -> {} - HealOutcome.HEALED -> { - inserted++ - insertedDuffs += row.amount - } - HealOutcome.HEALED_UNOWNED -> { - inserted++ - insertedDuffs += row.amount - healedUnowned++ - } - } + healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds, 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 + tally.skippedForeign += counts.skippedForeign + // 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) break + if (!page.hasMore || page.cursor == null) return true cursor = page.cursor } - return HealPass( - engineUtxos = engineUtxos, - inserted = inserted, - insertedDuffs = insertedDuffs, - healedUnowned = healedUnowned, - skippedImmature = skippedImmature, - skippedNoAddress = skippedNoAddress, - skippedForeign = skippedForeign, - transportFailures = transportFailures, - ) - } - - private enum class HealOutcome { - SKIPPED_IMMATURE, - SKIPPED_NO_ADDRESS, - SKIPPED_FOREIGN, - ALREADY_PRESENT, - HEALED, - HEALED_UNOWNED, } /** * Apply one engine inventory row to the store (inside the caller's - * exclusion lock and Room transaction). Provable-only discipline: it - * neither mutates nor suppresses on guesswork. + * exclusion lock and Room transaction) — Swift's + * `reconcileHealMissingTxos` gates, in its order: + * + * 1. malformed (txid not 32 bytes, no script, no address) → invalid; + * 2. the engine does not call it confirmed, or it is not + * [minConfirmations] deep at [tipHeight] → immature (a fresh coin can + * still reorg or, for coinbase, be 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. + * + * Between 2 and 3 sits the Kotlin-only contact exclusion (see + * [reconcileTxos]). 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` (the + * gate guarantees it) 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, @@ -1759,281 +1778,387 @@ class PlatformWalletPersistenceHandler( tipHeight: Int, minConfirmations: Int, foreignAccountIds: Set, - ): HealOutcome { - if (row.height <= 0 || tipHeight - row.height + 1 < minConfirmations) { - return HealOutcome.SKIPPED_IMMATURE + 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 || script.isEmpty() || + row.address.isEmpty() || !identitiesValid + ) { + counts.skippedInvalid++ + return + } + if (!row.isConfirmed || row.height <= 0 || tipHeight < row.height || + tipHeight - row.height + 1 < minConfirmations + ) { + counts.skippedImmature++ + return } - if (row.address.isEmpty()) return HealOutcome.SKIPPED_NO_ADDRESS - // The inventory tags every UTXO with its owning account tuple. The + // The inventory tags every coin with its owning account tuple. The // tag is the authoritative foreign check — a watch-only external // account's coin is the CONTACT's money whether or not its address // row survived persistence. The address-based check (through - // core_addresses.accountId, the same second path rowIsForeign uses - // for store rows) stays as a fallback. An unresolvable address is - // NOT provably foreign — those proceed. - if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) return HealOutcome.SKIPPED_FOREIGN + // core_addresses.accountId, the same second path the classify pass + // resolves store rows by) stays as a fallback. + if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) { + counts.skippedForeign++ + return + } val addressOwner = database.coreAddressDao().getByAddress(row.address)?.accountId if (addressOwner != null && addressOwner in foreignAccountIds) { - return HealOutcome.SKIPPED_FOREIGN + counts.skippedForeign++ + return } - val txid = row.txid.hexToByteArray() - require(txid.size == 32) { "engine inventory row carries a ${txid.size}-byte txid" } - if (database.txoDao().getByOutpoint(makeOutpoint(txid, row.vout)) != null) { - return HealOutcome.ALREADY_PRESENT + val outpoint = makeOutpoint(txid, row.vout) + if (database.txoDao().getByOutpoint(outpoint) != null) { + counts.alreadyPresent++ + return } - // Resolve the Room account from the tuple and stamp it on the - // healed row. Ownership must not depend on the address projection: - // the two things persistence loses together are the TXO and its - // address row, and a healed row with neither link is skipped by - // the restore loader at the next mirror-reload — recreating the - // fund loss the heal repaired. - val ownerAccountId = fetchAccount( - database, walletId, row.typeTag, row.index, row.standardTag, - row.registrationIndex, row.keyClass, - row.userIdentityId?.hexToByteArray() ?: ByteArray(32), - row.friendIdentityId?.hexToByteArray() ?: ByteArray(32), - )?.id - if (ownerAccountId == null) { - // Heal anyway — the address projection may still attribute it — - // but surface the unresolved owner: if the address row is also - // gone, this row will not survive the next mirror-reload. - Log.w( - TAG, - "txos reconcile: healing TXO with UNRESOLVED account " + - "(typeTag=${row.typeTag} index=${row.index} address=${row.address}) — " + - "ownership rides on the address projection alone", - ) + 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, - row.scriptHex.hexToByteArray(), row.height, + database, walletId, txid, row.vout, row.amount, row.address, script, row.height, isCoinbase = row.isCoinbase, - isConfirmed = row.isConfirmed, + isConfirmed = true, isInstantLocked = row.isInstantlocked, isLocked = row.isLocked, - resolvedAccountId = ownerAccountId, + 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 sweep), and adding the healed amount to an - // already-corrected net double-credits. The event pipeline owns - // net correctness. - return if (ownerAccountId == null) HealOutcome.HEALED_UNOWNED else HealOutcome.HEALED + // 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 + } } /** - * Classification pass of [reconcileTxos] (the widened scope from the - * #4425 / pre-#971 review). Inverted relative to the insert pass — it - * pages the STORE and asks the engine about each page — so that neither - * side has to hold a set over a whole inventory. - * - * Every query carries the account tuple the store files the coin under - * and the script the store recorded. Those 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 and why a row - * whose owning account this store cannot resolve is counted rather - * than asked about. + * 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. * - * Read-only by construction: it writes nothing, so it runs outside both - * the exclusion lock and any transaction. A row a concurrent callback - * moves under it is at worst a stale log line, and every verdict here - * is log-only anyway. + * 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. 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, at most + * [TXO_RECONCILE_MAX_STALE_RETRIES] times in a row before the run stops + * incomplete. * - * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. - * Production changeset writes leave txos.accountId null and route - * ownership through coreAddressId -> core_addresses.accountId, so the - * exclusion resolves BOTH paths — an accountId-only check silently - * classifies every contact row. + * 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, foreignAccountIds: Set, + isCancelled: () -> Boolean, classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, - ): ClassifyPass { - var wouldFlipSpent = 0 - var wouldFlipSpentDuffs = 0L - var wouldRemove = 0 - var wouldRemoveDuffs = 0L - var stuckSpent = 0 - var stuckSpentDuffs = 0L - var misattributed = 0 - var misattributedDuffs = 0L - var unresolvedAccount = 0 - var skippedForeign = 0 - var transportFailures = 0 - - // Owning Room account for a store row: the explicit link first, - // then the address projection production writes actually use. - suspend fun ownerAccount( - row: org.dashfoundation.dashsdk.persistence.entities.TxoEntity, - ): org.dashfoundation.dashsdk.persistence.entities.AccountEntity? { - row.accountId?.let { return database.accountDao().getById(it) } - val addr = row.coreAddressId ?: return null - val ownerId = database.coreAddressDao().getByAddress(addr)?.accountId ?: return null - return database.accountDao().getById(ownerId) - } + 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) { - val storeRows = database.txoDao().pageByWallet(walletId, after, limit) - if (storeRows.isEmpty()) break - after = storeRows.last().outpoint - - // Rows worth asking the engine about. A row still mid-insert - // (no txid yet) is not classifiable, a foreign row's absence - // from the engine is expected rather than divergence — counted, - // as before, only when it is unspent — and a row whose owning - // account cannot be resolved carries no claim to check. - val classifiable = - ArrayList( - storeRows.size, - ) - val queries = ArrayList(storeRows.size) - for (row in storeRows) { - if (row.txid == null || row.outpoint.size != OUTPOINT_BYTES) continue - val owner = ownerAccount(row) - if (owner != null && owner.id in foreignAccountIds) { - if (!row.isSpent) skippedForeign++ - continue + if (isCancelled()) { + tally.completed = false + return + } + val page = reconcileStoreStep(tally, walletRef, "unspent_page", isCancelled) { + readUnspentPage(walletId, after, limit, foreignAccountIds) + } ?: return + if (page.fetched == 0) return + tally.storeRows += page.rows.size + tally.unresolvedAccount += page.unresolvedAccount + tally.skippedForeign += page.skippedForeign + + if (page.rows.isNotEmpty()) { + 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 } - if (owner == null) { - // No account to name in the query, so the engine has - // nothing to check the claim against. Reported rather - // than guessed: the same missing link is what makes a - // healed row fail to survive the next mirror-reload. - if (!row.isSpent) unresolvedAccount++ + val counts = reconcileStoreStep(tally, walletRef, "flip", isCancelled) { + if (committedRoundGeneration != page.generation) { + FlipCounts().apply { staleGeneration = true } + } else { + database.withTransaction { applyEngineClasses(page.rows, verdicts) } + } + } ?: return + if (counts.staleGeneration) { + staleInARow++ + tally.staleRetries++ + if (staleInARow > TXO_RECONCILE_MAX_STALE_RETRIES) { + 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 + tally.skippedForeign -= page.skippedForeign continue } - classifiable.add(row) - queries.add( - 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 = row.txid.toHex(), - vout = row.vout, - scriptHex = row.scriptPubKey.toHex(), - ), - ) + 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) } - if (classifiable.isEmpty()) continue + after = page.lastOutpoint ?: return + if (page.fetched < limit) return + } + } - val verdicts = classifyOutpoints( - encodeOutpointQueries(queries), - ) - if (verdicts == null || verdicts.size != classifiable.size) { - // No verdicts, no classification. Log-only either way, so - // the sweep stops rather than guessing. - transportFailures++ - break + /** + * 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. + * + * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. + * Production changeset writes leave txos.accountId null and route + * ownership through coreAddressId -> core_addresses.accountId, so the + * owner resolves BOTH paths — an accountId-only check silently + * classifies every contact row. + */ + private suspend fun readUnspentPage( + walletId: ByteArray, + after: ByteArray, + limit: Int, + foreignAccountIds: Set, + ): UnspentPage { + val fetched = database.txoDao().pageUnspentByWallet(walletId, after, limit) + val rows = ArrayList(fetched.size) + val queries = ArrayList(fetched.size) + var unresolvedAccount = 0 + var skippedForeign = 0 + for (row in fetched) { + val txid = row.txid + if (txid == null || txid.size != 32 || row.outpoint.size != OUTPOINT_BYTES) continue + val owner = reconcileOwnerAccount(row) + if (owner == null) { + unresolvedAccount++ + continue } - for ((i, row) in classifiable.withIndex()) { - val verdict = verdicts[i] - val key = "${row.txid?.toHex()}:${row.vout}" - if (verdict == OUTPOINT_CLASS_NOT_OWNED) { - // The account this 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 — and the one verdict that says nothing - // about spentness, so it is reported for spent and - // unspent rows alike. - misattributed++ - misattributedDuffs += row.amount - Log.w( - TAG, - "txos reconcile: store row filed under an account that does " + - "not own its script outpoint=$key amount=${row.amount} " + - "accountId=${row.accountId} — LOG-ONLY, not re-filed", - ) - continue - } - if (row.isSpent) { - // Rows marked spent for coins the engine still lists - // unspent. Either lost-release residue (pre-#971) or a - // live spend racing the engine — never un-marked, only - // reported: un-marking a coin mid-payment would let the - // wallet double-spend it. - if (verdict == OUTPOINT_CLASS_UNSPENT) { - stuckSpent++ - stuckSpentDuffs += row.amount - Log.w( - TAG, - "txos reconcile: store row spent but engine lists it " + - "unspent outpoint=$key amount=${row.amount} — LOG-ONLY " + - "(lost release, or a live spend racing the engine)", - ) - } - continue - } - when (verdict) { - OUTPOINT_CLASS_UNSPENT -> {} - OUTPOINT_CLASS_KNOWN_UNCREDITED -> { - // Lost spend update (#4425), on durable evidence: - // this verdict requires the owning account to know - // the funding txid, to recognise the script, not to - // hold the coin, AND a funds account to hold a MINED - // record spending the outpoint — so unlike the old - // spent-set answer it cannot be a mempool artifact. - // Still LOG-ONLY here: flipping spent state is the - // one direction where a reconciler bug spends a - // coin the user still owns, and this pass is - // insert-only by contract. Acting on it is a - // separate, reviewable change. - wouldFlipSpent++ - wouldFlipSpentDuffs += row.amount - Log.w( - TAG, - "txos reconcile: store row unspent but the engine holds a " + - "mined record spending it outpoint=$key " + - "amount=${row.amount} — LOG-ONLY, not flipped", - ) - } - else -> { - // OUTPOINT_CLASS_UNKNOWN: the engine has no opinion. - // Swept/abandoned residue (pre-#971 stores), a - // funding transaction this session never processed, - // or an engine gap — and after a restart the - // finalized set is empty, so this is the common - // answer rather than the alarming one. Deliberately - // LOG-ONLY: removal by reconciliation is the one - // direction where a bug destroys user-visible data. - wouldRemove++ - wouldRemoveDuffs += row.amount - Log.w( - TAG, - "txos reconcile: engine has no verdict for store row " + - "outpoint=$key amount=${row.amount} — ambiguous " + - "(swept/abandoned residue, funding not seen this " + - "session, or a dropped engine record) — LOG-ONLY, " + - "not removed", - ) - } - } + if (owner.id in foreignAccountIds) { + skippedForeign++ + continue } + rows.add(row) + queries.add( + 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 = row.vout, + scriptHex = row.scriptPubKey.toHex(), + ), + ) } - return ClassifyPass( - wouldFlipSpent = wouldFlipSpent, - wouldFlipSpentDuffs = wouldFlipSpentDuffs, - wouldRemove = wouldRemove, - wouldRemoveDuffs = wouldRemoveDuffs, - stuckSpent = stuckSpent, - stuckSpentDuffs = stuckSpentDuffs, - misattributed = misattributed, - misattributedDuffs = misattributedDuffs, + return UnspentPage( + rows = rows, + queries = queries, + fetched = fetched.size, + lastOutpoint = fetched.lastOrNull()?.outpoint, unresolvedAccount = unresolvedAccount, skippedForeign = skippedForeign, - transportFailures = transportFailures, + generation = committedRoundGeneration, ) } + /** Owning Room account of a store row: the explicit link first, then + * the address projection production writes actually use. */ + private suspend fun reconcileOwnerAccount(row: TxoEntity): AccountEntity? { + row.accountId?.let { return database.accountDao().getById(it) } + val addr = row.coreAddressId ?: return null + val ownerId = database.coreAddressDao().getByAddress(addr)?.accountId ?: return null + return database.accountDao().getById(ownerId) + } + + /** + * 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 + * reference digests only — 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} skippedForeign=${report.skippedForeign} " + + "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") + } + } + override fun onWalletChangesetUtxoSpent( walletId: ByteArray, txid: ByteArray, @@ -4935,6 +5060,15 @@ class PlatformWalletPersistenceHandler( */ const val TXO_RECONCILE_PAGE_SIZE = 512 + /** Confirmations an engine coin needs before [reconcileTxos] inserts + * it: coinbase maturity, and well past any plausible reorg. */ + const val TXO_RECONCILE_MIN_CONFIRMATIONS = 100 + + /** Times in a row one classify page is classified again because a + * persistence round committed between its read and its apply, + * before [reconcileTxos] gives up on the run. */ + internal const val TXO_RECONCILE_MAX_STALE_RETRIES = 5 + /** * Decoder for the engine inventory transport. Deliberately the * strict default (`ignoreUnknownKeys = false`, no coercion): the JNI @@ -5181,6 +5315,23 @@ 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 SHA-256 — + * the Kotlin twin of Swift's `SDKLogger.reference`. Lets log lines from one + * run be correlated (same wallet, same coin) without carrying the wallet id, + * txid or outpoint itself. + */ +internal fun logReference(value: ByteArray): String = + java.security.MessageDigest.getInstance("SHA-256").digest(value).copyOf(6).toHex() + 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/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 98cccb9ab60..f218f3ef4a4 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 @@ -6813,10 +6813,9 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun reconcileSkipsImmatureOutputsAndPreservesSpentRows() = runTest { - // Immature: inside the 100-conf gate (flags on the engine snapshot - // can't carry coinbase/IS-lock, so fresh rows wait for a later - // sweep) — nothing inserted. + fun reconcileSkipsImmatureOutputsAndNeverUnmarksASpentRow() = runTest { + // Immature: inside the 100-conf 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), @@ -6827,8 +6826,7 @@ class PlatformWalletPersistenceHandlerTest { assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 0))) // A row the mirror already holds — even marked spent while the - // engine still lists it — is left untouched: reconcile is - // insert-only and never flips spend state. + // engine still lists it — is left untouched. assertEquals( 0, handler.onWalletChangesetUtxoAdded( @@ -6845,6 +6843,7 @@ class PlatformWalletPersistenceHandlerTest { tipHeight = reconcileTip, ) assertEquals(0, report.inserted) + assertEquals(1, report.alreadyPresent) assertTrue(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 2))!!.isSpent) } @@ -6862,34 +6861,213 @@ class PlatformWalletPersistenceHandlerTest { return """{"utxos":[$utxoRows],"spent":[$spentRows]}""" } + /** 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(), + ): String = + """{"typeTag":$typeTag,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """"amount":$amount,"address":"$address","scriptHex":"$scriptHex",""" + + """"height":$height,"isConfirmed":$isConfirmed,"isInstantlocked":false,""" + + """"isCoinbase":false,"isLocked":false}""" + + /** 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, + ), + ) + } + + 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, + tipHeight = reconcileTip, + pageSize = pageSize, + isCancelled = isCancelled, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) + + private val emptyEnginePage = """{"utxos":[],"cursor":null,"hasMore":false}""" + + // ── 1. Positive engine evidence marks a local unspent row spent ─── + @Test - fun reconcileLogsButNeverFlipsLostSpendRows() = runTest { - // A store row still marked unspent for a coin the engine records as - // spent (dashpay/platform#4425). The engine's spent set includes - // MEMPOOL spends and carries no context, so persisting the flip - // would settle an unconfirmed spend — counted and logged only. - handler.onWalletChangesetUtxoAdded( - walletId, changeTxid, 3, 500_000L, "yTestAddr", byteArrayOf(0x51), 1_400_000, - false, true, false, false, + 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, + ), + ), ) - val report = handler.reconcileFromInventory( - walletId, - engineInventoryJson(unspent = emptyList(), spent = listOf(changeTxid.toHexLower() to 3)), + assertEquals(1, restoredUtxoCount()) + val engine = FakeEngine(inventoryJson(spent = listOf(txid to 0))) + + val report = handler.reconcileWith(engine) + + 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, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> emptyEnginePage }, + 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, + ) + } + + @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 report = handler.reconcileTxos( + walletId = walletId, tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = { + classifyCalls++ + handler.onChangesetBegin(walletId) + handler.onChangesetEnd(walletId, success = true) + byteArrayOf(PlatformWalletPersistenceHandler.OUTPOINT_CLASS_KNOWN_UNCREDITED) + }, ) - assertEquals(1, report.wouldFlipSpent) - assertEquals(500_000L, report.wouldFlipSpentDuffs) - assertEquals(0, report.wouldRemove) - val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 3))!! - assertFalse("the row must stay unspent — the flip is log-only", row.isSpent) - assertNull(row.spendingTxid) + + val limit = PlatformWalletPersistenceHandler.TXO_RECONCILE_MAX_STALE_RETRIES + 1 + assertFalse(report.completed) + assertEquals(limit, report.staleRetries) + assertEquals(limit, classifyCalls) + assertEquals(0, report.flipped) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(txid, 0))!!.isSpent) } + // ── 2. Absence and no-opinion verdicts change nothing ───────────── + @Test - fun reconcileLogsButNeverRemovesEngineUnknownRows() = runTest { + fun reconcileLeavesRowsAbsentFromBothInventoriesAlone() = runTest { // A store row for a coin the engine has in NEITHER inventory — - // residue of a swept/abandoned transaction (pre-rust-dashcore#971 - // stores). Counted and logged, NEVER removed. + // 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, @@ -6899,21 +7077,44 @@ class PlatformWalletPersistenceHandlerTest { engineInventoryJson(unspent = emptyList(), spent = emptyList()), tipHeight = reconcileTip, ) - assertEquals(1, report.wouldRemove) - assertEquals(250_000L, report.wouldRemoveDuffs) - assertEquals(0, report.wouldFlipSpent) + 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) } + @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, tipHeight = reconcileTip) + + 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, and the old transport had no - // field to carry the truth in. + // the mirror the engine reloads from. val report = handler.reconcileFromInventory( walletId, engineUtxoJson( @@ -6932,40 +7133,13 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(row.isConfirmed) } - @Test - fun reconcileReportsRowsFiledUnderAnAccountThatDoesNotOwnThem() = 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. That is a store attribution error rather than a missing - // coin, and it is the one verdict that says nothing about - // spentness — so it is reported on its own counter 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, tipHeight = reconcileTip) - - assertEquals(1, report.misattributed) - assertEquals(777_000L, report.misattributedDuffs) - assertEquals("a not-owned row is not also counted as removable", 0, report.wouldRemove) - assertEquals(0, report.wouldFlipSpent) - val row = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 21))!! - assertFalse("the row is left alone — the verdict is log-only", row.isSpent) - assertEquals(777_000L, row.amount) - } - @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 — and the same missing link is - // what would stop the row surviving the next mirror-reload. + // counted rather than asked about. db.coreAddressDao().upsert( org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( address = "yOrphanAddr", @@ -6994,14 +7168,16 @@ class PlatformWalletPersistenceHandlerTest { ) assertEquals(1, report.unresolvedAccount) - assertEquals("an unasked row is not counted as engine-unknown", 0, report.wouldRemove) + 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) } + // ── 3. A consistent store stays untouched ───────────────────────── + @Test - fun reconcileReversePassIsSilentOnConsistentStore() = runTest { + fun reconcileClassifyPassIsSilentOnConsistentStore() = runTest { // Rows the engine also holds unspent — including a YOUNG coin the - // insert pass would skip as immature — are consistent, not - // divergence. Every reverse-pass counter must be zero. + // 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, @@ -7013,16 +7189,41 @@ class PlatformWalletPersistenceHandlerTest { """"height":${reconcileTip - 3},"isConfirmed":true,""" + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) - assertEquals(0, report.wouldFlipSpent) - assertEquals(0, report.wouldRemove) + 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 reconcileExcludesWatchOnlyContactRowsFromReversePass() = runTest { + 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 reconcileExcludesWatchOnlyContactRowsFromClassifyPass() = runTest { // Watch-only DIP-15 contact rows are never in the engine's - // inventories; flagging them would be a false positive on every + // inventories; asking about them would be a false positive on every // wallet with contact payments. db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) val foreignAccountId = db.accountDao().insert( @@ -7046,7 +7247,8 @@ class PlatformWalletPersistenceHandlerTest { tipHeight = reconcileTip, ) assertEquals(1, report.skippedForeign) - assertEquals(0, report.wouldRemove) + assertEquals(0, report.storeRows) + assertEquals(0, report.unknown) assertFalse(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!!.isSpent) } @@ -7055,7 +7257,7 @@ class PlatformWalletPersistenceHandlerTest { // Production changeset writes leave txos.accountId null and route // ownership through coreAddressId -> core_addresses.accountId. The // exclusion must resolve that path, or every contact row gets - // classified as divergence. + // classified. db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) val foreignAccountId = db.accountDao().insert( org.dashfoundation.dashsdk.persistence.entities.AccountEntity( @@ -7089,22 +7291,129 @@ class PlatformWalletPersistenceHandlerTest { tipHeight = reconcileTip, ) assertEquals(1, report.skippedForeign) - assertEquals(0, report.wouldRemove) + assertEquals(0, report.unknown) + } + + // ── 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 theHealPassRefusesImmatureUnconfirmedUnresolvedAndMalformedCoins() = runTest { + 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), // immature + engineRowJson(txid(0x77), height = reconcileTip - 99), // exactly 100 confirmations + // Deep enough, but the engine itself does not call it confirmed. + engineRowJson(txid(0x78), isConfirmed = false), + engineRowJson(txid(0x79), typeTag = 1), // CoinJoin: no store account + engineRowJson(txid(0x7a), scriptHex = ""), // no script + engineRowJson(txid(0x7b), address = ""), // no address + engineRowJson(txid(0x7c), height = 0), // unconfirmed height + engineRowJson(txid(0x7d), txidHex = "7d".repeat(31)), // 31-byte txid + ), + ), + ) + + val report = handler.reconcileWith(engine) + + assertTrue(report.completed) + assertEquals(8, report.engineRows) + assertEquals(1, report.inserted) + assertEquals(3, report.skippedImmature) + assertEquals(1, report.skippedUnresolvedAccount) + assertEquals(3, report.skippedInvalid) + assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(txid(0x77), 0))) + for (b in listOf(0x76, 0x78, 0x79, 0x7a, 0x7b, 0x7c)) { + 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 reconcileInsertPassNeverHealsContactAccountCoins() = runTest { - // The engine's UTXO inventory export includes the watch-only DIP-15 - // external accounts' coins — it tracks them to show payments TO - // contacts, but they are the CONTACT's money. With the foreign - // exclusion living only on the reverse pass, a fresh restore's - // post-backfill reconcile healed every contact-payment coin into the - // store as an ownerless row (12 rows / 5,692,493 duffs on the - // 2026-08-25 large-wallet validation run) while the reverse pass - // counted the very same rows as foreign — and the mirror-reload path - // hands such rows back to the engine on the next launch. The insert - // pass must skip any engine UTXO whose address resolves to an - // external account, and count it as foreign, not healed. + // The engine's inventory carries the watch-only DIP-15 external + // accounts' coins — it tracks them to show payments TO contacts, but + // they are the CONTACT's money (12 rows / 5,692,493 duffs healed into + // the store on the 2026-08-25 large-wallet validation run before + // this exclusion). An engine coin whose address resolves to an + // external account is skipped and counted as foreign, not healed. db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) val foreignAccountId = db.accountDao().insert( org.dashfoundation.dashsdk.persistence.entities.AccountEntity( @@ -7130,7 +7439,7 @@ class PlatformWalletPersistenceHandlerTest { """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + """"address":"yContactPaid","scriptHex":"51",""" + - """"height":1400000,"isLocked":false}],"spent":[]}""" + """"height":1400000,"isConfirmed":true,"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) @@ -7144,16 +7453,14 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconcileStampsResolvedAccountOnHealedRows() = runTest { - // The blocking scenario from review: persistence lost BOTH the TXO - // and its address row. The heal must resolve the owning Room account - // from the inventory's account tuple and stamp it on the inserted - // row — a healed row with neither accountId nor a resolvable address - // is skipped by the restore loader at the next mirror-reload, - // recreating the fund loss the heal repaired. + // 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 (the entity ctor default of an - // EMPTY array never occurs on persisted rows). + // 32-zero-byte identity ids verbatim. val bip44Id = db.accountDao().insert( org.dashfoundation.dashsdk.persistence.entities.AccountEntity( walletId = walletId, @@ -7171,7 +7478,7 @@ class PlatformWalletPersistenceHandlerTest { tipHeight = reconcileTip, ) assertEquals(1, report.inserted) - assertEquals(0, report.healedUnowned) + 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, @@ -7179,32 +7486,30 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun reconcileCountsHealsWhoseAccountCannotBeResolved() = runTest { - // No matching Room account row at all (a store damaged past the - // account registrations): the heal proceeds — the address projection - // may still attribute it — but the unresolved owner is surfaced. - db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) - val report = handler.reconcileFromInventory( - walletId, - engineUtxoJson(changeTxid.toHexLower(), vout = 12, amount = 5_000L), - tipHeight = reconcileTip, - ) - assertEquals(1, report.inserted) - assertEquals(1, report.healedUnowned) - assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))!!.accountId) + 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, tipHeight = reconcileTip) + assertEquals(0, report.inserted) + assertEquals(1, report.skippedUnresolvedAccount) + assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))) } @Test fun reconcileForeignSkipKeysOffTheInventoryTagWithoutAddressRow() = runTest { // The tag is the authoritative foreign check: a contact's coin must - // be skipped even when its address row never survived persistence - // (the case the address-based fallback cannot see). + // be skipped even when its address row never survived persistence. val json = """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,""" + """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + """"address":"yContactNoRow","scriptHex":"51",""" + - """"height":1400000,"isLocked":false}],"spent":[]}""" + """"height":1400000,"isConfirmed":true,"isLocked":false}],"spent":[]}""" val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) assertEquals(0, report.inserted) @@ -7212,13 +7517,13 @@ class PlatformWalletPersistenceHandlerTest { assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 13))) } + // ── 5. Never un-mark, never delete; idempotent; wallet-scoped ───── + @Test fun reconcileNeverUnmarksSpentRowsEvenWhenEngineDisagrees() = runTest { - // A row marked spent while the engine lists the coin unspent: either - // a lost release event (pre-rust-dashcore#971) or a live spend the - // store wrote before the engine settled. Un-marking a coin - // mid-payment would let the wallet double-spend it, so this is - // counted and logged but NEVER changed. + // 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, @@ -7234,38 +7539,139 @@ class PlatformWalletPersistenceHandlerTest { ), tipHeight = reconcileTip, ) - assertEquals(1, report.stuckSpent) - assertEquals(77_000L, report.stuckSpentDuffs) + 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, ) } - // ── Paged reconcile transport ───────────────────────────────────── + @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 sweep - // reads it a page at a time. Every page must be applied — a sweep - // that healed only the first one would leave most of a damaged - // mirror unrepaired, and silently. + // 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.reconcileTxos( - walletId = walletId, - tipHeight = reconcileTip, - pageSize = 2, - engineUtxoPage = engine::page, - classifyOutpoints = engine::classify, - )!! + val report = handler.reconcileWith(engine) assertEquals("3 pages for 5 rows at 2 per page", 3, engine.pages) - assertEquals(5, report.engineUtxos) + assertEquals(5, report.engineRows) assertEquals(5, report.inserted) assertEquals(5_000L, report.insertedDuffs) for (vout in 20 until 25) { @@ -7276,11 +7682,30 @@ class PlatformWalletPersistenceHandlerTest { } } + @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-sweep must not discard the pages that - // already landed — the pass is insert-only and idempotent, so they - // are already correct — and must not report a clean run either. + // 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) }, @@ -7295,48 +7720,39 @@ class PlatformWalletPersistenceHandlerTest { if (cursor == null) engine.page(cursor, limit) else null }, 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 reverse direction is inverted: the STORE is paged and the - // engine is asked about one page at a time, so neither side builds - // a set over a whole inventory. Every batch must still be answered - // and counted. + // 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, ) } - // These rows must carry a resolvable owner: a query names the - // account the store filed the coin under, and a row without one is - // counted rather than sent to the classifier. attributeUnownedTxos(walletId) val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) - val report = handler.reconcileTxos( - walletId = walletId, - tipHeight = reconcileTip, - pageSize = 1, - engineUtxoPage = engine::page, - classifyOutpoints = engine::classify, - )!! + 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.wouldRemove) - assertEquals(300L, report.wouldRemoveDuffs) + assertEquals(3, report.storeRows) + assertEquals(3, report.unknown) } @Test fun reconcileStopsWhenAClassificationBatchFails() = runTest { - // No verdicts, no classification: the reverse pass stops rather + // 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( @@ -7344,9 +7760,6 @@ class PlatformWalletPersistenceHandlerTest { false, true, false, false, ) } - // These rows must carry a resolvable owner: a query names the - // account the store filed the coin under, and a row without one is - // counted rather than sent to the classifier. attributeUnownedTxos(walletId) val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) val report = handler.reconcileTxos( @@ -7355,17 +7768,19 @@ class PlatformWalletPersistenceHandlerTest { pageSize = 1, engineUtxoPage = engine::page, classifyOutpoints = { null }, - )!! + ) + assertFalse(report.completed) assertEquals(1, report.transportFailures) - assertEquals(0, report.wouldRemove) + assertEquals(0, report.unknown) + assertEquals(0, report.flipped) } @Test - fun reconcileReturnsNoReportWhenTheFirstPageIsUnavailable() = runTest { - // No first page means nothing to reconcile against — the contract - // callers had before the sweep was paged, now enforced inside the - // handler rather than by a prefetch in the caller. + 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, @@ -7378,10 +7793,211 @@ class PlatformWalletPersistenceHandlerTest { ) }, ) - assertNull("a dead transport yields no report", report) + 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 for the account read, once before each + // engine read and once more before each store step: the first + // three admit the account read, page one and its heal; the + // fourth (before page two) reports the epoch bumped. + isCancelled = { ++checks >= 4 }, + ) + + 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, + tipHeight = reconcileTip, + pageSize = 1, + isCancelled = { cancelled }, + engineUtxoPage = { cursor, limit -> + engine.page(cursor, limit).also { cancelled = true } + }, + 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. + 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), + ), + 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, + tipHeight = reconcileTip, + engineUtxoPage = engine::page, + 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 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(), + ) + 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)) + } + } + + @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 @@ -7452,14 +8068,14 @@ class PlatformWalletPersistenceHandlerTest { /** * Drive the paged reconcile from one whole-inventory JSON blob — the - * shape these tests describe an engine in, and the shape the native - * side used to hand over in a single unbounded call. - * - * The blob is served the way the transport now serves it: sliced into - * bounded pages behind an opaque cursor, with a separate positional - * classifier for the outpoints the store asks about. [pageSize] - * defaults to 2, so a test describing more than a couple of rows walks - * the real cursor loop rather than a single page. + * 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, @@ -7470,32 +8086,29 @@ class PlatformWalletPersistenceHandlerTest { ): PlatformWalletPersistenceHandler.TxoReconcileReport { attributeUnownedTxos(walletId) val engine = FakeEngine(inventoryJson) - return checkNotNull( - reconcileTxos( - walletId = walletId, - tipHeight = tipHeight, - minConfirmations = minConfirmations, - pageSize = pageSize, - engineUtxoPage = engine::page, - classifyOutpoints = engine::classify, - ), - ) { "the fake engine always serves a first page" } + return reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + minConfirmations = minConfirmations, + pageSize = pageSize, + engineUtxoPage = engine::page, + classifyOutpoints = engine::classify, + ) } /** * File every still-unattributed TXO of [walletId] under a default * BIP44 account, seeding that account on first use. * - * The classification pass asks the engine about a row by naming the - * account the STORE filed it under — the engine checks that claim - * against its own pools rather than trusting it — so a row with no - * resolvable owner carries no claim and is counted instead of asked - * about. 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. + * 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)) @@ -7522,10 +8135,10 @@ class PlatformWalletPersistenceHandlerTest { * whole-inventory JSON a test writes out. Pages come back behind an * opaque ordinal cursor — the real cursor is opaque too, the handler * only ever hands back what it was given — and classification answers - * positionally out of the same two inventories: 1 unspent, - * 2 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. + * 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 @@ -7542,6 +8155,9 @@ class PlatformWalletPersistenceHandlerTest { var classified = 0 private set + /** Every classification query received, in order. */ + val queries = ArrayList() + init { val root = kotlinx.serialization.json.Json .parseToJsonElement(inventoryJson).jsonObject @@ -7578,12 +8194,13 @@ class PlatformWalletPersistenceHandlerTest { fun classify(queriesJson: String): ByteArray { batches++ - val queries = kotlinx.serialization.json.Json + val batch = kotlinx.serialization.json.Json .parseToJsonElement(queriesJson).jsonArray - classified += queries.size - val verdicts = ByteArray(queries.size) - for ((i, query) in queries.withIndex()) { + 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 @@ -7617,9 +8234,6 @@ class PlatformWalletPersistenceHandlerTest { val RUST_REQUIRED_QUERY_KEYS = listOf( "typeTag", "standardTag", "index", "registrationIndex", "keyClass", "txid", "vout", ) - - /** txid (32 bytes, wire order) + vout (4 bytes, little-endian). */ - const val OUTPOINT_SIZE = 36 } } From b646afff0e632a9830a062834727347bec92b67c Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 20:12:54 -0700 Subject: [PATCH 10/28] fix(kotlin-sdk): gate and schedule the TXO reconcile the way Swift does PlatformWalletManager.reconcileTxoStore(walletId) now follows Swift's reconcileCoreTxoStore(for:) from dashpay/platform#4638 and returns a TxoReconcileOutcome: Skipped(reason) or Reconciled(report). Gates, in order: manager closed; wallet not loaded; a run for it already in flight; SPV not in steady state (SYNCED, or WAIT_FOR_EVENTS with the filter phase at a non-zero target); no scan tip (filter height, else header height); syncFaultDetected(); the wallet's durable watermark (the Room wallet row's syncedHeight) more than 6 blocks behind the tip. The automatic trigger runs on the rising edge into steady state and then every 30 minutes per wallet, stamped at schedule time, never while that wallet's run is in flight. closeSuspending() and removeWallet() bump a reconcile epoch, and the run stops at its next page or store step with completed = false; the run also holds a teardown-gate borrow so the native manager is never destroyed under an engine read. Skip, transport-failure and run-failure lines carry the reason or the exception type and a wallet reference digest only; the cursor and native error messages are no longer logged. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/WalletManagerNative.kt | 4 +- .../dashsdk/wallet/PlatformWalletManager.kt | 280 ++++++++++-------- .../dashsdk/wallet/TxoReconcile.kt | 152 ++++++++++ .../dashsdk/wallet/TxoReconcileTest.kt | 146 +++++++++ 4 files changed, 456 insertions(+), 126 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcile.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt 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 4c0f6c0e77c..00e0d2a6390 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 @@ -159,8 +159,8 @@ internal object WalletManagerNative { * Paged, not swept whole: a wallet's UTXO count is chain-controlled * (anyone who knows a watched address can keep sending dust to it), so * a full-inventory read would let a remote party decide how much this - * process allocates on every SYNCED transition and every 30-minute - * pass. Pass [cursor] `null` to start, then hand back the returned + * process allocates on every steady-state transition and every + * 30-minute pass. Pass [cursor] `null` to start, then hand back the returned * `cursor` verbatim while `hasMore` is true — a cursor this export did * not produce throws. [limit] caps the rows in one page; non-positive * asks for the engine's own default, and the engine clamps its own 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 f881371fa91..76c29256b44 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 @@ -1023,6 +1024,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. + txoReconcile.bumpEpoch() + txoReconcile.forget(key) // 3. Snapshot → atomic secret delete → Room cascade, with both // persistence callbacks and app-side key writers excluded. @@ -1258,79 +1265,123 @@ class PlatformWalletManager( } /** - * Reconcile the Room `txos` mirror against the engine's live UTXO - * inventory, healing rows a changeset failed to deliver. The mirror is - * write-behind with no other feedback loop, and the engine is REBUILT - * from it on restart — an unhealed hole becomes a fund-loss on the next - * launch (the job-flower 106.43→86.33 restart drop: rescan - * nondeterministically drops the change outputs of sends funded from - * CoinJoin-account outputs). Insert-only; never flips spent state or - * deletes. + * 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. * - * Both directions of the sweep are paged, so neither this process nor - * the engine ever holds a whole wallet's inventory: UTXO counts are - * chain-controlled, and a periodic full-inventory read would let anyone - * who knows a watched address decide how much a phone allocates. See - * [PlatformWalletPersistenceHandler.reconcileTxos]. + * Gates, each a [TxoReconcileOutcome.Skipped] rather than an error, in + * order: the manager not closed; the wallet loaded; no run for it + * already in flight; SPV in steady state + * ([TxoReconcileGates.isSteadySyncState]); a scan tip + * ([TxoReconcileGates.scanTipHeight]); no latched sync fault + * ([syncFaultDetected] — a rejected round means rows are missing by + * design and a rescan is pending); and the wallet's own durable scan + * watermark — the Room wallet row's `syncedHeight`, which the engine + * persists through the changeset header — within + * [TxoReconcileGates.TIP_MARGIN] blocks of the tip: the tip says how far + * the CLIENT got, not how far this wallet's rows are committed, and a + * wallet added behind the tip is still being scanned. * - * Call it after the L1 scan settles and again on a slow cadence; - * [tipHeight] is the synced chain height — only outputs at least - * [minConfirmations] deep are healed (immature holes age into the next - * sweep). Returns null when the engine inventory read failed at the - * FIRST page: there is nothing to reconcile against, so there is no - * report to make. A page or classification batch failing later - * truncates the sweep instead, which the report's `transportFailures` - * records. Native failures surface through [mapNativeErrors] as - * exceptions; both transport lambdas turn them into the null the - * handler's truncate-and-report contract is written against (and log - * them), so a mid-sweep fault yields a partial report rather than - * discarding the sweep. A malformed page is NOT absorbed: the handler's - * strict decode throws, and that propagates. + * The run holds a [teardownGate] borrow, so the native manager is not + * destroyed under an engine read. [closeSuspending] and [removeWallet] + * bump the reconcile epoch first, and the run stops between pages, + * reporting `completed = false`. + * + * Runs automatically on the steady-state transition of the SPV progress + * poll and every 30 minutes while it lasts (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, - tipHeight: Int, - minConfirmations: Int = 100, - ): PlatformWalletPersistenceHandler.TxoReconcileReport? = - persistenceHandler.reconcileTxos( - walletId = walletId, - tipHeight = tipHeight, - minConfirmations = minConfirmations, - engineUtxoPage = { cursor, limit -> - withContext(Dispatchers.IO) { - runCatching { - mapNativeErrors { - WalletManagerNative.walletManagerUtxosPageJson( - managerHandle, walletId, cursor, limit, - ) - } - }.onFailure { t -> - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile: engine inventory page failed (cursor=$cursor)", - t, - ) - }.getOrNull() - } - }, - classifyOutpoints = { queriesJson -> - withContext(Dispatchers.IO) { - runCatching { - mapNativeErrors { - WalletManagerNative.walletManagerClassifyOutpoints( - managerHandle, walletId, queriesJson, - ) - } - }.onFailure { t -> - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile: outpoint classification failed", - t, - ) - }.getOrNull() + 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) + val key = walletId.toHex() + if (walletId.size != 32 || _wallets.value[key] == null) { + return skip(TxoReconcileSkipReason.WALLET_UNKNOWN) + } + if (!txoReconcile.tryBegin(key)) return skip(TxoReconcileSkipReason.ALREADY_RUNNING) + try { + val generation = txoReconcile.currentEpoch + val progress = _spvProgress.value + if (!TxoReconcileGates.isSteadySyncState(progress)) { + return skip(TxoReconcileSkipReason.NOT_STEADY_STATE) + } + val tipHeight = TxoReconcileGates.scanTipHeight(progress) + ?: return skip(TxoReconcileSkipReason.TIP_UNAVAILABLE) + return try { + teardownGate.withOp { + if (syncFaultDetected()) return@withOp skip(TxoReconcileSkipReason.SYNC_FAULT_DETECTED) + val syncedHeight = database.walletDao().getByWalletId(walletId)?.syncedHeight + ?: return@withOp skip(TxoReconcileSkipReason.WALLET_UNKNOWN) + if (syncedHeight.toLong() + TxoReconcileGates.TIP_MARGIN < tipHeight) { + return@withOp skip(TxoReconcileSkipReason.WALLET_BEHIND_TIP) + } + val isCancelled = { isClosed || txoReconcile.currentEpoch != generation } + if (isCancelled()) return@withOp skip(TxoReconcileSkipReason.MANAGER_CLOSED) + val report = persistenceHandler.reconcileTxos( + walletId = walletId, + tipHeight = tipHeight, + isCancelled = isCancelled, + engineUtxoPage = { cursor, limit -> + reconcileEngineRead("engine inventory page", walletRef) { + WalletManagerNative.walletManagerUtxosPageJson( + managerHandle, walletId, cursor, limit, + ) + } + }, + classifyOutpoints = { queriesJson -> + reconcileEngineRead("outpoint classification", walletRef) { + WalletManagerNative.walletManagerClassifyOutpoints( + managerHandle, walletId, queriesJson, + ) + } + }, + ) + txoReconcile.markRun(key, monotonicNowMs()) + TxoReconcileOutcome.Reconciled(report) } - }, - ) + } catch (closing: IllegalStateException) { + // The teardown gate refuses new borrows once close began. + if (isClosed) skip(TxoReconcileSkipReason.MANAGER_CLOSED) else throw closing + } + } finally { + txoReconcile.end(key) + } + } + + /** + * 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: @@ -2249,61 +2300,46 @@ class PlatformWalletManager( } } - private var lastTxoReconcileAtMs = 0L - private var txoReconcileWasSynced = false + /** In-flight runs, per-wallet cadence, the steady-state edge and the + * cancellation epoch of the TXO-store reconcile. */ + private val txoReconcile = TxoReconcileCoordinator() /** - * SDK-internal trigger for [reconcileTxoStore] — runs on the SYNCED - * transition of the SPV progress poll and again every - * [TXO_RECONCILE_INTERVAL_MS] while synced, for every loaded wallet. - * Lives here rather than in the host apps so Android and iOS-parity - * hosts both get the heal without wiring anything: the mirror hole it - * repairs (rescan dropping change outputs of CoinJoin-funded sends) - * becomes a fund-loss on the next engine reload if any host forgets - * to call it. Failures are logged and re-tried on the next cadence - * tick — never allowed to kill the progress poll. + * SDK-internal trigger for [reconcileTxoStore], fed by every accepted + * read of the SPV progress poll: run every loaded wallet on the + * transition into steady state, and again every 30 minutes per wallet + * while it lasts (each wallet's last run is stamped when it is + * scheduled) — never while a run for that wallet is in flight, and + * never inline: the poll tick stays cheap. 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 on the next cadence tick — never allowed to kill + * the progress poll. */ private fun maybeReconcileTxoStores(progress: SpvSyncProgressData) { - val synced = progress.overallState == SpvSyncState.SYNCED - val transitioned = synced && !txoReconcileWasSynced - txoReconcileWasSynced = synced - if (!synced) return - val now = System.currentTimeMillis() - if (!transitioned && now - lastTxoReconcileAtMs < TXO_RECONCILE_INTERVAL_MS) return - // The filter sub-phase is the wallet-relevant height; if dash-spv - // reports SYNCED without one (filter sync disabled, or the phase - // dropped after completion) fall back to the header tip rather than - // silently skipping the heal this exists to deliver. - val tipHeight = ( - progress.filters?.currentHeight?.takeIf { it > 0L } - ?: progress.headers?.currentHeight - ?: 0L - ).toInt() - if (tipHeight <= 0) { - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile skipped: SYNCED progress carries no tip height " + - "(filters=${progress.filters?.currentHeight} headers=${progress.headers?.currentHeight})", - ) - return - } - val walletIds = wallets.value.values.map { it.walletId } - if (walletIds.isEmpty()) return - lastTxoReconcileAtMs = now - scope.launch { - for (walletId in walletIds) { - runCatching { reconcileTxoStore(walletId, tipHeight) } - .onFailure { t -> - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile failed for wallet ${walletId.toHex()}", - t, - ) - } + if (isClosed) return + val loaded = wallets.value + val due = txoReconcile.walletsDue(progress, loaded.keys, monotonicNowMs()) + for (key in due) { + 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)}", + ) + } } } } + private fun monotonicNowMs(): Long = System.nanoTime() / 1_000_000 + // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2730,6 +2766,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. + txoReconcile.bumpEpoch() val bundle = bundleRef.getAndSet(0) if (bundle == 0L) return // NonCancellable from the moment the bundle is claimed: bundleRef @@ -2809,14 +2849,6 @@ class PlatformWalletManager( /** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */ const val POLL_INTERVAL_MS = 1_000L - /** - * Cadence of the steady-state TXO-store reconcile - * ([maybeReconcileTxoStores]) while SPV reports SYNCED. The - * SYNCED transition itself always triggers a pass regardless of - * this interval. - */ - const val TXO_RECONCILE_INTERVAL_MS = 30 * 60 * 1_000L - /** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */ const val PWFFI_INVALID_PARAMETER = 2 } diff --git a/packages/kotlin-sdk/sdk/src/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..40ffa3c8c1c --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcile.kt @@ -0,0 +1,152 @@ +package org.dashfoundation.dashsdk.wallet + +import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler +import java.util.concurrent.atomic.AtomicLong + +/** + * 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. */ +enum class TxoReconcileSkipReason { + /** The manager is closed or closing. */ + MANAGER_CLOSED, + + /** The wallet is not loaded in this manager (or has no Room row). */ + WALLET_UNKNOWN, + + /** A run for this wallet is already in flight. */ + ALREADY_RUNNING, + + /** SPV is not in its steady state (see [TxoReconcileGates.isSteadySyncState]). */ + NOT_STEADY_STATE, + + /** The progress carries no scan tip height. */ + TIP_UNAVAILABLE, + + /** The native manager latched a sync fault: rows are missing by design + * and a rescan is pending ([PlatformWalletManager.syncFaultDetected]). */ + SYNC_FAULT_DETECTED, + + /** The wallet's durable scan watermark trails the scan tip by more + * than [TxoReconcileGates.TIP_MARGIN] blocks — it is still being + * scanned. */ + WALLET_BEHIND_TIP, +} + +/** The pure gates of the TXO-store reconcile, testable without a manager. */ +internal object TxoReconcileGates { + /** How far the wallet's durable scan watermark may trail the scan tip + * for the scan to count as complete for this wallet. */ + const val TIP_MARGIN = 6 + + /** Cadence of the automatic run while SPV stays in steady state. */ + const val CADENCE_MS = 30 * 60 * 1_000L + + /** + * dash-spv's steady state for a fully synced client is + * [SpvSyncState.WAIT_FOR_EVENTS] with the filter phase at its target + * height; [SpvSyncState.SYNCED] is the transient window before it. + * Either counts — anything else is a scan still running (or not + * running at all, which the poll publishes as + * [SpvSyncProgressData.EMPTY]: waiting, with no filter phase). + */ + fun isSteadySyncState(progress: SpvSyncProgressData): Boolean = + when (progress.overallState) { + SpvSyncState.SYNCED -> true + SpvSyncState.WAIT_FOR_EVENTS -> { + val filters = progress.filters + filters != null && filters.targetHeight > 0 && filters.currentHeight >= filters.targetHeight + } + SpvSyncState.WAITING_FOR_CONNECTIONS, SpvSyncState.SYNCING, SpvSyncState.ERROR -> false + } + + /** + * The scan tip: the filter phase's height (the wallet-relevant one), + * falling back to the header tip when the filter phase is absent; null + * when neither carries a height. + */ + fun scanTipHeight(progress: SpvSyncProgressData): Int? { + val height = progress.filters?.currentHeight?.takeIf { it > 0 } + ?: progress.headers?.currentHeight?.takeIf { it > 0 } + ?: return null + return height.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } +} + +/** + * The manager-side state of the TXO-store reconcile — the Kotlin twin of + * Swift's `coreTxoReconcileInFlight` / `coreTxoReconcileLastRunAt` / + * `coreTxoReconcileWasSteady` / `coreTxoReconcileEpoch`. Thread-safe: the + * progress poll, scheduled runs and host calls all reach it. + */ +internal class TxoReconcileCoordinator(private val cadenceMs: Long = TxoReconcileGates.CADENCE_MS) { + private val lock = Any() + private var wasSteady = false + private val inFlight = HashSet() + private val lastRunAtMs = HashMap() + private val epoch = AtomicLong() + + /** + * Bumped by manager close and by wallet deletion; a run captures it at + * start and stops between pages once it moves. + */ + val currentEpoch: Long get() = epoch.get() + + fun bumpEpoch() { + epoch.incrementAndGet() + } + + /** + * Feed one progress read (every accepted read, not only a changed one: + * it is the reconcile's only clock, and a quiet steady-state wallet's + * progress does not change for the whole cadence). Returns the wallets + * to run now — all of [walletKeys] on the rising edge into steady + * state, and afterwards each wallet whose last run is at least the + * cadence old (or that never ran) — never one already in flight. Each + * returned wallet is stamped NOW, at schedule time, so the next tick + * does not schedule it again while this run is still gating. + */ + fun walletsDue(progress: SpvSyncProgressData, walletKeys: Collection, nowMs: Long): List = + synchronized(lock) { + val steady = TxoReconcileGates.isSteadySyncState(progress) + val rising = steady && !wasSteady + wasSteady = steady + if (!steady) return@synchronized emptyList() + walletKeys.filter { key -> + if (key in inFlight) return@filter false + val last = lastRunAtMs[key] + val due = rising || last == null || nowMs - last >= cadenceMs + if (due) lastRunAtMs[key] = nowMs + due + } + } + + /** Claim the wallet for one run; false when a run is already in flight. */ + fun tryBegin(walletKey: String): Boolean = synchronized(lock) { inFlight.add(walletKey) } + + /** Release the claim [tryBegin] took. */ + fun end(walletKey: String) { + synchronized(lock) { inFlight.remove(walletKey) } + } + + /** Record a completed run of the wallet for the cadence. */ + fun markRun(walletKey: String, nowMs: Long) { + synchronized(lock) { lastRunAtMs[walletKey] = nowMs } + } + + /** Forget a deleted wallet's cadence. */ + fun forget(walletKey: String) { + synchronized(lock) { lastRunAtMs.remove(walletKey) } + } +} 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..b27f53f0c71 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt @@ -0,0 +1,146 @@ +package org.dashfoundation.dashsdk.wallet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The manager-side gates and trigger of the TXO-store reconcile — port of + * the steady-state and scheduling cases of Swift's `CoreTxoReconcileTests` + * / `CoreTxoReconcileShutdownTests` (dashpay/platform#4638). Pure: no + * native manager is needed. + */ +class TxoReconcileTest { + + private fun progress( + state: SpvSyncState, + filters: Pair?, + headers: Long = 2_535_898, + ) = SpvSyncProgressData( + overallState = state, + overallPercentage = 0.0, + headers = SpvSubProgress(state, headers, headers, 1.0), + filterHeaders = null, + filters = filters?.let { (current, target) -> SpvSubProgress(state, current, target, 0.0) }, + masternodes = null, + ) + + private val steady = progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 2_535_898L to 2_535_898L) + private val syncing = progress(SpvSyncState.SYNCING, filters = 199_999L to 2_535_898L) + + @Test + fun theSteadyStateGateRefusesAnUnfinishedScan() { + assertFalse(TxoReconcileGates.isSteadySyncState(syncing)) + assertFalse(TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAITING_FOR_CONNECTIONS, filters = null))) + assertFalse( + TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.ERROR, filters = 2_535_898L to 2_535_898L)), + ) + assertFalse( + "waiting for events with the filter phase behind its target is a scan still running", + TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 2_535_800L to 2_535_898L)), + ) + assertFalse( + "no filter phase at all proves nothing", + TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = null)), + ) + assertFalse( + "a filter phase with no target proves nothing", + TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 0L to 0L)), + ) + assertFalse( + "the poll's not-running placeholder is not steady", + TxoReconcileGates.isSteadySyncState(SpvSyncProgressData.EMPTY), + ) + assertTrue(TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.SYNCED, filters = 2_535_898L to 2_535_898L))) + assertTrue("dash-spv's fully synced steady state", TxoReconcileGates.isSteadySyncState(steady)) + } + + @Test + fun theTipIsTheFilterHeightFallingBackToTheHeaderTip() { + assertEquals(2_535_898, TxoReconcileGates.scanTipHeight(steady)) + assertEquals( + "falls back to the header tip", + 2_535_898, + TxoReconcileGates.scanTipHeight(progress(SpvSyncState.SYNCED, filters = null)), + ) + assertEquals( + "a filter phase at height 0 falls back too", + 2_535_000, + TxoReconcileGates.scanTipHeight(progress(SpvSyncState.SYNCED, filters = 0L to 0L, headers = 2_535_000)), + ) + assertNull(TxoReconcileGates.scanTipHeight(SpvSyncProgressData.EMPTY)) + } + + @Test + fun everyLoadedWalletRunsOnTheRisingEdgeIntoSteadyState() { + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) + val wallets = listOf("a", "b") + + assertEquals(emptyList(), coordinator.walletsDue(syncing, wallets, nowMs = 0)) + assertEquals(wallets, coordinator.walletsDue(steady, wallets, nowMs = 10)) + assertEquals( + "the next tick inside the cadence schedules nothing", + emptyList(), + coordinator.walletsDue(steady, wallets, nowMs = 20), + ) + } + + @Test + fun aSteadyClientRunsEachWalletAgainOnItsOwnCadence() { + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) + assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) + // A wallet loaded while steady never ran: it is due at once, on its + // own clock. + assertEquals(listOf("b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 500)) + assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 1_000)) + assertEquals(listOf("b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 1_500)) + // A completed run restarts that wallet's cadence. + coordinator.markRun("a", nowMs = 1_900) + assertEquals(emptyList(), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 2_000)) + assertEquals(listOf("a", "b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 2_900)) + } + + @Test + fun aWalletWithARunInFlightIsNeverScheduledOrStartedAgain() { + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) + assertTrue(coordinator.tryBegin("a")) + assertFalse("a second run for the same wallet is refused", coordinator.tryBegin("a")) + assertEquals( + "not even on the rising edge", + listOf("b"), + coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 0), + ) + coordinator.end("a") + assertTrue(coordinator.tryBegin("a")) + } + + @Test + fun leavingSteadyStateArmsTheNextRisingEdge() { + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000_000) + assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) + assertEquals(emptyList(), coordinator.walletsDue(syncing, listOf("a"), nowMs = 10)) + assertEquals( + "back in steady state: due again, cadence or not", + listOf("a"), + coordinator.walletsDue(steady, listOf("a"), nowMs = 20), + ) + } + + @Test + fun closeAndDeletionStaleTheEpochAndDeletionForgetsTheCadence() { + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) + val before = coordinator.currentEpoch + coordinator.bumpEpoch() + assertTrue("an in-flight run sees its epoch move", coordinator.currentEpoch != before) + + assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) + coordinator.forget("a") + assertEquals( + "a re-added wallet is due at once", + listOf("a"), + coordinator.walletsDue(steady, listOf("a"), nowMs = 10), + ) + } +} From 420996c25609e4a12015bfccf4b874bcbd6bd10c Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 2 Oct 2026 21:05:23 -0700 Subject: [PATCH 11/28] fix(kotlin-sdk): gate the TXO reconcile on the engine's wallet watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconcile skipped itself at the SYNCED edge of a restore (WALLET_BEHIND_TIP) and, stamped at schedule time, did not try again for 30 minutes. The gate read the wallet's persisted Room syncedHeight, which trails the engine by up to one persistence round — exactly the window the SYNCED transition lands in. Swift gates on the engine's own value (platform_wallet_core_wallet_state). Bind it — one JNI export over that one FFI call — and gate on it, through a pure TxoReconcileGates.walletCaughtUp with its own test. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/WalletManagerNative.kt | 14 +++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 27 ++++++++++---- .../dashsdk/wallet/TxoReconcile.kt | 10 +++++ .../dashsdk/wallet/TxoReconcileTest.kt | 16 ++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 37 +++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 00e0d2a6390..5e9d3fd17f9 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 @@ -214,6 +214,20 @@ internal object WalletManagerNative { queriesJson: String, ): ByteArray? + /** + * The wallet's core scan watermark as the ENGINE holds it + * (`platform_wallet_core_wallet_state`'s `synced_height`) — the value the + * TXO-store reconcile gates on, as Swift's does. The persisted + * `syncedHeight` in Room trails it by up to one persistence round, which + * is exactly the window the SYNCED transition lands in. Throws + * [org.dashfoundation.dashsdk.errors.DashSdkError] `NotFound` when the + * manager does not hold the wallet. + */ + external fun walletManagerCoreWalletSyncedHeight( + managerHandle: Long, + walletId: ByteArray, + ): 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/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 76c29256b44..09dc20c7e1e 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 @@ -1280,12 +1280,16 @@ class PlatformWalletManager( * ([TxoReconcileGates.isSteadySyncState]); a scan tip * ([TxoReconcileGates.scanTipHeight]); no latched sync fault * ([syncFaultDetected] — a rejected round means rows are missing by - * design and a rescan is pending); and the wallet's own durable scan - * watermark — the Room wallet row's `syncedHeight`, which the engine - * persists through the changeset header — within + * design and a rescan is pending); and the wallet's own scan watermark + * as the engine holds it + * ([WalletManagerNative.walletManagerCoreWalletSyncedHeight], Swift's + * `platform_wallet_core_wallet_state`) within * [TxoReconcileGates.TIP_MARGIN] blocks of the tip: the tip says how far - * the CLIENT got, not how far this wallet's rows are committed, and a - * wallet added behind the tip is still being scanned. + * the CLIENT got, and a wallet added behind the tip is still being + * scanned. Not the persisted Room `syncedHeight`: that copy trails the + * engine by up to one persistence round, and the SYNCED transition lands + * inside that window — gating on it skipped the run at the end of every + * restore. * * The run holds a [teardownGate] borrow, so the native manager is not * destroyed under an engine read. [closeSuspending] and [removeWallet] @@ -1324,9 +1328,16 @@ class PlatformWalletManager( return try { teardownGate.withOp { if (syncFaultDetected()) return@withOp skip(TxoReconcileSkipReason.SYNC_FAULT_DETECTED) - val syncedHeight = database.walletDao().getByWalletId(walletId)?.syncedHeight - ?: return@withOp skip(TxoReconcileSkipReason.WALLET_UNKNOWN) - if (syncedHeight.toLong() + TxoReconcileGates.TIP_MARGIN < tipHeight) { + val walletSyncedHeight = try { + withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerCoreWalletSyncedHeight(managerHandle, walletId) + } + } + } catch (notFound: DashSdkError.PlatformWallet.NotFound) { + return@withOp skip(TxoReconcileSkipReason.WALLET_UNKNOWN) + } + if (!TxoReconcileGates.walletCaughtUp(walletSyncedHeight, tipHeight)) { return@withOp skip(TxoReconcileSkipReason.WALLET_BEHIND_TIP) } val isCancelled = { isClosed || txoReconcile.currentEpoch != generation } 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 index 40ffa3c8c1c..ee8182bf7e0 100644 --- 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 @@ -53,6 +53,16 @@ internal object TxoReconcileGates { /** Cadence of the automatic run while SPV stays in steady state. */ const val CADENCE_MS = 30 * 60 * 1_000L + /** + * Whether the wallet's own scan watermark (the engine's, see + * [org.dashfoundation.dashsdk.ffi.WalletManagerNative.walletManagerCoreWalletSyncedHeight]) + * is within [TIP_MARGIN] blocks of the scan tip — Swift's + * `synced_height + margin >= tip`. The tip says how far the CLIENT got; + * a wallet added behind it is still being scanned. + */ + fun walletCaughtUp(walletSyncedHeight: Long, tipHeight: Int): Boolean = + walletSyncedHeight >= 0 && walletSyncedHeight + TIP_MARGIN >= tipHeight + /** * dash-spv's steady state for a fully synced client is * [SpvSyncState.WAIT_FOR_EVENTS] with the filter phase at its target 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 index b27f53f0c71..b65d846c0e7 100644 --- 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 @@ -143,4 +143,20 @@ class TxoReconcileTest { coordinator.walletsDue(steady, listOf("a"), nowMs = 10), ) } + + @Test + fun theWalletGateUsesTheEnginesWatermarkWithinTheTipMargin() { + // Swift's `synced_height + margin >= tip`. The value passed is the + // ENGINE's watermark: the persisted Room copy trails it by a round, + // and gating on that skipped the run at the SYNCED edge of a restore + // (2026-10-02 device run: WALLET_BEHIND_TIP at the transition, the + // next attempt 30 minutes later). + assertTrue(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_211L, tipHeight = 1_565_211)) + assertTrue(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_205L, tipHeight = 1_565_211)) + assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_204L, tipHeight = 1_565_211)) + // A wallet added behind the tip is still being scanned. + assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 0L, tipHeight = 1_565_211)) + // The native "no value" sentinel never passes. + assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = -1L, tipHeight = 3)) + } } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index fc23f74599f..400d153310d 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -2660,6 +2660,43 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_s }) } +/// The wallet's core scan watermark as the ENGINE holds it +/// (`platform_wallet_core_wallet_state`'s `synced_height`) — the value the +/// TXO-store reconcile gates on, as Swift's does. The persisted copy in the +/// host store trails it by up to one persistence round, which is exactly +/// the window the SYNCED transition lands in. Throws `NotFound` when the +/// manager does not hold the wallet. Backs +/// `PlatformWalletManager.reconcileTxoStore`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerCoreWalletSyncedHeight( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, +) -> jlong { + guard(&mut env, -1, |env| { + let Some(wid) = read_id32(env, &wallet_id) else { + return -1; + }; + let mut state = platform_wallet_ffi::CoreWalletStateFFI { + synced_height: 0, + last_processed_height: 0, + monitor_revision: 0, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_core_wallet_state( + manager_handle as Handle, + wid.as_ptr(), + &mut state as *mut platform_wallet_ffi::CoreWalletStateFFI, + ) + }; + if take_pwffi_error(env, result) { + return -1; + } + jlong::from(state.synced_height) + }) +} + #[cfg(feature = "shielded")] sync_start_stop!( Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_shieldedSyncStart, From e6ad3a45349fee5418dc38194a470e2d96d160da Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 10 Oct 2026 16:12:26 -0700 Subject: [PATCH 12/28] fix(kotlin-sdk): resolve reconcile TXO owners the way the restore does The classify pass resolved a row's owner through the explicit accountId link or coreAddressId only, while buildUtxoRestoreData falls back to the row's own address. upsertUtxoRow leaves coreAddressId null when the address row does not exist yet, and the address-pool callback that inserts it later does not backfill the TXO, so such a row was restored into the engine at every launch while every reconcile run counted it unresolved, even when the engine proved it spent. Both paths now share one resolver: the explicit link, then the address projection keyed by coreAddressId or, failing that, the row's own non-empty address. Adds a regression test where the address row lands after the TXO and a known-uncredited verdict flips the row out of the restore set. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 44 +++++++++++------- .../PlatformWalletPersistenceHandlerTest.kt | 45 +++++++++++++++++++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 3d79d2cb628..6cfb4759497 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 @@ -1982,9 +1982,9 @@ class PlatformWalletPersistenceHandler( * * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. * Production changeset writes leave txos.accountId null and route - * ownership through coreAddressId -> core_addresses.accountId, so the - * owner resolves BOTH paths — an accountId-only check silently - * classifies every contact row. + * ownership through the address projection, so the owner resolves + * BOTH paths ([resolveTxoOwnerAccount]) — an accountId-only check + * silently classifies every contact row. */ private suspend fun readUnspentPage( walletId: ByteArray, @@ -1997,10 +1997,11 @@ class PlatformWalletPersistenceHandler( val queries = ArrayList(fetched.size) var unresolvedAccount = 0 var skippedForeign = 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 = reconcileOwnerAccount(row) + val owner = resolveTxoOwnerAccount(row, accountByAddress) if (owner == null) { unresolvedAccount++ continue @@ -2038,13 +2039,28 @@ class PlatformWalletPersistenceHandler( ) } - /** Owning Room account of a store row: the explicit link first, then - * the address projection production writes actually use. */ - private suspend fun reconcileOwnerAccount(row: TxoEntity): AccountEntity? { - row.accountId?.let { return database.accountDao().getById(it) } - val addr = row.coreAddressId ?: return null - val ownerId = database.coreAddressDao().getByAddress(addr)?.accountId ?: return null - return database.accountDao().getById(ownerId) + /** + * 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) } + } } /** @@ -4194,11 +4210,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 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 f218f3ef4a4..7a8799025af 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 @@ -7172,6 +7172,51 @@ class PlatformWalletPersistenceHandlerTest { 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 From 4035de5ec17403c59472d81f7eb999f4754966ef Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 10 Oct 2026 16:13:35 -0700 Subject: [PATCH 13/28] fix(kotlin-sdk): key reconcile log references with a per-process secret logReference was a plain SHA-256 prefix of the outpoint (and of the wallet id), so anyone holding diagnostic logs could hash public outpoints, match the per-item lines, and use the wallet reference on the same line to group the matches into one wallet's history. References are now the first six bytes of an HMAC-SHA256 under a 32-byte SecureRandom key drawn once per process on first use and never logged. They keep their 12-hex-character shape and still correlate within one run, but match no digest computable outside the process and do not correlate across runs. The privacy test now also rejects the unkeyed digests of the logged outpoints and wallet id, and a new test pins stability within the process and non-equality with SHA-256. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 31 +++++++++++++--- .../PlatformWalletPersistenceHandlerTest.kt | 35 ++++++++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 6cfb4759497..65657b04fd3 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 @@ -2144,7 +2144,8 @@ class PlatformWalletPersistenceHandler( } /** One line per healed / healed-spent / flipped row: the action and - * reference digests only — never the outpoint, address or amount. */ + * 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") } @@ -5336,13 +5337,33 @@ internal fun String.hexToByteArrayOrNull(): ByteArray? = } /** - * A log reference for [value]: the first 12 hex characters of its SHA-256 — - * the Kotlin twin of Swift's `SDKLogger.reference`. Lets log lines from one - * run be correlated (same wallet, same coin) without carrying the wallet id, + * 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 = - java.security.MessageDigest.getInstance("SHA-256").digest(value).copyOf(6).toHex() + 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" } 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 7a8799025af..b50df22aeb6 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 @@ -7964,7 +7964,9 @@ class PlatformWalletPersistenceHandlerTest { // 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. + // 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() } @@ -8004,6 +8006,11 @@ class PlatformWalletPersistenceHandlerTest { 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, @@ -8015,6 +8022,9 @@ class PlatformWalletPersistenceHandlerTest { "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,}") @@ -8027,6 +8037,29 @@ class PlatformWalletPersistenceHandlerTest { } } + /** 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() From 94c915a61ff29fc4e1e47ec2cd965cfe5f854fea Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 10 Oct 2026 16:19:27 -0700 Subject: [PATCH 14/28] test(unified-sdk-jni): pin the reconcile transport to a shared golden fixture The Kotlin reconcile tests drive a FakeEngine with handwritten inventory JSON and a separately maintained list of the keys the Rust query deserializer requires, and nothing in Rust exercised the serde models, the identity conversion or the cursor reconstruction, so the two codecs could disagree without any test noticing. One checked-in fixture, kotlin-sdk/sdk/src/test/resources/golden/ txo_reconcile_transport_v1.json, is now read by both sides. The Rust tests (include_str! of that file) serialize the real UtxoPage for four accessor entries and require the fixture page exactly: a zero-valued BIP44 account, non-default account fields with every flag set and an amount past 32 bits, a registration index with no address form, and a DashPay account with both identity halves. They decode the fixture's cursor back into the resume point, round-trip a cursor carrying every account field, reject cursors this layer did not emit, decode the fixture's classification queries through the real OutpointQuery conversion back to the same coins, fail a batch missing any required field, and reject a malformed identity id, txid or script by name. The Kotlin tests decode the fixture page with the production decoder and build the fixture queries with the production encoder. To make that testable without a JVM the row, page, cursor and query conversions move out of the two JNI exports into small functions on the serde types; the exports behave as before. On the Kotlin side the page decode and the query construction get the same treatment. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 47 +- .../PlatformWalletPersistenceHandlerTest.kt | 105 +++ .../golden/txo_reconcile_transport_v1.json | 123 ++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 680 +++++++++++++++--- 4 files changed, 824 insertions(+), 131 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json 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 65657b04fd3..0dca5d22b72 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 @@ -1716,7 +1716,7 @@ class PlatformWalletPersistenceHandler( // Strict decode: an unknown key, a missing required field or a // mistyped value throws — the row would otherwise be healed with // a defaulted owner or a zero amount. - val page = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + val page = decodeEngineUtxoPage(pageJson) tally.engineRows += page.utxos.size if (page.utxos.isNotEmpty()) { @@ -2011,22 +2011,7 @@ class PlatformWalletPersistenceHandler( continue } rows.add(row) - queries.add( - 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 = row.vout, - scriptHex = row.scriptPubKey.toHex(), - ), - ) + queries.add(outpointQueryFor(owner, txid, row.vout, row.scriptPubKey)) } return UnspentPage( rows = rows, @@ -5090,6 +5075,10 @@ class PlatformWalletPersistenceHandler( */ private val engineJson = Json + /** One page of `walletManagerUtxosPageJson`, through [engineJson]. */ + internal fun decodeEngineUtxoPage(pageJson: String): EngineUtxoPage = + engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) + /** One classification batch, as `walletManagerClassifyOutpoints` * deserializes it (rs-unified-sdk-jni `OutpointQuery`). */ internal fun encodeOutpointQueries(queries: List): String = @@ -5098,6 +5087,30 @@ class PlatformWalletPersistenceHandler( 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 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 b50df22aeb6..e4ef30e2d45 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 @@ -8144,6 +8144,111 @@ class PlatformWalletPersistenceHandlerTest { 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 accessor + * entries 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + ), + 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 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..0a6a151d66c --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/resources/golden/txo_reconcile_transport_v1.json @@ -0,0 +1,123 @@ +{ + "_about": "Golden TXO-reconcile transport payloads, read by rs-unified-sdk-jni (wallet_manager::tests) and the Kotlin PlatformWalletPersistenceHandlerTest. inventoryPage is exactly what walletManagerUtxosPageJson emits for the four accessor entries the Rust test builds; outpointQueries is exactly what the Kotlin reconcile sends walletManagerClassifyOutpoints 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 + }, + { + "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 + }, + { + "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 + }, + { + "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 + } + ], + "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-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 400d153310d..96dafdddf73 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3270,7 +3270,7 @@ fn core_selection_strategy( /// 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(serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct UtxoAccountTuple { type_tag: u8, @@ -3337,6 +3337,45 @@ struct UtxoPageRow { is_locked: bool, } +impl UtxoPageRow { + /// The row for one accessor entry, carrying the engine's own address + /// and flags. + /// + /// # Safety + /// + /// `e.address` must be null or a valid NUL-terminated string, and + /// `e.script_pubkey` null or valid for `e.script_pubkey_len` bytes — + /// the accessor's contract for every entry it returns. + unsafe fn from_entry(e: &platform_wallet_ffi::WalletUtxoEntryFFI) -> Self { + 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, + } + } +} + /// One page: the rows, the opaque resume cursor (absent on the last page) /// and whether more pages follow. #[derive(serde::Serialize)] @@ -3347,6 +3386,23 @@ struct UtxoPage { 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_wallet_utxos_page` /// resumes from. Serialized as JSON and handed to the host as an opaque @@ -3360,6 +3416,36 @@ struct UtxoPageCursor { 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 @@ -3376,6 +3462,43 @@ struct OutpointQuery { 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)) + } +} + /// One bounded page of the engine's UTXO inventory across every funds /// account of one wallet — the source of truth /// `PlatformWalletManager.reconcileTxoStore` diffs against the Room `txos` @@ -3432,23 +3555,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w return ptr::null_mut(); } }; - let parsed = serde_json::from_str::(&raw) - .ok() - .and_then(|c| { - 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 }, - }) - }); - match parsed { + match UtxoPageCursor::decode(&raw) { Some(r) => Some(r), None => { throw_sdk_exception( @@ -3482,34 +3589,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w if !entries.is_null() && count > 0 { let items = unsafe { std::slice::from_raw_parts(entries, count) }; for e in items { - let script: &[u8] = if e.script_pubkey.is_null() || e.script_pubkey_len == 0 { - &[] - } else { - unsafe { std::slice::from_raw_parts(e.script_pubkey, e.script_pubkey_len) } - }; - // 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 { - unsafe { CStr::from_ptr(e.address) } - .to_string_lossy() - .into_owned() - }; - rows.push(UtxoPageRow { - account: UtxoAccountTuple::from_entry(e), - txid: hex::encode(e.outpoint.txid), - vout: e.outpoint.vout, - amount: e.value_duffs, - address, - script_hex: hex::encode(script), - height: e.height, - is_confirmed: e.is_confirmed, - is_instantlocked: e.is_instantlocked, - is_coinbase: e.is_coinbase, - is_locked: e.is_locked, - }); + // SAFETY: every entry the accessor returns satisfies the + // pointer contract `from_entry` documents. + rows.push(unsafe { UtxoPageRow::from_entry(e) }); } unsafe { platform_wallet_ffi::platform_wallet_wallet_utxos_page_free( @@ -3518,46 +3600,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w ) }; } - // The cursor is the last row itself; a page with no rows has - // nowhere to resume from, and the accessor reports no more in that - // case. - let cursor_json = if has_more { - match rows.last() { - Some(last) => { - let next = UtxoPageCursor { - account: UtxoAccountTuple { - type_tag: last.account.type_tag, - standard_tag: last.account.standard_tag, - index: last.account.index, - registration_index: last.account.registration_index, - key_class: last.account.key_class, - user_identity_id: last.account.user_identity_id.clone(), - friend_identity_id: last.account.friend_identity_id.clone(), - }, - txid: last.txid.clone(), - vout: last.vout, - }; - match serde_json::to_string(&next) { - Ok(c) => Some(c), - Err(e) => { - throw_sdk_exception( - env, - 1, - &format!("inventory cursor encode failed: {e}"), - ); - return ptr::null_mut(); - } - } - } - None => None, + 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(); } - } else { - None - }; - let page = UtxoPage { - utxos: rows, - has_more: has_more && cursor_json.is_some(), - cursor: cursor_json, }; match serde_json::to_string(&page) { Ok(json) => env @@ -3627,38 +3675,18 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w let mut queries: Vec = Vec::with_capacity(parsed.len()); for q in &parsed { - let Some((user_identity_id, friend_identity_id)) = q.account.identity_ids() else { - throw_sdk_exception(env, 1, "outpoint query carries a malformed identity id"); - return ptr::null_mut(); - }; - let txid: [u8; 32] = match hex::decode(&q.txid).ok().and_then(|b| b.try_into().ok()) { - Some(t) => t, - None => { - throw_sdk_exception(env, 1, "outpoint query carries a malformed txid"); - return ptr::null_mut(); + match q.to_ffi() { + Ok((query, script)) => { + scripts.push(script); + // Its script pointer is filled below, once `scripts` + // has stopped reallocating. + queries.push(query); } - }; - let script = match hex::decode(&q.script_hex) { - Ok(s) => s, - Err(_) => { - throw_sdk_exception(env, 1, "outpoint query carries a malformed script"); + Err(message) => { + throw_sdk_exception(env, 1, message); return ptr::null_mut(); } - }; - scripts.push(script); - queries.push(platform_wallet_ffi::OutpointOwnershipQueryFFI { - type_tag: q.account.type_tag, - standard_tag: q.account.standard_tag, - index: q.account.index, - registration_index: q.account.registration_index, - key_class: q.account.key_class, - user_identity_id, - friend_identity_id, - outpoint: platform_wallet_ffi::OutPointFFI { txid, vout: q.vout }, - // Filled below, once `scripts` has stopped reallocating. - script_pubkey: ptr::null(), - script_pubkey_len: 0, - }); + } } for (query, script) in queries.iter_mut().zip(scripts.iter()) { query.script_pubkey = if script.is_empty() { @@ -4473,3 +4501,427 @@ 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, WalletUtxoEntryFFI}; + 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 accessor entry plus the buffers it points into, kept alive with it. + struct Coin { + entry: WalletUtxoEntryFFI, + _address: CString, + script: Vec, + } + + 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], + } + + 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 { + entry, + _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], + } + .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], + } + .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], + } + .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], + } + .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_entry(&c.entry) }) + .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], + } + .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" + ); + } + } +} From 0c674bce16ac10c9f98f5861a65aa2558a1056bb Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 10 Oct 2026 16:20:02 -0700 Subject: [PATCH 15/28] fix(kotlin-sdk): require every routing field of an engine inventory row EngineUtxoRow promised that a missing routing field fails the decode instead of healing a row under a defaulted owner, but standardTag, index, registrationIndex and keyClass defaulted to zero, so a page missing index decoded and resolved to account 0. The current Rust producer emits all four, so this hardens the contract rather than fixing an observed failure. The four fields are now required; the DashPay identity halves stay optional. Test inventory JSON that omitted registrationIndex and keyClass now carries them, and a new test proves a row missing any one account field fails to decode. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 13 ++++--- .../PlatformWalletPersistenceHandlerTest.kt | 39 ++++++++++++++++--- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 0dca5d22b72..1d90f092a29 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 @@ -1351,16 +1351,17 @@ class PlatformWalletPersistenceHandler( * 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; the - * DashPay identity halves are absent on every non-DashPay account. + * [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. */ @Serializable data class EngineUtxoRow( val typeTag: Int, - val standardTag: Int = 0, - val index: Int = 0, - val registrationIndex: Int = 0, - val keyClass: Int = 0, + val standardTag: Int, + val index: Int, + val registrationIndex: Int, + val keyClass: Int, val userIdentityId: String? = null, val friendIdentityId: String? = null, val txid: String, 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 e4ef30e2d45..f1a3eec084f 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 @@ -6241,7 +6241,7 @@ class PlatformWalletPersistenceHandlerTest { isConfirmed: Boolean = true, isCoinbase: Boolean = false, ): String = - """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """{"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}]}""" @@ -6850,7 +6850,7 @@ class PlatformWalletPersistenceHandlerTest { /** Engine inventory JSON with both halves: unspent rows and spent outpoints. */ private fun engineInventoryJson(unspent: List>, spent: List>): String { val utxoRows = unspent.joinToString(",") { (txid, vout, amount) -> - """{"typeTag":0,"standardTag":0,"index":0,"txid":"$txid","vout":$vout,""" + + """{"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}""" @@ -6873,7 +6873,7 @@ class PlatformWalletPersistenceHandlerTest { typeTag: Int = 0, txidHex: String = txid.toHexLower(), ): String = - """{"typeTag":$typeTag,"standardTag":0,"index":0,"txid":"$txidHex","vout":$vout,""" + + """{"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}""" @@ -7228,7 +7228,7 @@ class PlatformWalletPersistenceHandlerTest { false, true, false, false, ) val json = - """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """{"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,""" + @@ -7481,7 +7481,7 @@ class PlatformWalletPersistenceHandlerTest { ) val json = - """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,""" + + """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,""" + """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + """"address":"yContactPaid","scriptHex":"51",""" + """"height":1400000,"isConfirmed":true,"isLocked":false}],"spent":[]}""" @@ -7550,7 +7550,7 @@ class PlatformWalletPersistenceHandlerTest { // The tag is the authoritative foreign check: a contact's coin must // be skipped even when its address row never survived persistence. val json = - """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,""" + + """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,""" + """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + """"address":"yContactNoRow","scriptHex":"51",""" + @@ -8108,6 +8108,33 @@ class PlatformWalletPersistenceHandlerTest { ) } + @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")) { + 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 From fe2671317184c2b2a3994edd928e27b38c75368d Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 15 Oct 2026 19:41:20 -0700 Subject: [PATCH 16/28] fix(kotlin-sdk): refuse reconcile flips while a persistence round is open The classify pass asks the engine about a page outside the callback lock and applies the verdicts under it only if committedRoundGeneration has not moved. That count advances only when a round commits, so a round that began in the gap and staged a credited re-delivery of one of the page's coins went unnoticed: the stale verdict marked the row spent before the round committed. For a row with a mempool spender link the re-delivery then keeps the flag, so the store dropped the coin from the restore while the engine still held it, and no later run looked at the spent row again. The apply now also refuses while the wallet has an open round, through the existing stale path: nothing is written, the page is classified again, and after the existing retry limit the run stops incomplete so the rows are classified again once the round has closed. A new test opens a round during classification that re-delivers a plain row and a mempool-linked row, keeps it open past the apply and closes it after the run. Both rows stay unspent and restorable, and a later run classifies them again; without the guard the linked row is left spent. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 28 ++++--- .../PlatformWalletPersistenceHandlerTest.kt | 78 +++++++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 1d90f092a29..d5d2b28dd42 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 @@ -481,8 +481,9 @@ class PlatformWalletPersistenceHandler( * 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 — see - * [classifyStoreRows]. Read and written only under [callbackExclusion]. + * 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 @@ -1475,7 +1476,7 @@ class PlatformWalletPersistenceHandler( val unresolvedAccount: Int = 0, // Run shape. /** Pages classified again because a persistence round committed - * between their read and their apply. */ + * 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. */ @@ -1581,8 +1582,9 @@ class PlatformWalletPersistenceHandler( /** Rows that changed under the walk (already spent, or gone). */ var stale = 0 - /** A round committed between the read and the apply: nothing was - * written and the page is classified again. */ + /** 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() } @@ -1890,11 +1892,15 @@ class PlatformWalletPersistenceHandler( * 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. 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, at most - * [TXO_RECONCILE_MAX_STALE_RETRIES] times in a row before the run stops - * incomplete. + * 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, at + * most [TXO_RECONCILE_MAX_STALE_RETRIES] times in a row before 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. * * The walk is keyed by outpoint (`outpoint > after`, the primary-key * order), not by offset, so the flips themselves never disturb it: a @@ -1937,7 +1943,7 @@ class PlatformWalletPersistenceHandler( return } val counts = reconcileStoreStep(tally, walletRef, "flip", isCancelled) { - if (committedRoundGeneration != page.generation) { + if (committedRoundGeneration != page.generation || openRound(walletId) != null) { FlipCounts().apply { staleGeneration = true } } else { database.withTransaction { applyEngineClasses(page.rows, verdicts) } 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 f1a3eec084f..a409c5afe6a 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 @@ -7060,6 +7060,84 @@ class PlatformWalletPersistenceHandlerTest { 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, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> emptyEnginePage }, + 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) + + 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 = PlatformWalletPersistenceHandler.TXO_RECONCILE_MAX_STALE_RETRIES + 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, + tipHeight = reconcileTip, + engineUtxoPage = { _, _ -> emptyEnginePage }, + 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 ───────────── @Test From 97ec47a4a44b3717fef157a05d0922203e442565 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 15 Oct 2026 19:43:06 -0700 Subject: [PATCH 17/28] fix(kotlin-sdk): reset the reconcile steady-state edge when SPV stops Only the running branch of the SPV progress poll fed progress to TxoReconcileCoordinator. The stopped branch published the empty progress without telling the coordinator and stopSpv did not reset it either, so wasSteady stayed true across a stop. If SPV restarted and the first progress read was already steady, no rising edge was seen and every wallet waited out the 30-minute cadence instead of getting the steady-state reconcile. The coordinator now has spvStopped(), which clears the edge. The poll calls it on every stopped read and stopSpv calls it after the native stop, which also covers a restart inside one poll interval that the poll never sees as stopped. A new coordinator test goes steady, stopped, steady with no syncing read in between and expects every wallet to be due on the second steady read, and only on that one. With the reset removed it fails. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 15 ++++++++--- .../dashsdk/wallet/TxoReconcile.kt | 10 ++++++++ .../dashsdk/wallet/TxoReconcileTest.kt | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) 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 09dc20c7e1e..7fe11787259 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 @@ -2209,6 +2209,10 @@ class PlatformWalletManager( suspend fun stopSpv() = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.spvStop(managerHandle) } + // Arm the reconcile's next steady-state edge here as well as on the + // poll's next stopped read: a restart inside one poll interval + // would otherwise never be seen as a stop. + txoReconcile.spvStopped() } /** Clear all persisted SPV storage (headers, filters, state). */ @@ -2302,9 +2306,14 @@ class PlatformWalletManager( runCatching { spvTipUnixSeconds() }.getOrNull()?.let { tip -> if (tip != _spvTipUnixSeconds.value) _spvTipUnixSeconds.value = tip } - } else if (_spvProgress.value != SpvSyncProgressData.EMPTY) { - // SPV stopped — reset so the overlay clears. - _spvProgress.value = SpvSyncProgressData.EMPTY + } else { + // SPV stopped — the next steady read after a restart is + // a new steady state for the reconcile. + txoReconcile.spvStopped() + if (_spvProgress.value != SpvSyncProgressData.EMPTY) { + // Reset so the overlay clears. + _spvProgress.value = SpvSyncProgressData.EMPTY + } } delay(POLL_INTERVAL_MS) } 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 index ee8182bf7e0..9b6ece66cbb 100644 --- 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 @@ -142,6 +142,16 @@ internal class TxoReconcileCoordinator(private val cadenceMs: Long = TxoReconcil } } + /** + * SPV stopped: arm the next rising edge. The poll publishes no progress + * read while SPV is down, so without this a client that restarts + * straight into steady state would never leave it as far as + * [walletsDue] can tell, and every wallet would wait out the cadence. + */ + fun spvStopped() { + synchronized(lock) { wasSteady = false } + } + /** Claim the wallet for one run; false when a run is already in flight. */ fun tryBegin(walletKey: String): Boolean = synchronized(lock) { inFlight.add(walletKey) } 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 index b65d846c0e7..680328caf44 100644 --- 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 @@ -128,6 +128,31 @@ class TxoReconcileTest { ) } + @Test + fun stoppingSpvArmsTheNextRisingEdgeWithoutASyncingRead() { + // SPV stops and restarts straight into steady state: the poll feeds + // no progress while SPV is down and the first read after the + // restart is already steady, so no syncing read ever lowers the + // edge. The stop itself must, or every wallet waits out the cadence. + val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000_000) + val wallets = listOf("a", "b") + assertEquals(wallets, coordinator.walletsDue(steady, wallets, nowMs = 0)) + assertEquals(emptyList(), coordinator.walletsDue(steady, wallets, nowMs = 10)) + + coordinator.spvStopped() + + assertEquals( + "the first steady read after the restart is a new steady state", + wallets, + coordinator.walletsDue(steady, wallets, nowMs = 20), + ) + assertEquals( + "and only the first", + emptyList(), + coordinator.walletsDue(steady, wallets, nowMs = 30), + ) + } + @Test fun closeAndDeletionStaleTheEpochAndDeletionForgetsTheCadence() { val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) From 1f8df0427fe66e7d41bca0ee6abbdf0f01480bd9 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 18 Oct 2026 22:13:52 -0700 Subject: [PATCH 18/28] feat(platform-wallet): own the TXO-store reconcile policy in the wallet The host TXO-store reconcile ported from #4638 decided in Kotlin which engine coins may be healed into the store, when a run may start, how often it runs and how long a stale page is retried. Those are wallet decisions, so they move to a new manager::txo_reconcile module and every host asks the same questions: - txo_heal_verdict / txo_heal_page tag each inventory row Eligible, Immature (not engine-confirmed, or fewer than 100 confirmations at the tip) or Invalid (no script or address). The watch-only contact exclusion stays where it already was, in wallet_utxos_page and classify_outpoints. - TxoReconciler::begin claims one run per wallet and applies the start gates in the #4638 order: SPV running and steady (Synced, or WaitForEvents with the filter phase at its target), a scan tip (filter height, else header height), no latched sync fault, and the wallet's own engine watermark within 6 blocks of the tip. finish releases the claim and restarts the wallet's cadence when the run went ahead. - TxoReconciler::due_wallets is the schedule: every wallet on the rising edge into steady state, then each wallet again after 30 minutes, never one in flight. SPV not running lowers the edge, and SpvRuntime now counts its starts so a stop and restart between two polls is a new steady state without the host reporting the stop. - txo_reconcile_retry_stale_page holds the limit of five stale pages in a row. Removing a wallet forgets its cadence. The policy is unit-tested here, replacing the Kotlin tests of the same rules. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/mod.rs | 7 + .../src/manager/txo_reconcile.rs | 818 ++++++++++++++++++ .../src/manager/wallet_lifecycle.rs | 4 + .../rs-platform-wallet/src/spv/runtime.rs | 13 + 4 files changed, 842 insertions(+) create mode 100644 packages/rs-platform-wallet/src/manager/txo_reconcile.rs diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 6ea8ba9b5fd..08d6332f91e 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(), } } 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..73c73d91dce --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -0,0 +1,818 @@ +//! 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`]; +//! - **how long to retry a page a persistence round staled** — +//! [`txo_reconcile_retry_stale_page`]. +//! +//! Which store rows to mark spent is +//! [`classify_outpoints`](crate::manager::accessors::classify_outpoints)'s +//! verdict. 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. + +use std::collections::{BTreeMap, BTreeSet}; +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::RwLock; + +use crate::changeset::PlatformWalletPersistence; +use crate::manager::accessors::{wallet_utxos_page, 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: BTreeSet, + last_run: BTreeMap, +} + +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. Each returned wallet is stamped `now`, so the next poll + /// does not schedule it again while this run is still gating. + 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); + let mut due = Vec::new(); + for wallet_id in wallets { + if self.in_flight.contains(&wallet_id) { + continue; + } + let cadence_elapsed = self + .last_run + .get(&wallet_id) + .is_none_or(|last| now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE); + if rising || cadence_elapsed { + self.last_run.insert(wallet_id, now); + due.push(wallet_id); + } + } + due + } + + /// Claim the wallet for one run; `false` when a run is already in + /// flight. + pub fn try_begin(&mut self, wallet_id: WalletId) -> bool { + self.in_flight.insert(wallet_id) + } + + /// Release the claim [`Self::try_begin`] took. `ran_at` is when a run + /// that went ahead finished, which restarts the wallet's cadence; + /// `None` for one that did not. + pub fn finish(&mut self, wallet_id: &WalletId, ran_at: Option) { + self.in_flight.remove(wallet_id); + if let Some(at) = ran_at { + 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. + pub fn forget(&mut self, wallet_id: &WalletId) { + self.last_run.remove(wallet_id); + } +} + +/// 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 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. + pub async fn begin(&self, wallet_id: &WalletId) -> Result { + if !lock_schedule(&self.schedule).try_begin(*wallet_id) { + return Err(TxoReconcileSkip::AlreadyRunning); + } + let gate = self.gate(wallet_id).await; + if gate.is_err() { + lock_schedule(&self.schedule).finish(wallet_id, None); + } + gate + } + + /// Release the claim [`Self::begin`] granted. `ran` is whether the run + /// went ahead (even if it stopped early): that restarts the wallet's + /// cadence. + 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. + pub(super) fn forget_txo_reconcile(&self, wallet_id: &WalletId) { + lock_schedule(&self.txo_reconcile_schedule).forget(wallet_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + + use crate::events::PlatformEventManager; + + 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]; + + #[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]); + 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]); + // 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]); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + TXO_RECONCILE_CADENCE), + vec![A] + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + half + TXO_RECONCILE_CADENCE), + vec![B] + ); + // A finished run restarts that wallet's cadence. + let a_ran = t0 + TXO_RECONCILE_CADENCE + half + half / 2; + assert!(schedule.try_begin(A)); + 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(); + assert!(schedule.try_begin(A)); + assert!( + !schedule.try_begin(A), + "a second run for the same wallet is refused" + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], Instant::now()), + vec![B], + "not even on the rising edge" + ); + schedule.finish(&A, None); + assert!(schedule.try_begin(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]); + 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]); + 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" + ); + 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]); + 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" + ); + } + + fn reconciler() -> TxoReconciler { + let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( + dashcore::Network::Testnet, + ))); + 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)); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::AlreadyRunning) + ); + reconciler.finish(&A, true); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState) + ); + } +} 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 73d4b7902cc..cbe3a78fcd8 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -2,6 +2,7 @@ use std::future::Future; use std::pin::pin; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::task::{Context, Poll, Waker}; use std::time::Duration; @@ -83,6 +84,8 @@ pub struct SpvRuntime { /// each stop or storage clear, so none of them interleave. shutdown: tokio::sync::Mutex, peer_tracker: Arc, + /// Successful [`start`](Self::start)s so far; see [`Self::run_session`]. + run_sessions: AtomicU64, } /// Classify a failure from the SPV acceptance-check path @@ -123,6 +126,7 @@ impl SpvRuntime { task: Mutex::new(None), shutdown: tokio::sync::Mutex::new(Shutdown::Idle), peer_tracker: Arc::new(PeerTracker::default()), + run_sessions: AtomicU64::new(0), } } @@ -181,10 +185,19 @@ 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); 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(|_| { From d4a577efbe9e2d1f76450d25be9ad77d6ff7c084 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 18 Oct 2026 22:13:53 -0700 Subject: [PATCH 19/28] feat(platform-wallet): expose the TXO-store reconcile policy over the C ABI Thin exports over manager::txo_reconcile, one Rust operation each: - platform_wallet_manager_txo_reconcile_due_wallets (IdentifierArray out) - platform_wallet_manager_txo_reconcile_begin (gate code and scan tip out) - platform_wallet_manager_txo_reconcile_finish - platform_wallet_txo_reconcile_heal_page / _heal_page_free, returning TxoHealRowFFI rows: the existing WalletUtxoEntryFFI plus its verdict - platform_wallet_txo_reconcile_retry_stale_page The TXO_RECONCILE_SKIP_* and TXO_HEAL_VERDICT_* codes are pinned to the Rust enums by a test. The cursor decoding and the entry build and release of platform_wallet_wallet_utxos_page move into shared helpers so the heal page uses the same ones; that export is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/manager_diagnostics.rs | 143 +++++--- .../src/txo_reconcile.rs | 341 ++++++++++++++++++ 3 files changed, 426 insertions(+), 60 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/txo_reconcile.rs diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index f9bf8f2c602..a912a78f05f 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -85,6 +85,7 @@ pub mod spv; pub mod token_persistence; pub mod tokens; pub mod tracked_masternode; +pub mod txo_reconcile; pub mod types; pub mod utils; pub mod wallet; @@ -160,6 +161,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_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 5630ad287be..dcb1d9a6785 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -982,6 +982,82 @@ 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: &crate::core_wallet_types::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: platform_wallet::manager::accessors::WalletUtxoRow, +) -> crate::core_wallet_types::WalletUtxoEntryFFI { + use crate::core_wallet_types::{account_type_to_tags, OutPointFFI, WalletUtxoEntryFFI}; + + 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: &crate::core_wallet_types::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 +1082,7 @@ 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 crate::core_wallet_types::WalletUtxoEntryFFI; use platform_wallet::manager::accessors::{wallet_utxos_page, WalletUtxoCursor}; check_ptr!(wallet_id); @@ -1021,26 +1097,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 +1125,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 +1144,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)); } 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..355e2dbd407 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -0,0 +1,341 @@ +//! C ABI over `platform_wallet::manager::txo_reconcile`: the schedule, +//! start gate, heal verdicts and stale-page retry of the host TXO-store +//! reconcile (dashpay/platform#4638). Each export forwards to one Rust +//! operation; the decisions, thresholds and cadence live in +//! `platform-wallet`. Which store rows to mark spent is +//! `platform_wallet_classify_outpoints`'s answer. + +use platform_wallet::manager::accessors::WalletUtxoCursor; +use platform_wallet::manager::txo_reconcile::{txo_heal_page, txo_reconcile_retry_stale_page}; + +use crate::check_ptr; +use crate::core_wallet_types::{WalletUtxoCursorFFI, WalletUtxoEntryFFI}; +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; +use crate::manager_diagnostics::{ + 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. Each returned wallet counts as +/// scheduled now. `*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. +/// +/// # 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. +/// +/// # 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)); +} + +/// 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}; + + #[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); + } +} From 0a62646d12c7bc5dee5b8654042f6a2e1c16a21c Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 18 Oct 2026 22:14:04 -0700 Subject: [PATCH 20/28] refactor(kotlin-sdk): ask the Rust wallet for every TXO reconcile decision Kotlin no longer decides anything in the TXO-store reconcile. The JNI bindings and their Kotlin signatures change together: - walletManagerTxoReconcileDueWallets, walletManagerTxoReconcileBegin, walletManagerTxoReconcileFinish and txoReconcileRetryStalePage bridge one platform-wallet-ffi call each. - walletManagerTxoHealPageJson replaces walletManagerUtxosPageJson; each row now carries the engine's healVerdict (golden fixture updated on both sides). walletManagerCoreWalletSyncedHeight is gone: the wallet watermark gate reads it in Rust. On the Kotlin side TxoReconcileGates and TxoReconcileCoordinator are removed. The progress poll asks the native schedule which wallets are due on every tick, running or not, and stopSpv no longer has to report the stop. reconcileTxoStore keeps the closed and loaded-wallet checks, the teardown borrow and the cancellation epoch, takes the start decision and scan tip from the native gate, and always releases the native claim. The persistence handler inserts only rows the engine calls eligible, takes the stale-page decision as a parameter, and no longer screens watch-only contact rows on either pass: the inventory never offers a contact's coin and the classifier gives one no verdict, so such rows now count as unknown. The report's skippedForeign field goes with that screen. Room pagination, owner resolution, the transactions, the callback exclusion lock and the generation and open-round fences stay in Kotlin. The Kotlin tests of the moved rules are replaced by the Rust tests; the handler tests now drive the verdicts and the stale-page decision as inputs. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/WalletManagerNative.kt | 92 ++++-- .../PlatformWalletPersistenceHandler.kt | 199 +++++------- .../dashsdk/wallet/PlatformWalletManager.kt | 179 ++++++----- .../dashsdk/wallet/TxoReconcile.kt | 170 ++-------- .../PlatformWalletPersistenceHandlerTest.kt | 290 +++++++++--------- .../dashsdk/wallet/TxoReconcileTest.kt | 192 ++---------- .../golden/txo_reconcile_transport_v1.json | 14 +- .../rs-unified-sdk-jni/src/wallet_manager.rs | 286 ++++++++++++----- 8 files changed, 633 insertions(+), 789 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 5e9d3fd17f9..2dcc6fbff46 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,22 +145,72 @@ 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, as JSON - * `{"utxos":[...],"cursor":,"hasMore":}` — the - * source of truth the TXO-store reconciler - * ([PlatformWalletManager.reconcileTxoStore]) diffs against the Room - * `txos` mirror. A serialization shim over one Rust call - * (`platform_wallet_wallet_utxos_page`): account ordering, cursor - * semantics, the page bound and the address rendering all live in - * `platform-wallet`, so the Swift host walks the identical inventory. + * 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 - * 30-minute pass. Pass [cursor] `null` to start, then hand back the returned + * 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 @@ -176,14 +226,16 @@ internal object WalletManagerNative { * 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, and the engine's own `isConfirmed` / - * `isInstantLocked` / `isCoinbase` / `isLocked` flags. + * scriptHex, height, the engine's own `isConfirmed` / + * `isInstantLocked` / `isCoinbase` / `isLocked` flags, and its + * `healVerdict` (`TXO_HEAL_VERDICT_*`). */ - external fun walletManagerUtxosPageJson( + external fun walletManagerTxoHealPageJson( managerHandle: Long, walletId: ByteArray, cursor: String?, limit: Int, + tipHeight: Int, ): String? /** @@ -214,20 +266,6 @@ internal object WalletManagerNative { queriesJson: String, ): ByteArray? - /** - * The wallet's core scan watermark as the ENGINE holds it - * (`platform_wallet_core_wallet_state`'s `synced_height`) — the value the - * TXO-store reconcile gates on, as Swift's does. The persisted - * `syncedHeight` in Room trails it by up to one persistence round, which - * is exactly the window the SYNCED transition lands in. Throws - * [org.dashfoundation.dashsdk.errors.DashSdkError] `NotFound` when the - * manager does not hold the wallet. - */ - external fun walletManagerCoreWalletSyncedHeight( - managerHandle: Long, - walletId: ByteArray, - ): 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 11e910a7036..6a2bfd84a2b 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 @@ -1379,7 +1379,7 @@ class PlatformWalletPersistenceHandler( /** * One row of the engine's paged UTXO inventory, exactly as - * `walletManagerUtxosPageJson` serializes it (rs-unified-sdk-jni + * `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 @@ -1387,7 +1387,10 @@ class PlatformWalletPersistenceHandler( * (`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. + * 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( @@ -1411,6 +1414,7 @@ class PlatformWalletPersistenceHandler( val isInstantlocked: Boolean = false, val isCoinbase: Boolean = false, val isLocked: Boolean = false, + val healVerdict: Int, ) /** @@ -1474,21 +1478,16 @@ class PlatformWalletPersistenceHandler( /** 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 does not call confirmed, or below the - * confirmation gate — they age into a later run. */ + /** 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 store could not validate (txid not 32 bytes, no - * script, no address). */ + /** Engine coins the engine calls invalid (no script, no address), + * or whose fields do not decode (txid not 32 bytes). */ val skippedInvalid: Int = 0, - /** Watch-only DIP-15 contact coins excluded on both sides — engine - * coins the heal pass refused to file as ours, and unspent store - * rows the classify pass did not ask about (the engine's own - * accounts never report them, so their absence is expected). */ - val skippedForeign: Int = 0, // Classify pass (store → engine, flip-only). /** Unspent store rows of this wallet handed to the engine. */ val storeRows: Int = 0, @@ -1498,7 +1497,9 @@ class PlatformWalletPersistenceHandler( * [OUTPOINT_CLASS_KNOWN_UNCREDITED] verdict. */ val flipped: Int = 0, val flippedDuffs: Long = 0, - /** Store rows the engine has no opinion about — left untouched. */ + /** 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. */ @@ -1536,7 +1537,6 @@ class PlatformWalletPersistenceHandler( var skippedImmature = 0 var skippedUnresolvedAccount = 0 var skippedInvalid = 0 - var skippedForeign = 0 var storeRows = 0 var unspent = 0 var flipped = 0 @@ -1558,7 +1558,6 @@ class PlatformWalletPersistenceHandler( skippedImmature = skippedImmature, skippedUnresolvedAccount = skippedUnresolvedAccount, skippedInvalid = skippedInvalid, - skippedForeign = skippedForeign, storeRows = storeRows, unspent = unspent, flipped = flipped, @@ -1582,7 +1581,6 @@ class PlatformWalletPersistenceHandler( var skippedImmature = 0 var skippedUnresolvedAccount = 0 var skippedInvalid = 0 - var skippedForeign = 0 val healedOutpoints = ArrayList() val healedSpentOutpoints = ArrayList() } @@ -1600,7 +1598,6 @@ class PlatformWalletPersistenceHandler( val fetched: Int, val lastOutpoint: ByteArray?, val unresolvedAccount: Int, - val skippedForeign: Int, val generation: Long, ) @@ -1639,14 +1636,20 @@ class PlatformWalletPersistenceHandler( * * * [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 — validated, - * owned, confirmed by the engine and at least [minConfirmations] deep. + * `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. * + * 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 @@ -1678,39 +1681,21 @@ class PlatformWalletPersistenceHandler( */ suspend fun reconcileTxos( walletId: ByteArray, - tipHeight: Int, - minConfirmations: Int = TXO_RECONCILE_MIN_CONFIRMATIONS, pageSize: Int = TXO_RECONCILE_PAGE_SIZE, isCancelled: () -> Boolean = { false }, engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + retryStalePage: (staleInARow: Int) -> Boolean, ): TxoReconcileReport { val limit = pageSize.coerceAtLeast(1) val walletRef = logReference(walletId) val tally = ReconcileTally() - // Watch-only DIP-15 contact (external) accounts, resolved once up - // front because BOTH passes need the exclusion and the account set - // does not move under a run. The engine tracks these accounts' coins - // to show payments TO contacts, but they are the CONTACT's money and - // must never be healed into the store as ours. Before the check - // lived on the insert pass, a fresh restore's post-backfill reconcile - // healed every contact-payment coin into the store (12 rows / - // 0.05692493 tDASH on the large-wallet validation run of 2026-08-25). - val foreignAccountIds = reconcileStoreStep(tally, walletRef, "accounts", isCancelled) { - database.accountDao() - .observeByWallet(walletId).first() - .filter { it.accountType == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL } - .map { it.id } - .toSet() - } - if (foreignAccountIds != null && - healMissingTxos( - walletId, tipHeight, minConfirmations, limit, foreignAccountIds, - isCancelled, engineUtxoPage, tally, walletRef, - ) - ) { - classifyStoreRows(walletId, limit, foreignAccountIds, isCancelled, classifyOutpoints, tally, walletRef) + // 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, retryStalePage, tally, walletRef) } val report = tally.toReport() logReconcileSummary(report, walletRef) @@ -1726,10 +1711,7 @@ class PlatformWalletPersistenceHandler( */ private suspend fun healMissingTxos( walletId: ByteArray, - tipHeight: Int, - minConfirmations: Int, limit: Int, - foreignAccountIds: Set, isCancelled: () -> Boolean, engineUtxoPage: suspend (cursor: String?, limit: Int) -> String?, tally: ReconcileTally, @@ -1760,7 +1742,7 @@ class PlatformWalletPersistenceHandler( database.withTransaction { val c = HealCounts() for (row in page.utxos) { - healEngineRow(walletId, row, tipHeight, minConfirmations, foreignAccountIds, c) + healEngineRow(walletId, row, c) } c } @@ -1772,7 +1754,6 @@ class PlatformWalletPersistenceHandler( tally.skippedImmature += counts.skippedImmature tally.skippedUnresolvedAccount += counts.skippedUnresolvedAccount tally.skippedInvalid += counts.skippedInvalid - tally.skippedForeign += counts.skippedForeign // 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) @@ -1785,35 +1766,32 @@ class PlatformWalletPersistenceHandler( /** * Apply one engine inventory row to the store (inside the caller's - * exclusion lock and Room transaction) — Swift's - * `reconcileHealMissingTxos` gates, in its order: + * exclusion lock and Room transaction), in Swift's + * `reconcileHealMissingTxos` order: * - * 1. malformed (txid not 32 bytes, no script, no address) → invalid; - * 2. the engine does not call it confirmed, or it is not - * [minConfirmations] deep at [tipHeight] → immature (a fresh coin can - * still reorg or, for coinbase, be immature; it ages into a later - * run); + * 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. * - * Between 2 and 3 sits the Kotlin-only contact exclusion (see - * [reconcileTxos]). 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` (the - * gate guarantees it) 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. + * 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, - tipHeight: Int, - minConfirmations: Int, - foreignAccountIds: Set, counts: HealCounts, ) { val txid = row.txid.hexToByteArrayOrNull() @@ -1823,32 +1801,22 @@ class PlatformWalletPersistenceHandler( 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 || script.isEmpty() || - row.address.isEmpty() || !identitiesValid - ) { + if (txid == null || txid.size != 32 || script == null || !identitiesValid) { counts.skippedInvalid++ return } - if (!row.isConfirmed || row.height <= 0 || tipHeight < row.height || - tipHeight - row.height + 1 < minConfirmations - ) { - counts.skippedImmature++ - return - } - // The inventory tags every coin with its owning account tuple. The - // tag is the authoritative foreign check — a watch-only external - // account's coin is the CONTACT's money whether or not its address - // row survived persistence. The address-based check (through - // core_addresses.accountId, the same second path the classify pass - // resolves store rows by) stays as a fallback. - if (row.typeTag == ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL) { - counts.skippedForeign++ - return - } - val addressOwner = database.coreAddressDao().getByAddress(row.address)?.accountId - if (addressOwner != null && addressOwner in foreignAccountIds) { - counts.skippedForeign++ - 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) { @@ -1927,9 +1895,10 @@ class PlatformWalletPersistenceHandler( * 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, at - * most [TXO_RECONCILE_MAX_STALE_RETRIES] times in a row before the run - * stops incomplete. An open round counts too: it has not moved + * 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 @@ -1944,9 +1913,9 @@ class PlatformWalletPersistenceHandler( private suspend fun classifyStoreRows( walletId: ByteArray, limit: Int, - foreignAccountIds: Set, isCancelled: () -> Boolean, classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + retryStalePage: (staleInARow: Int) -> Boolean, tally: ReconcileTally, walletRef: String, ) { @@ -1960,12 +1929,11 @@ class PlatformWalletPersistenceHandler( return } val page = reconcileStoreStep(tally, walletRef, "unspent_page", isCancelled) { - readUnspentPage(walletId, after, limit, foreignAccountIds) + readUnspentPage(walletId, after, limit) } ?: return if (page.fetched == 0) return tally.storeRows += page.rows.size tally.unresolvedAccount += page.unresolvedAccount - tally.skippedForeign += page.skippedForeign if (page.rows.isNotEmpty()) { val verdicts = classifyOutpoints(encodeOutpointQueries(page.queries)) @@ -1985,7 +1953,7 @@ class PlatformWalletPersistenceHandler( if (counts.staleGeneration) { staleInARow++ tally.staleRetries++ - if (staleInARow > TXO_RECONCILE_MAX_STALE_RETRIES) { + if (!retryStalePage(staleInARow)) { tally.completed = false return } @@ -1993,7 +1961,6 @@ class PlatformWalletPersistenceHandler( // again and classified against the store as it is now. tally.storeRows -= page.rows.size tally.unresolvedAccount -= page.unresolvedAccount - tally.skippedForeign -= page.skippedForeign continue } staleInARow = 0 @@ -2020,23 +1987,21 @@ class PlatformWalletPersistenceHandler( * the question. A row still mid-insert (no 32-byte txid) is not * classifiable at all. * - * Watch-only DIP-15 contact rows are excluded via [foreignAccountIds]. * Production changeset writes leave txos.accountId null and route - * ownership through the address projection, so the owner resolves - * BOTH paths ([resolveTxoOwnerAccount]) — an accountId-only check - * silently classifies every contact row. + * 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, - foreignAccountIds: Set, ): UnspentPage { val fetched = database.txoDao().pageUnspentByWallet(walletId, after, limit) val rows = ArrayList(fetched.size) val queries = ArrayList(fetched.size) var unresolvedAccount = 0 - var skippedForeign = 0 val accountByAddress = HashMap() for (row in fetched) { val txid = row.txid @@ -2046,10 +2011,6 @@ class PlatformWalletPersistenceHandler( unresolvedAccount++ continue } - if (owner.id in foreignAccountIds) { - skippedForeign++ - continue - } rows.add(row) queries.add(outpointQueryFor(owner, txid, row.vout, row.scriptPubKey)) } @@ -2059,7 +2020,6 @@ class PlatformWalletPersistenceHandler( fetched = fetched.size, lastOutpoint = fetched.lastOrNull()?.outpoint, unresolvedAccount = unresolvedAccount, - skippedForeign = skippedForeign, generation = committedRoundGeneration, ) } @@ -2190,7 +2150,7 @@ class PlatformWalletPersistenceHandler( "unknown=${report.unknown} notOwned=${report.notOwned} " + "skippedImmature=${report.skippedImmature} " + "skippedUnresolvedAccount=${report.skippedUnresolvedAccount} " + - "skippedInvalid=${report.skippedInvalid} skippedForeign=${report.skippedForeign} " + + "skippedInvalid=${report.skippedInvalid} " + "unresolvedAccount=${report.unresolvedAccount} staleRetries=${report.staleRetries} " + "transportFailures=${report.transportFailures} storeFailures=${report.storeFailures} " + "completed=${report.completed} wallet=$walletRef" @@ -5120,10 +5080,6 @@ class PlatformWalletPersistenceHandler( runBlocking(dispatcher) { block() } companion object { - /** `AccountTypeTagFFI::DashpayExternalAccount` — watch-only DIP-15 - * contact accounts the engine's inventories never report. */ - internal const val ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL = 13 - /** Bytes of a `txos.outpoint` key: 32-byte txid (wire order) plus * the vout as a little-endian `Int`. Also the wire format of the * reconcile's outpoint-classification batch. */ @@ -5152,14 +5108,13 @@ class PlatformWalletPersistenceHandler( */ const val TXO_RECONCILE_PAGE_SIZE = 512 - /** Confirmations an engine coin needs before [reconcileTxos] inserts - * it: coinbase maturity, and well past any plausible reorg. */ - const val TXO_RECONCILE_MIN_CONFIRMATIONS = 100 - - /** Times in a row one classify page is classified again because a - * persistence round committed between its read and its apply, - * before [reconcileTxos] gives up on the run. */ - internal const val TXO_RECONCILE_MAX_STALE_RETRIES = 5 + /** [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 @@ -5169,7 +5124,7 @@ class PlatformWalletPersistenceHandler( */ private val engineJson = Json - /** One page of `walletManagerUtxosPageJson`, through [engineJson]. */ + /** One page of `walletManagerTxoHealPageJson`, through [engineJson]. */ internal fun decodeEngineUtxoPage(pageJson: String): EngineUtxoPage = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) 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 9547132bba5..cd7cb7ed8f2 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 @@ -1097,9 +1097,9 @@ class PlatformWalletManager( // 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. - txoReconcile.bumpEpoch() - txoReconcile.forget(key) + // 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. @@ -1344,30 +1344,22 @@ class PlatformWalletManager( * un-marks one, and never acts on absence. See * [PlatformWalletPersistenceHandler.reconcileTxos] for the passes. * - * Gates, each a [TxoReconcileOutcome.Skipped] rather than an error, in - * order: the manager not closed; the wallet loaded; no run for it - * already in flight; SPV in steady state - * ([TxoReconcileGates.isSteadySyncState]); a scan tip - * ([TxoReconcileGates.scanTipHeight]); no latched sync fault - * ([syncFaultDetected] — a rejected round means rows are missing by - * design and a rescan is pending); and the wallet's own scan watermark - * as the engine holds it - * ([WalletManagerNative.walletManagerCoreWalletSyncedHeight], Swift's - * `platform_wallet_core_wallet_state`) within - * [TxoReconcileGates.TIP_MARGIN] blocks of the tip: the tip says how far - * the CLIENT got, and a wallet added behind the tip is still being - * scanned. Not the persisted Room `syncedHeight`: that copy trails the - * engine by up to one persistence round, and the SYNCED transition lands - * inside that window — gating on it skipped the run at the end of every - * restore. + * 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. [closeSuspending] and [removeWallet] - * bump the reconcile epoch first, and the run stops between pages, - * reporting `completed = false`. + * destroyed under an engine read, and releases its native claim when it + * ends ([WalletManagerNative.walletManagerTxoReconcileFinish]). + * [closeSuspending] and [removeWallet] move [txoReconcileEpoch] first, + * and the run stops between pages, reporting `completed = false`. * - * Runs automatically on the steady-state transition of the SPV progress - * poll and every 30 minutes while it lasts (see + * 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. @@ -1382,44 +1374,33 @@ class PlatformWalletManager( return TxoReconcileOutcome.Skipped(reason) } if (isClosed) return skip(TxoReconcileSkipReason.MANAGER_CLOSED) - val key = walletId.toHex() - if (walletId.size != 32 || _wallets.value[key] == null) { + if (walletId.size != 32 || _wallets.value[walletId.toHex()] == null) { return skip(TxoReconcileSkipReason.WALLET_UNKNOWN) } - if (!txoReconcile.tryBegin(key)) return skip(TxoReconcileSkipReason.ALREADY_RUNNING) - try { - val generation = txoReconcile.currentEpoch - val progress = _spvProgress.value - if (!TxoReconcileGates.isSteadySyncState(progress)) { - return skip(TxoReconcileSkipReason.NOT_STEADY_STATE) - } - val tipHeight = TxoReconcileGates.scanTipHeight(progress) - ?: return skip(TxoReconcileSkipReason.TIP_UNAVAILABLE) - return try { - teardownGate.withOp { - if (syncFaultDetected()) return@withOp skip(TxoReconcileSkipReason.SYNC_FAULT_DETECTED) - val walletSyncedHeight = try { - withContext(Dispatchers.IO) { - mapNativeErrors { - WalletManagerNative.walletManagerCoreWalletSyncedHeight(managerHandle, walletId) - } - } - } catch (notFound: DashSdkError.PlatformWallet.NotFound) { - return@withOp skip(TxoReconcileSkipReason.WALLET_UNKNOWN) + val generation = txoReconcileEpoch.get() + return try { + teardownGate.withOp { + val tipOut = IntArray(1) + val gate = withContext(Dispatchers.IO) { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileBegin(managerHandle, walletId, tipOut) } - if (!TxoReconcileGates.walletCaughtUp(walletSyncedHeight, tipHeight)) { - return@withOp skip(TxoReconcileSkipReason.WALLET_BEHIND_TIP) - } - val isCancelled = { isClosed || txoReconcile.currentEpoch != generation } + } + if (gate != WalletManagerNative.TXO_RECONCILE_GATE_RUN) { + return@withOp skip(TxoReconcileSkipReason.fromGateCode(gate)) + } + val tipHeight = tipOut[0] + var ran = false + try { + val isCancelled = { isClosed || txoReconcileEpoch.get() != generation } if (isCancelled()) return@withOp skip(TxoReconcileSkipReason.MANAGER_CLOSED) val report = persistenceHandler.reconcileTxos( walletId = walletId, - tipHeight = tipHeight, isCancelled = isCancelled, engineUtxoPage = { cursor, limit -> reconcileEngineRead("engine inventory page", walletRef) { - WalletManagerNative.walletManagerUtxosPageJson( - managerHandle, walletId, cursor, limit, + WalletManagerNative.walletManagerTxoHealPageJson( + managerHandle, walletId, cursor, limit, tipHeight, ) } }, @@ -1430,16 +1411,32 @@ class PlatformWalletManager( ) } }, + retryStalePage = WalletManagerNative::txoReconcileRetryStalePage, ) - txoReconcile.markRun(key, monotonicNowMs()) + 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) { + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileFinish(managerHandle, walletId, ran) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: release failed error=${t.javaClass.simpleName} wallet=$walletRef", + ) + } + } } - } catch (closing: IllegalStateException) { - // The teardown gate refuses new borrows once close began. - if (isClosed) skip(TxoReconcileSkipReason.MANAGER_CLOSED) else throw closing } - } finally { - txoReconcile.end(key) + } catch (closing: IllegalStateException) { + // The teardown gate refuses new borrows once close began. + if (isClosed) skip(TxoReconcileSkipReason.MANAGER_CLOSED) else throw closing } } @@ -2279,10 +2276,6 @@ class PlatformWalletManager( suspend fun stopSpv() = withContext(Dispatchers.IO) { mapNativeErrors { WalletManagerNative.spvStop(managerHandle) } - // Arm the reconcile's next steady-state edge here as well as on the - // poll's next stopped read: a restart inside one poll interval - // would otherwise never be seen as a stop. - txoReconcile.spvStopped() } /** Clear all persisted SPV storage (headers, filters, state). */ @@ -2371,46 +2364,52 @@ class PlatformWalletManager( if (running) { runCatching { spvSyncProgress() }.getOrNull()?.let { next -> if (next != _spvProgress.value) _spvProgress.value = next - maybeReconcileTxoStores(next) } runCatching { spvTipUnixSeconds() }.getOrNull()?.let { tip -> if (tip != _spvTipUnixSeconds.value) _spvTipUnixSeconds.value = tip } - } else { - // SPV stopped — the next steady read after a restart is - // a new steady state for the reconcile. - txoReconcile.spvStopped() - if (_spvProgress.value != SpvSyncProgressData.EMPTY) { - // Reset so the overlay clears. - _spvProgress.value = SpvSyncProgressData.EMPTY - } + } else if (_spvProgress.value != SpvSyncProgressData.EMPTY) { + // 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) } } } - /** In-flight runs, per-wallet cadence, the steady-state edge and the - * cancellation epoch of the TXO-store reconcile. */ - private val txoReconcile = TxoReconcileCoordinator() + /** + * 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], fed by every accepted - * read of the SPV progress poll: run every loaded wallet on the - * transition into steady state, and again every 30 minutes per wallet - * while it lasts (each wallet's last run is stamped when it is - * scheduled) — never while a run for that wallet is in flight, and - * never inline: the poll tick stays cheap. 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 on the next cadence tick — never allowed to kill - * the progress poll. + * 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. 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 fun maybeReconcileTxoStores(progress: SpvSyncProgressData) { + private suspend fun maybeReconcileTxoStores() { if (isClosed) return + val due = runCatching { + withContext(Dispatchers.IO) { + mapNativeErrors { WalletManagerNative.walletManagerTxoReconcileDueWallets(managerHandle) } + } + }.getOrNull() ?: return val loaded = wallets.value - val due = txoReconcile.walletsDue(progress, loaded.keys, monotonicNowMs()) - for (key in due) { + 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 { @@ -2428,8 +2427,6 @@ class PlatformWalletManager( } } - private fun monotonicNowMs(): Long = System.nanoTime() / 1_000_000 - // ── DashPay sync + seedless unlock ──────────────────────────────── // // Port of `PlatformWalletManagerDashPaySync.swift` + the unlock flow @@ -2859,7 +2856,7 @@ class PlatformWalletManager( // 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. - txoReconcile.bumpEpoch() + txoReconcileEpoch.incrementAndGet() val bundle = bundleRef.getAndSet(0) if (bundle == 0L) return // NonCancellable from the moment the bundle is claimed: bundleRef 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 index 9b6ece66cbb..ee4d026fd6b 100644 --- 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 @@ -1,7 +1,6 @@ package org.dashfoundation.dashsdk.wallet import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler -import java.util.concurrent.atomic.AtomicLong /** * What [PlatformWalletManager.reconcileTxoStore] did — the Kotlin twin of @@ -17,156 +16,43 @@ sealed class TxoReconcileOutcome { ) : TxoReconcileOutcome() } -/** Why [PlatformWalletManager.reconcileTxoStore] did not run. */ -enum class TxoReconcileSkipReason { +/** + * 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, + MANAGER_CLOSED(null), - /** The wallet is not loaded in this manager (or has no Room row). */ - WALLET_UNKNOWN, + /** The wallet is not loaded in this manager. */ + WALLET_UNKNOWN(1), /** A run for this wallet is already in flight. */ - ALREADY_RUNNING, + ALREADY_RUNNING(2), - /** SPV is not in its steady state (see [TxoReconcileGates.isSteadySyncState]). */ - NOT_STEADY_STATE, + /** SPV is not running, or not in its steady state. */ + NOT_STEADY_STATE(3), /** The progress carries no scan tip height. */ - TIP_UNAVAILABLE, + 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, - - /** The wallet's durable scan watermark trails the scan tip by more - * than [TxoReconcileGates.TIP_MARGIN] blocks — it is still being - * scanned. */ - WALLET_BEHIND_TIP, -} - -/** The pure gates of the TXO-store reconcile, testable without a manager. */ -internal object TxoReconcileGates { - /** How far the wallet's durable scan watermark may trail the scan tip - * for the scan to count as complete for this wallet. */ - const val TIP_MARGIN = 6 - - /** Cadence of the automatic run while SPV stays in steady state. */ - const val CADENCE_MS = 30 * 60 * 1_000L - - /** - * Whether the wallet's own scan watermark (the engine's, see - * [org.dashfoundation.dashsdk.ffi.WalletManagerNative.walletManagerCoreWalletSyncedHeight]) - * is within [TIP_MARGIN] blocks of the scan tip — Swift's - * `synced_height + margin >= tip`. The tip says how far the CLIENT got; - * a wallet added behind it is still being scanned. - */ - fun walletCaughtUp(walletSyncedHeight: Long, tipHeight: Int): Boolean = - walletSyncedHeight >= 0 && walletSyncedHeight + TIP_MARGIN >= tipHeight - - /** - * dash-spv's steady state for a fully synced client is - * [SpvSyncState.WAIT_FOR_EVENTS] with the filter phase at its target - * height; [SpvSyncState.SYNCED] is the transient window before it. - * Either counts — anything else is a scan still running (or not - * running at all, which the poll publishes as - * [SpvSyncProgressData.EMPTY]: waiting, with no filter phase). - */ - fun isSteadySyncState(progress: SpvSyncProgressData): Boolean = - when (progress.overallState) { - SpvSyncState.SYNCED -> true - SpvSyncState.WAIT_FOR_EVENTS -> { - val filters = progress.filters - filters != null && filters.targetHeight > 0 && filters.currentHeight >= filters.targetHeight - } - SpvSyncState.WAITING_FOR_CONNECTIONS, SpvSyncState.SYNCING, SpvSyncState.ERROR -> false - } - - /** - * The scan tip: the filter phase's height (the wallet-relevant one), - * falling back to the header tip when the filter phase is absent; null - * when neither carries a height. - */ - fun scanTipHeight(progress: SpvSyncProgressData): Int? { - val height = progress.filters?.currentHeight?.takeIf { it > 0 } - ?: progress.headers?.currentHeight?.takeIf { it > 0 } - ?: return null - return height.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - } -} - -/** - * The manager-side state of the TXO-store reconcile — the Kotlin twin of - * Swift's `coreTxoReconcileInFlight` / `coreTxoReconcileLastRunAt` / - * `coreTxoReconcileWasSteady` / `coreTxoReconcileEpoch`. Thread-safe: the - * progress poll, scheduled runs and host calls all reach it. - */ -internal class TxoReconcileCoordinator(private val cadenceMs: Long = TxoReconcileGates.CADENCE_MS) { - private val lock = Any() - private var wasSteady = false - private val inFlight = HashSet() - private val lastRunAtMs = HashMap() - private val epoch = AtomicLong() - - /** - * Bumped by manager close and by wallet deletion; a run captures it at - * start and stops between pages once it moves. - */ - val currentEpoch: Long get() = epoch.get() - - fun bumpEpoch() { - epoch.incrementAndGet() - } - - /** - * Feed one progress read (every accepted read, not only a changed one: - * it is the reconcile's only clock, and a quiet steady-state wallet's - * progress does not change for the whole cadence). Returns the wallets - * to run now — all of [walletKeys] on the rising edge into steady - * state, and afterwards each wallet whose last run is at least the - * cadence old (or that never ran) — never one already in flight. Each - * returned wallet is stamped NOW, at schedule time, so the next tick - * does not schedule it again while this run is still gating. - */ - fun walletsDue(progress: SpvSyncProgressData, walletKeys: Collection, nowMs: Long): List = - synchronized(lock) { - val steady = TxoReconcileGates.isSteadySyncState(progress) - val rising = steady && !wasSteady - wasSteady = steady - if (!steady) return@synchronized emptyList() - walletKeys.filter { key -> - if (key in inFlight) return@filter false - val last = lastRunAtMs[key] - val due = rising || last == null || nowMs - last >= cadenceMs - if (due) lastRunAtMs[key] = nowMs - due - } - } - - /** - * SPV stopped: arm the next rising edge. The poll publishes no progress - * read while SPV is down, so without this a client that restarts - * straight into steady state would never leave it as far as - * [walletsDue] can tell, and every wallet would wait out the cadence. - */ - fun spvStopped() { - synchronized(lock) { wasSteady = false } - } - - /** Claim the wallet for one run; false when a run is already in flight. */ - fun tryBegin(walletKey: String): Boolean = synchronized(lock) { inFlight.add(walletKey) } - - /** Release the claim [tryBegin] took. */ - fun end(walletKey: String) { - synchronized(lock) { inFlight.remove(walletKey) } - } - - /** Record a completed run of the wallet for the cadence. */ - fun markRun(walletKey: String, nowMs: Long) { - synchronized(lock) { lastRunAtMs[walletKey] = nowMs } - } - - /** Forget a deleted wallet's cadence. */ - fun forget(walletKey: String) { - synchronized(lock) { lastRunAtMs.remove(walletKey) } + 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 4046cafd6ae..f154b87f263 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 @@ -6341,6 +6341,22 @@ class PlatformWalletPersistenceHandlerTest { 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, @@ -6349,11 +6365,12 @@ class PlatformWalletPersistenceHandlerTest { 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}]}""" + """"isCoinbase":$isCoinbase,"isLocked":false,"healVerdict":$healVerdict}]}""" private fun ByteArray.toHexLower() = joinToString("") { "%02x".format(it) } @@ -6373,7 +6390,6 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L), - tipHeight = reconcileTip, ) assertEquals(1, report.inserted) @@ -6911,8 +6927,8 @@ class PlatformWalletPersistenceHandlerTest { ) val json = engineUtxoJson(changeTxid.toHexLower(), vout = 1, amount = 989_009_773L) - handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) - val second = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + handler.reconcileFromInventory(walletId, json) + val second = handler.reconcileFromInventory(walletId, json) assertEquals(0, second.inserted) assertEquals( @@ -6923,12 +6939,14 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconcileSkipsImmatureOutputsAndNeverUnmarksASpentRow() = runTest { - // Immature: inside the 100-conf gate — nothing inserted; the coin - // ages into a later run. + // 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), - tipHeight = reconcileTip, + engineUtxoJson( + changeTxid.toHexLower(), vout = 0, amount = 5L, height = reconcileTip - 3, + healVerdict = healImmature, + ), ) assertEquals(0, fresh.inserted) assertEquals(1, fresh.skippedImmature) @@ -6949,7 +6967,6 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 2, amount = 42L, height = 1_500_000), - tipHeight = reconcileTip, ) assertEquals(0, report.inserted) assertEquals(1, report.alreadyPresent) @@ -6962,7 +6979,7 @@ class PlatformWalletPersistenceHandlerTest { """{"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}""" + """"isInstantlocked":false,"isCoinbase":false,"isLocked":false,"healVerdict":$healEligible}""" } val spentRows = spent.joinToString(",") { (txid, vout) -> """{"txid":"$txid","vout":$vout}""" @@ -6981,11 +6998,12 @@ class PlatformWalletPersistenceHandlerTest { 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}""" + """"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. */ @@ -7041,7 +7059,7 @@ class PlatformWalletPersistenceHandlerTest { ): PlatformWalletPersistenceHandler.TxoReconcileReport = reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, pageSize = pageSize, isCancelled = isCancelled, engineUtxoPage = engine::page, @@ -7113,7 +7131,7 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, classifyOutpoints = { classifyCalls++ @@ -7151,7 +7169,7 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, classifyOutpoints = { classifyCalls++ @@ -7161,7 +7179,7 @@ class PlatformWalletPersistenceHandlerTest { }, ) - val limit = PlatformWalletPersistenceHandler.TXO_RECONCILE_MAX_STALE_RETRIES + 1 + val limit = staleRetriesAllowed + 1 assertFalse(report.completed) assertEquals(limit, report.staleRetries) assertEquals(limit, classifyCalls) @@ -7195,7 +7213,7 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, classifyOutpoints = { classifyCalls++ @@ -7227,7 +7245,7 @@ class PlatformWalletPersistenceHandlerTest { spender.contentEquals(db.txoDao().getByOutpoint(makeOutpoint(linked, 0))!!.spendingTxid), ) assertEquals("both coins are still handed back at the next load", 2, restoredUtxoCount()) - val limit = PlatformWalletPersistenceHandler.TXO_RECONCILE_MAX_STALE_RETRIES + 1 + 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) @@ -7236,7 +7254,7 @@ class PlatformWalletPersistenceHandlerTest { // again against the engine as it is now. val after = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, classifyOutpoints = { ByteArray(2) { PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT } }, ) @@ -7262,7 +7280,6 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = emptyList()), - tipHeight = reconcileTip, ) assertTrue(report.completed) assertEquals(1, report.storeRows) @@ -7286,7 +7303,7 @@ class PlatformWalletPersistenceHandlerTest { val json = """{"utxos":[],"spent":[],"notOwned":[""" + """{"txid":"${changeTxid.toHexLower()}","vout":21}]}""" - val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val report = handler.reconcileFromInventory(walletId, json) assertEquals(1, report.notOwned) assertEquals("a not-owned row is not also counted as unknown", 0, report.unknown) @@ -7311,7 +7328,6 @@ class PlatformWalletPersistenceHandlerTest { isConfirmed = true, isCoinbase = true, ), - tipHeight = reconcileTip, ) assertEquals(1, report.inserted) @@ -7351,7 +7367,6 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileFromInventory( walletId, engineInventoryJson(unspent = emptyList(), spent = emptyList()), - tipHeight = reconcileTip, ) assertEquals(1, report.unresolvedAccount) @@ -7419,8 +7434,9 @@ class PlatformWalletPersistenceHandlerTest { """"txid":"${changeTxid.toHexLower()}","vout":5,"amount":42,""" + """"address":"yTestAddr","scriptHex":"51",""" + """"height":${reconcileTip - 3},"isConfirmed":true,""" + - """"isInstantlocked":false,"isCoinbase":false,"isLocked":false}],"spent":[]}""" - val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + """"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) @@ -7453,15 +7469,16 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun reconcileExcludesWatchOnlyContactRowsFromClassifyPass() = runTest { - // Watch-only DIP-15 contact rows are never in the engine's - // inventories; asking about them would be a false positive on every - // wallet with contact payments. + 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 foreignAccountId = db.accountDao().insert( + val contactAccountId = db.accountDao().insert( org.dashfoundation.dashsdk.persistence.entities.AccountEntity( walletId = walletId, - accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountType = ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, accountIndex = 0, accountTypeName = "DashpayExternalAccount", ), @@ -7471,30 +7488,35 @@ class PlatformWalletPersistenceHandlerTest { false, true, false, false, ) val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 6))!! - db.txoDao().upsert(seeded.copy(accountId = foreignAccountId)) + db.txoDao().upsert(seeded.copy(accountId = contactAccountId)) + val engine = ContactBlindEngine() - val report = handler.reconcileFromInventory( - walletId, - engineInventoryJson(unspent = emptyList(), spent = emptyList()), - tipHeight = reconcileTip, + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = engine::classify, ) - assertEquals(1, report.skippedForeign) - assertEquals(0, report.storeRows) - assertEquals(0, report.unknown) + + 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 reconcileResolvesContactOwnershipThroughCoreAddressId() = runTest { + fun aContactRowsOwnerResolvesThroughItsAddressRow() = runTest { // Production changeset writes leave txos.accountId null and route // ownership through coreAddressId -> core_addresses.accountId. The - // exclusion must resolve that path, or every contact row gets - // classified. + // 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 foreignAccountId = db.accountDao().insert( + val contactAccountId = db.accountDao().insert( org.dashfoundation.dashsdk.persistence.entities.AccountEntity( walletId = walletId, - accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, + accountType = ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, accountIndex = 1, accountTypeName = "DashpayExternalAccount", ), @@ -7507,7 +7529,7 @@ class PlatformWalletPersistenceHandlerTest { addressIndex = 0, derivationPath = "m/9'/1'/15'/0'/x/y/0", isUsed = true, - accountId = foreignAccountId, + accountId = contactAccountId, ), ) handler.onWalletChangesetUtxoAdded( @@ -7516,14 +7538,41 @@ class PlatformWalletPersistenceHandlerTest { ) val seeded = db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 8))!! assertNull("production shape: accountId is null", seeded.accountId) + val engine = ContactBlindEngine() - val report = handler.reconcileFromInventory( - walletId, - engineInventoryJson(unspent = emptyList(), spent = emptyList()), - tipHeight = reconcileTip, + val report = handler.reconcileTxos( + walletId = walletId, + retryStalePage = retryStale, + engineUtxoPage = { _, _ -> emptyEnginePage }, + classifyOutpoints = engine::classify, ) - assertEquals(1, report.skippedForeign) - assertEquals(0, report.unknown) + + 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 ──── @@ -7558,21 +7607,26 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun theHealPassRefusesImmatureUnconfirmedUnresolvedAndMalformedCoins() = runTest { + 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), // immature - engineRowJson(txid(0x77), height = reconcileTip - 99), // exactly 100 confirmations - // Deep enough, but the engine itself does not call it confirmed. - engineRowJson(txid(0x78), isConfirmed = false), + 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 = ""), // no script - engineRowJson(txid(0x7b), address = ""), // no address - engineRowJson(txid(0x7c), height = 0), // unconfirmed height + 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), ), ), ) @@ -7580,13 +7634,13 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileWith(engine) assertTrue(report.completed) - assertEquals(8, report.engineRows) + assertEquals(9, report.engineRows) assertEquals(1, report.inserted) assertEquals(3, report.skippedImmature) assertEquals(1, report.skippedUnresolvedAccount) - assertEquals(3, report.skippedInvalid) + assertEquals(4, report.skippedInvalid) assertNotNull(db.txoDao().getByOutpoint(makeOutpoint(txid(0x77), 0))) - for (b in listOf(0x76, 0x78, 0x79, 0x7a, 0x7b, 0x7c)) { + 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) @@ -7638,51 +7692,6 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0, restoredUtxoCount()) } - @Test - fun reconcileInsertPassNeverHealsContactAccountCoins() = runTest { - // The engine's inventory carries the watch-only DIP-15 external - // accounts' coins — it tracks them to show payments TO contacts, but - // they are the CONTACT's money (12 rows / 5,692,493 duffs healed into - // the store on the 2026-08-25 large-wallet validation run before - // this exclusion). An engine coin whose address resolves to an - // external account is skipped and counted as foreign, not healed. - db.walletDao().upsert(WalletEntity(walletId, networkRaw = Network.TESTNET.ffiValue)) - val foreignAccountId = db.accountDao().insert( - org.dashfoundation.dashsdk.persistence.entities.AccountEntity( - walletId = walletId, - accountType = PlatformWalletPersistenceHandler.ACCOUNT_TYPE_TAG_DASHPAY_EXTERNAL, - accountIndex = 2, - accountTypeName = "DashpayExternalAccount", - ), - ) - db.coreAddressDao().upsert( - org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity( - address = "yContactPaid", - publicKey = ByteArray(33), - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/9'/1'/15'/0'/x/y/1", - isUsed = true, - accountId = foreignAccountId, - ), - ) - - val json = - """{"utxos":[{"typeTag":0,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,""" + - """"txid":"${changeTxid.toHexLower()}","vout":9,"amount":10000,""" + - """"address":"yContactPaid","scriptHex":"51",""" + - """"height":1400000,"isConfirmed":true,"isLocked":false}],"spent":[]}""" - val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) - - assertEquals(0, report.inserted) - assertEquals(0L, report.insertedDuffs) - assertEquals(1, report.skippedForeign) - assertNull( - "the contact's coin must not enter the store", - db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 9)), - ) - } - @Test fun reconcileStampsResolvedAccountOnHealedRows() = runTest { // Persistence lost BOTH the TXO and its address row. The heal @@ -7707,7 +7716,6 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileFromInventory( walletId, engineUtxoJson(changeTxid.toHexLower(), vout = 11, amount = 70_000L, address = "yOrphanAddr"), - tipHeight = reconcileTip, ) assertEquals(1, report.inserted) assertEquals(0, report.skippedUnresolvedAccount) @@ -7726,29 +7734,12 @@ class PlatformWalletPersistenceHandlerTest { val json = """{"utxos":[""" + engineRowJson(changeTxid, vout = 12, amount = 5_000L, typeTag = 1) + """],"spent":[]}""" - val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) + val report = handler.reconcileFromInventory(walletId, json) assertEquals(0, report.inserted) assertEquals(1, report.skippedUnresolvedAccount) assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 12))) } - @Test - fun reconcileForeignSkipKeysOffTheInventoryTagWithoutAddressRow() = runTest { - // The tag is the authoritative foreign check: a contact's coin must - // be skipped even when its address row never survived persistence. - val json = - """{"utxos":[{"typeTag":13,"standardTag":0,"index":0,"registrationIndex":0,"keyClass":0,""" + - """"userIdentityId":"${"11".repeat(32)}","friendIdentityId":"${"22".repeat(32)}",""" + - """"txid":"${changeTxid.toHexLower()}","vout":13,"amount":10000,""" + - """"address":"yContactNoRow","scriptHex":"51",""" + - """"height":1400000,"isConfirmed":true,"isLocked":false}],"spent":[]}""" - val report = handler.reconcileFromInventory(walletId, json, tipHeight = reconcileTip) - - assertEquals(0, report.inserted) - assertEquals(1, report.skippedForeign) - assertNull(db.txoDao().getByOutpoint(makeOutpoint(changeTxid, 13))) - } - // ── 5. Never un-mark, never delete; idempotent; wallet-scoped ───── @Test @@ -7769,7 +7760,6 @@ class PlatformWalletPersistenceHandlerTest { unspent = listOf(Triple(changeTxid.toHexLower(), 7, 77_000L)), spent = emptyList(), ), - tipHeight = reconcileTip, ) assertEquals(1, report.alreadyPresent) assertEquals(0, report.storeRows) @@ -7946,7 +7936,7 @@ class PlatformWalletPersistenceHandlerTest { ) val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, pageSize = 2, engineUtxoPage = { cursor, limit -> if (cursor == null) engine.page(cursor, limit) else null @@ -7996,7 +7986,7 @@ class PlatformWalletPersistenceHandlerTest { val engine = FakeEngine(engineInventoryJson(unspent = emptyList(), spent = emptyList())) val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, pageSize = 1, engineUtxoPage = engine::page, classifyOutpoints = { null }, @@ -8016,7 +8006,7 @@ class PlatformWalletPersistenceHandlerTest { var classifyCalls = 0 val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> null }, classifyOutpoints = { queriesJson -> classifyCalls++ @@ -8101,11 +8091,10 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileWith( engine, - // The run checks once for the account read, once before each - // engine read and once more before each store step: the first - // three admit the account read, page one and its heal; the - // fourth (before page two) reports the epoch bumped. - isCancelled = { ++checks >= 4 }, + // 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) @@ -8128,7 +8117,7 @@ class PlatformWalletPersistenceHandlerTest { val report = handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, pageSize = 1, isCancelled = { cancelled }, engineUtxoPage = { cursor, limit -> @@ -8166,7 +8155,10 @@ class PlatformWalletPersistenceHandlerTest { 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), + engineRowJson( + ByteArray(32) { 0x66 }, address = address, height = reconcileTip, + healVerdict = healImmature, + ), ), spent = listOf(flippedTxid to 1), ), @@ -8177,7 +8169,7 @@ class PlatformWalletPersistenceHandlerTest { // A second, failing run renders the incomplete summary as well. handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = engine::page, classifyOutpoints = { null }, ) @@ -8275,7 +8267,7 @@ class PlatformWalletPersistenceHandlerTest { val thrown = runCatching { handler.reconcileTxos( walletId = walletId, - tipHeight = reconcileTip, + retryStalePage = retryStale, engineUtxoPage = { _, _ -> malformed }, classifyOutpoints = { queriesJson -> ByteArray( @@ -8311,7 +8303,7 @@ class PlatformWalletPersistenceHandlerTest { PlatformWalletPersistenceHandler.decodeEngineUtxoPage(page(complete)).utxos.size, ) - for (key in listOf("typeTag", "standardTag", "index", "registrationIndex", "keyClass")) { + for (key in listOf("typeTag", "standardTag", "index", "registrationIndex", "keyClass", "healVerdict")) { val thrown = runCatching { PlatformWalletPersistenceHandler.decodeEngineUtxoPage(page(complete - key)) }.exceptionOrNull() @@ -8363,8 +8355,8 @@ class PlatformWalletPersistenceHandlerTest { /** * 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 accessor - * entries and require `inventoryPage` exactly, and they deserialize + * 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 @@ -8397,27 +8389,27 @@ class PlatformWalletPersistenceHandlerTest { 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, + 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, + 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, + 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, + true, true, false, false, healImmature, ), ), page.utxos, @@ -8477,19 +8469,16 @@ class PlatformWalletPersistenceHandlerTest { private suspend fun PlatformWalletPersistenceHandler.reconcileFromInventory( walletId: ByteArray, inventoryJson: String, - tipHeight: Int, - minConfirmations: Int = 100, pageSize: Int = 2, ): PlatformWalletPersistenceHandler.TxoReconcileReport { attributeUnownedTxos(walletId) val engine = FakeEngine(inventoryJson) return reconcileTxos( walletId = walletId, - tipHeight = tipHeight, - minConfirmations = minConfirmations, pageSize = pageSize, engineUtxoPage = engine::page, classifyOutpoints = engine::classify, + retryStalePage = retryStale, ) } @@ -9163,3 +9152,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/TxoReconcileTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileTest.kt index 680328caf44..60728bec505 100644 --- 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 @@ -1,187 +1,43 @@ package org.dashfoundation.dashsdk.wallet import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** - * The manager-side gates and trigger of the TXO-store reconcile — port of - * the steady-state and scheduling cases of Swift's `CoreTxoReconcileTests` - * / `CoreTxoReconcileShutdownTests` (dashpay/platform#4638). Pure: no - * native manager is needed. + * 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 { - private fun progress( - state: SpvSyncState, - filters: Pair?, - headers: Long = 2_535_898, - ) = SpvSyncProgressData( - overallState = state, - overallPercentage = 0.0, - headers = SpvSubProgress(state, headers, headers, 1.0), - filterHeaders = null, - filters = filters?.let { (current, target) -> SpvSubProgress(state, current, target, 0.0) }, - masternodes = null, - ) - - private val steady = progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 2_535_898L to 2_535_898L) - private val syncing = progress(SpvSyncState.SYNCING, filters = 199_999L to 2_535_898L) - - @Test - fun theSteadyStateGateRefusesAnUnfinishedScan() { - assertFalse(TxoReconcileGates.isSteadySyncState(syncing)) - assertFalse(TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAITING_FOR_CONNECTIONS, filters = null))) - assertFalse( - TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.ERROR, filters = 2_535_898L to 2_535_898L)), - ) - assertFalse( - "waiting for events with the filter phase behind its target is a scan still running", - TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 2_535_800L to 2_535_898L)), - ) - assertFalse( - "no filter phase at all proves nothing", - TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = null)), - ) - assertFalse( - "a filter phase with no target proves nothing", - TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.WAIT_FOR_EVENTS, filters = 0L to 0L)), - ) - assertFalse( - "the poll's not-running placeholder is not steady", - TxoReconcileGates.isSteadySyncState(SpvSyncProgressData.EMPTY), - ) - assertTrue(TxoReconcileGates.isSteadySyncState(progress(SpvSyncState.SYNCED, filters = 2_535_898L to 2_535_898L))) - assertTrue("dash-spv's fully synced steady state", TxoReconcileGates.isSteadySyncState(steady)) - } - @Test - fun theTipIsTheFilterHeightFallingBackToTheHeaderTip() { - assertEquals(2_535_898, TxoReconcileGates.scanTipHeight(steady)) - assertEquals( - "falls back to the header tip", - 2_535_898, - TxoReconcileGates.scanTipHeight(progress(SpvSyncState.SYNCED, filters = null)), + 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( - "a filter phase at height 0 falls back too", - 2_535_000, - TxoReconcileGates.scanTipHeight(progress(SpvSyncState.SYNCED, filters = 0L to 0L, headers = 2_535_000)), - ) - assertNull(TxoReconcileGates.scanTipHeight(SpvSyncProgressData.EMPTY)) - } - - @Test - fun everyLoadedWalletRunsOnTheRisingEdgeIntoSteadyState() { - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) - val wallets = listOf("a", "b") - - assertEquals(emptyList(), coordinator.walletsDue(syncing, wallets, nowMs = 0)) - assertEquals(wallets, coordinator.walletsDue(steady, wallets, nowMs = 10)) - assertEquals( - "the next tick inside the cadence schedules nothing", - emptyList(), - coordinator.walletsDue(steady, wallets, nowMs = 20), - ) - } - - @Test - fun aSteadyClientRunsEachWalletAgainOnItsOwnCadence() { - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) - assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) - // A wallet loaded while steady never ran: it is due at once, on its - // own clock. - assertEquals(listOf("b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 500)) - assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 1_000)) - assertEquals(listOf("b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 1_500)) - // A completed run restarts that wallet's cadence. - coordinator.markRun("a", nowMs = 1_900) - assertEquals(emptyList(), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 2_000)) - assertEquals(listOf("a", "b"), coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 2_900)) - } - - @Test - fun aWalletWithARunInFlightIsNeverScheduledOrStartedAgain() { - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) - assertTrue(coordinator.tryBegin("a")) - assertFalse("a second run for the same wallet is refused", coordinator.tryBegin("a")) - assertEquals( - "not even on the rising edge", - listOf("b"), - coordinator.walletsDue(steady, listOf("a", "b"), nowMs = 0), - ) - coordinator.end("a") - assertTrue(coordinator.tryBegin("a")) - } - - @Test - fun leavingSteadyStateArmsTheNextRisingEdge() { - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000_000) - assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) - assertEquals(emptyList(), coordinator.walletsDue(syncing, listOf("a"), nowMs = 10)) - assertEquals( - "back in steady state: due again, cadence or not", - listOf("a"), - coordinator.walletsDue(steady, listOf("a"), nowMs = 20), - ) - } - - @Test - fun stoppingSpvArmsTheNextRisingEdgeWithoutASyncingRead() { - // SPV stops and restarts straight into steady state: the poll feeds - // no progress while SPV is down and the first read after the - // restart is already steady, so no syncing read ever lowers the - // edge. The stop itself must, or every wallet waits out the cadence. - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000_000) - val wallets = listOf("a", "b") - assertEquals(wallets, coordinator.walletsDue(steady, wallets, nowMs = 0)) - assertEquals(emptyList(), coordinator.walletsDue(steady, wallets, nowMs = 10)) - - coordinator.spvStopped() - - assertEquals( - "the first steady read after the restart is a new steady state", - wallets, - coordinator.walletsDue(steady, wallets, nowMs = 20), - ) - assertEquals( - "and only the first", - emptyList(), - coordinator.walletsDue(steady, wallets, nowMs = 30), - ) - } - - @Test - fun closeAndDeletionStaleTheEpochAndDeletionForgetsTheCadence() { - val coordinator = TxoReconcileCoordinator(cadenceMs = 1_000) - val before = coordinator.currentEpoch - coordinator.bumpEpoch() - assertTrue("an in-flight run sees its epoch move", coordinator.currentEpoch != before) - - assertEquals(listOf("a"), coordinator.walletsDue(steady, listOf("a"), nowMs = 0)) - coordinator.forget("a") - assertEquals( - "a re-added wallet is due at once", - listOf("a"), - coordinator.walletsDue(steady, listOf("a"), nowMs = 10), + "only the manager's own closed check has no native code", + listOf(TxoReconcileSkipReason.MANAGER_CLOSED), + TxoReconcileSkipReason.entries.filter { it.gateCode == null }, ) } @Test - fun theWalletGateUsesTheEnginesWatermarkWithinTheTipMargin() { - // Swift's `synced_height + margin >= tip`. The value passed is the - // ENGINE's watermark: the persisted Room copy trails it by a round, - // and gating on that skipped the run at the SYNCED edge of a restore - // (2026-10-02 device run: WALLET_BEHIND_TIP at the transition, the - // next attempt 30 minutes later). - assertTrue(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_211L, tipHeight = 1_565_211)) - assertTrue(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_205L, tipHeight = 1_565_211)) - assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 1_565_204L, tipHeight = 1_565_211)) - // A wallet added behind the tip is still being scanned. - assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = 0L, tipHeight = 1_565_211)) - // The native "no value" sentinel never passes. - assertFalse(TxoReconcileGates.walletCaughtUp(walletSyncedHeight = -1L, tipHeight = 3)) + 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 index 0a6a151d66c..c7d098fbad4 100644 --- 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 @@ -1,5 +1,5 @@ { - "_about": "Golden TXO-reconcile transport payloads, read by rs-unified-sdk-jni (wallet_manager::tests) and the Kotlin PlatformWalletPersistenceHandlerTest. inventoryPage is exactly what walletManagerUtxosPageJson emits for the four accessor entries the Rust test builds; outpointQueries is exactly what the Kotlin reconcile sends walletManagerClassifyOutpoints for the same four coins. Change both sides together.", + "_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 walletManagerClassifyOutpoints for the same four coins. Change both sides together.", "inventoryPage": { "utxos": [ { @@ -17,7 +17,8 @@ "isConfirmed": true, "isInstantlocked": false, "isCoinbase": false, - "isLocked": false + "isLocked": false, + "healVerdict": 2 }, { "typeTag": 0, @@ -34,7 +35,8 @@ "isConfirmed": true, "isInstantlocked": true, "isCoinbase": true, - "isLocked": true + "isLocked": true, + "healVerdict": 2 }, { "typeTag": 3, @@ -51,7 +53,8 @@ "isConfirmed": false, "isInstantlocked": false, "isCoinbase": false, - "isLocked": false + "isLocked": false, + "healVerdict": 0 }, { "typeTag": 12, @@ -70,7 +73,8 @@ "isConfirmed": true, "isInstantlocked": true, "isCoinbase": false, - "isLocked": false + "isLocked": false, + "healVerdict": 1 } ], "cursor": "{\"typeTag\":12,\"standardTag\":0,\"index\":1,\"registrationIndex\":0,\"keyClass\":0,\"userIdentityId\":\"a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1\",\"friendIdentityId\":\"b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2\",\"txid\":\"606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f\",\"vout\":3}", diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 69fce696744..57b99caf299 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -2663,43 +2663,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_s }) } -/// The wallet's core scan watermark as the ENGINE holds it -/// (`platform_wallet_core_wallet_state`'s `synced_height`) — the value the -/// TXO-store reconcile gates on, as Swift's does. The persisted copy in the -/// host store trails it by up to one persistence round, which is exactly -/// the window the SYNCED transition lands in. Throws `NotFound` when the -/// manager does not hold the wallet. Backs -/// `PlatformWalletManager.reconcileTxoStore`. -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerCoreWalletSyncedHeight( - mut env: JNIEnv, - _class: JClass, - manager_handle: jlong, - wallet_id: JByteArray, -) -> jlong { - guard(&mut env, -1, |env| { - let Some(wid) = read_id32(env, &wallet_id) else { - return -1; - }; - let mut state = platform_wallet_ffi::CoreWalletStateFFI { - synced_height: 0, - last_processed_height: 0, - monitor_revision: 0, - }; - let result = unsafe { - platform_wallet_ffi::platform_wallet_core_wallet_state( - manager_handle as Handle, - wid.as_ptr(), - &mut state as *mut platform_wallet_ffi::CoreWalletStateFFI, - ) - }; - if take_pwffi_error(env, result) { - return -1; - } - jlong::from(state.synced_height) - }) -} - #[cfg(feature = "shielded")] sync_start_stop!( Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_shieldedSyncStart, @@ -3258,15 +3221,18 @@ fn core_selection_strategy( } } -// ── TXO-store reconcile transport ───────────────────────────────────── +// ── TXO-store reconcile ─────────────────────────────────────────────── // -// Two exports, each a serialization shim over exactly ONE platform-wallet -// call: `platform_wallet_wallet_utxos_page` and -// `platform_wallet_classify_outpoints`. The account ordering, the cursor -// semantics, the page bound and every classification rule live in -// `platform-wallet`, so the Swift host walks the identical inventory and -// gets the identical verdicts. Nothing here decides anything; it moves -// serde-shaped JSON in and out. +// 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 outpoint classifier +// (`platform_wallet_classify_outpoints`) 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` @@ -3317,12 +3283,13 @@ impl UtxoAccountTuple { } } -/// One row of an inventory page — the typed contract the Kotlin +/// 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. +/// 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 { @@ -3338,18 +3305,20 @@ struct UtxoPageRow { is_instantlocked: bool, is_coinbase: bool, is_locked: bool, + heal_verdict: u8, } impl UtxoPageRow { - /// The row for one accessor entry, carrying the engine's own address - /// and flags. + /// The row for one heal-page entry, carrying the engine's own address, + /// flags and verdict. /// /// # Safety /// - /// `e.address` must be null or a valid NUL-terminated string, and - /// `e.script_pubkey` null or valid for `e.script_pubkey_len` bytes — - /// the accessor's contract for every entry it returns. - unsafe fn from_entry(e: &platform_wallet_ffi::WalletUtxoEntryFFI) -> Self { + /// `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 { @@ -3375,6 +3344,7 @@ impl UtxoPageRow { is_instantlocked: e.is_instantlocked, is_coinbase: e.is_coinbase, is_locked: e.is_locked, + heal_verdict: r.verdict, } } } @@ -3407,7 +3377,7 @@ impl UtxoPage { } /// Where a paged inventory sweep left off — the last row's account tuple -/// and outpoint, which is exactly what `platform_wallet_wallet_utxos_page` +/// 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)] @@ -3502,19 +3472,140 @@ impl OutpointQuery { } } +/// 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. 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; +/// [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); + }) +} + +/// 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 — 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). +/// 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 SYNCED transition and every -/// 30-minute pass. Nothing bigger than one page is ever formatted, copied -/// across JNI, or parsed. +/// 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 @@ -3523,25 +3614,31 @@ impl OutpointQuery { /// 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. +/// layer holds no copy of either bound. `tipHeight` is the tip +/// `walletManagerTxoReconcileBegin` returned. /// -/// Rows carry the engine's own address and flags. 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. +/// 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_walletManagerUtxosPageJson( +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: jni::sys::jint, + 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 }; @@ -3571,15 +3668,16 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w } }; - let mut entries: *const platform_wallet_ffi::WalletUtxoEntryFFI = ptr::null(); + 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_wallet_utxos_page( + 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, @@ -3591,14 +3689,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w let mut rows: Vec = Vec::with_capacity(count); if !entries.is_null() && count > 0 { let items = unsafe { std::slice::from_raw_parts(entries, count) }; - for e in items { - // SAFETY: every entry the accessor returns satisfies the - // pointer contract `from_entry` documents. - rows.push(unsafe { UtxoPageRow::from_entry(e) }); + 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_wallet_utxos_page_free( - entries as *mut platform_wallet_ffi::WalletUtxoEntryFFI, + platform_wallet_ffi::platform_wallet_txo_reconcile_heal_page_free( + entries as *mut platform_wallet_ffi::TxoHealRowFFI, count, ) }; @@ -4513,7 +4611,10 @@ mod tests { //! silently. use super::*; - use platform_wallet_ffi::{AccountTypeTagFFI, StandardAccountTypeTagFFI, WalletUtxoEntryFFI}; + 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 @@ -4541,13 +4642,19 @@ mod tests { script } - /// One accessor entry plus the buffers it points into, kept alive with it. + /// One heal-page row plus the buffers it points into, kept alive with it. struct Coin { - entry: WalletUtxoEntryFFI, + row: TxoHealRowFFI, _address: CString, script: Vec, } + impl Coin { + fn entry(&self) -> &WalletUtxoEntryFFI { + &self.row.utxo + } + } + struct CoinSpec { type_tag: AccountTypeTagFFI, standard_tag: StandardAccountTypeTagFFI, @@ -4563,6 +4670,7 @@ mod tests { height: u32, /// confirmed, instantlocked, coinbase, locked. flags: [bool; 4], + heal_verdict: u8, } impl CoinSpec { @@ -4597,7 +4705,10 @@ mod tests { is_locked: self.flags[3], }; Coin { - entry, + row: TxoHealRowFFI { + utxo: entry, + verdict: self.heal_verdict, + }, _address: address, script, } @@ -4626,6 +4737,7 @@ mod tests { script: p2pkh(0x5b), height: 1_534_921, flags: [true, false, false, false], + heal_verdict: TXO_HEAL_VERDICT_ELIGIBLE, } .build(), CoinSpec { @@ -4642,6 +4754,7 @@ mod tests { script: p2pkh(0xc3), height: 1_400_000, flags: [true, true, true, true], + heal_verdict: TXO_HEAL_VERDICT_ELIGIBLE, } .build(), CoinSpec { @@ -4658,6 +4771,7 @@ mod tests { script: Vec::new(), height: 0, flags: [false, false, false, false], + heal_verdict: TXO_HEAL_VERDICT_INVALID, } .build(), CoinSpec { @@ -4674,6 +4788,7 @@ mod tests { script: p2pkh(0xd4), height: 1_534_000, flags: [true, true, false, false], + heal_verdict: TXO_HEAL_VERDICT_IMMATURE, } .build(), ] @@ -4684,7 +4799,7 @@ mod tests { .iter() // SAFETY: every pointer in a `Coin` entry points into that coin's // own live buffers. - .map(|c| unsafe { UtxoPageRow::from_entry(&c.entry) }) + .map(|c| unsafe { UtxoPageRow::from_heal_row(&c.row) }) .collect() } @@ -4718,7 +4833,7 @@ mod tests { #[test] fn the_golden_cursor_resumes_after_the_last_row() { let coins = golden_coins(); - let last = &coins.last().expect("golden coins").entry; + let last = coins.last().expect("golden coins").entry(); let raw = golden()["inventoryPage"]["cursor"] .as_str() .expect("golden cursor is a string") @@ -4753,6 +4868,7 @@ mod tests { 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); @@ -4760,7 +4876,7 @@ mod tests { let resume = UtxoPageCursor::decode(&raw).expect("own cursor decodes"); - let e = &coin.entry; + let e = coin.entry(); assert_eq!( ( resume.type_tag, @@ -4831,7 +4947,7 @@ mod tests { assert_eq!(parsed.len(), coins.len()); for (q, coin) in parsed.iter().zip(&coins) { - let e = &coin.entry; + 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"); From b998f903de8d22c55f4864e52b2d0094fec1cfd2 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 20 Oct 2026 14:08:21 -0700 Subject: [PATCH 21/28] fix(kotlin-sdk): never lose the native reconcile claim to a cancelled caller `walletManagerTxoReconcileBegin` ran inside a cancellable withContext. If the calling job was cancelled while it waited on the wallet lock, the native side had already claimed the wallet and returned RUN, but the result was discarded and the `try`/`finally` that releases the claim was never entered. The wallet then stayed ALREADY_RUNNING, and out of the cadence, for the rest of the session. Run `begin` under NonCancellable, like `finish`; cancellation is still observed inside the `try`. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/wallet/PlatformWalletManager.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 cd7cb7ed8f2..ca3ae0c83e4 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 @@ -1381,7 +1381,12 @@ class PlatformWalletManager( return try { teardownGate.withOp { val tipOut = IntArray(1) - val gate = withContext(Dispatchers.IO) { + // 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) } From 8a9a3f7fd5ffc246fa5536432c37b084b2e341af Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 20 Oct 2026 14:08:22 -0700 Subject: [PATCH 22/28] style(platform-wallet-ffi): import the UTXO page types the shared helpers name Co-Authored-By: Claude Opus 5.5 --- .../src/manager_diagnostics.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index dcb1d9a6785..1ff99d5051e 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -21,7 +21,7 @@ use platform_wallet::manager::accessors::{ AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, WalletUtxoCursor, WalletUtxoRow, }; use crate::check_ptr; @@ -30,6 +30,7 @@ use crate::core_wallet_types::{ AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + WalletUtxoCursorFFI, WalletUtxoEntryFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -985,8 +986,8 @@ 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: &crate::core_wallet_types::WalletUtxoCursorFFI, -) -> Result { + cursor: &WalletUtxoCursorFFI, +) -> Result { let spec = account_spec_from_raw_tags( cursor.type_tag, cursor.standard_tag, @@ -1003,10 +1004,8 @@ pub(crate) fn wallet_utxo_cursor_from_ffi( /// 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: platform_wallet::manager::accessors::WalletUtxoRow, -) -> crate::core_wallet_types::WalletUtxoEntryFFI { - use crate::core_wallet_types::{account_type_to_tags, OutPointFFI, WalletUtxoEntryFFI}; +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(); @@ -1044,9 +1043,7 @@ pub(crate) fn wallet_utxo_entry_ffi( /// /// # Safety /// `entry` must come from [`wallet_utxo_entry_ffi`], released once. -pub(crate) unsafe fn release_wallet_utxo_entry( - entry: &crate::core_wallet_types::WalletUtxoEntryFFI, -) { +pub(crate) unsafe fn release_wallet_utxo_entry(entry: &WalletUtxoEntryFFI) { if !entry.address.is_null() { let _ = CString::from_raw(entry.address); } @@ -1082,7 +1079,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::WalletUtxoEntryFFI; use platform_wallet::manager::accessors::{wallet_utxos_page, WalletUtxoCursor}; check_ptr!(wallet_id); From 3a73b8b07f9c74fb8bbcc0d3c36d31da77bdc1b8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 17:17:54 -0700 Subject: [PATCH 23/28] fix(platform-wallet): let a wallet the host skips stay due `due_wallets` stamped every wallet it reported as run at that moment. Kotlin then drops the ones it has not wrapped yet (creation and restore publish the native wallet first), so such a wallet lost its run and waited up to 30 minutes. Reporting now consumes nothing; the cadence restarts when a run claims the wallet in `begin`. A run its gate refuses still waits for the cadence, as before, so it is not retried on every poll. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 14 +- .../src/txo_reconcile.rs | 9 +- .../src/manager/txo_reconcile.rs | 131 +++++++++++++----- .../rs-unified-sdk-jni/src/wallet_manager.rs | 6 +- 4 files changed, 117 insertions(+), 43 deletions(-) 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 ca3ae0c83e4..dd14a255bae 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 @@ -2399,11 +2399,15 @@ class PlatformWalletManager( * 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. 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. + * 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 diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index 355e2dbd407..8128a441078 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -63,8 +63,10 @@ pub struct TxoHealRowFFI { /// 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. Each returned wallet counts as -/// scheduled now. `*out_wallet_ids` is released by +/// 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 @@ -90,7 +92,8 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( /// `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. +/// 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. diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index 73c73d91dce..989f5dfbb43 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -269,8 +269,9 @@ impl TxoReconcileSchedule { /// 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. Each returned wallet is stamped `now`, so the next poll - /// does not schedule it again while this run is still gating. + /// 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, @@ -284,32 +285,33 @@ impl TxoReconcileSchedule { } let rising = self.steady_session != Some(spv_session); self.steady_session = Some(spv_session); - let mut due = Vec::new(); - for wallet_id in wallets { - if self.in_flight.contains(&wallet_id) { - continue; - } - let cadence_elapsed = self - .last_run - .get(&wallet_id) - .is_none_or(|last| now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE); - if rising || cadence_elapsed { - self.last_run.insert(wallet_id, now); - due.push(wallet_id); - } + wallets + .into_iter() + .filter(|wallet_id| !self.in_flight.contains(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.insert(wallet_id) { + return false; } - due - } - - /// Claim the wallet for one run; `false` when a run is already in - /// flight. - pub fn try_begin(&mut self, wallet_id: WalletId) -> bool { - self.in_flight.insert(wallet_id) + 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; - /// `None` for one that did not. + /// that went ahead finished, which restarts the wallet's cadence from + /// then; `None` for one that did not. pub fn finish(&mut self, wallet_id: &WalletId, ran_at: Option) { self.in_flight.remove(wallet_id); if let Some(at) = ran_at { @@ -361,7 +363,7 @@ impl TxoReconciler { /// On `Ok` the claim holds until [`Self::finish`]; a refusal releases /// it. pub async fn begin(&self, wallet_id: &WalletId) -> Result { - if !lock_schedule(&self.schedule).try_begin(*wallet_id) { + if !lock_schedule(&self.schedule).try_begin(*wallet_id, Instant::now()) { return Err(TxoReconcileSkip::AlreadyRunning); } let gate = self.gate(wallet_id).await; @@ -373,7 +375,7 @@ impl TxoReconciler { /// Release the claim [`Self::begin`] granted. `ran` is whether the run /// went ahead (even if it stopped early): that restarts the wallet's - /// cadence. + /// cadence from now. pub fn finish(&self, wallet_id: &WalletId, ran: bool) { lock_schedule(&self.schedule).finish(wallet_id, ran.then(Instant::now)); } @@ -657,12 +659,22 @@ mod tests { 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)) @@ -677,20 +689,24 @@ mod tests { 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] ); - // A finished run restarts that wallet's cadence. + 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)); + 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) @@ -704,18 +720,63 @@ mod tests { #[test] fn a_wallet_with_a_run_in_flight_is_never_scheduled_or_started_again() { let mut schedule = TxoReconcileSchedule::default(); - assert!(schedule.try_begin(A)); + let t0 = Instant::now(); + assert!(schedule.try_begin(A, t0)); assert!( - !schedule.try_begin(A), + !schedule.try_begin(A, t0), "a second run for the same wallet is refused" ); assert_eq!( - schedule.due_wallets(true, 1, [A, B], Instant::now()), + 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)); + 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] @@ -723,6 +784,7 @@ mod tests { 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()); @@ -740,6 +802,7 @@ mod tests { 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()); @@ -748,6 +811,7 @@ mod tests { 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)) @@ -761,6 +825,7 @@ mod tests { 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)), @@ -804,7 +869,7 @@ mod tests { #[tokio::test] async fn a_granted_claim_holds_until_finish() { let reconciler = reconciler(); - assert!(lock_schedule(&reconciler.schedule).try_begin(A)); + assert!(lock_schedule(&reconciler.schedule).try_begin(A, Instant::now())); assert_eq!( reconciler.begin(&A).await, Err(TxoReconcileSkip::AlreadyRunning) diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 57b99caf299..0b34b8172b8 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3475,8 +3475,10 @@ impl OutpointQuery { /// 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. Called on every SPV progress -/// poll, running or not. Backs `PlatformWalletManager`'s progress poll. +/// 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, From ee82972fb1cf9e10d0858eacdbb477b722e00662 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 17:19:23 -0700 Subject: [PATCH 24/28] fix(platform-wallet): keep a removed wallet's reconcile cadence forgotten Removing a wallet forgets its reconcile cadence, but a run still in flight could finish afterwards and write its timestamp back. Kotlin's epoch stop makes this reachable: the run ends early and still reports that it ran. A wallet re-added under the same id while SPV stays steady then waited out the old run's 30 minutes. Each claim now records whether its wallet was forgotten while it ran; the old run keeps the wallet until it exits, but its finish restores no history. Co-Authored-By: Claude Opus 5.5 --- .../src/manager/txo_reconcile.rs | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index 989f5dfbb43..f087b2b6813 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -25,7 +25,7 @@ //! resolving the account that owns each, its transactions, and the fences //! that keep its persistence callbacks and teardown out of a run's writes. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; @@ -258,10 +258,20 @@ pub struct TxoReconcileSchedule { /// no non-steady read was seen in between (a stop and restart quicker /// than the host's poll). steady_session: Option, - in_flight: BTreeSet, + in_flight: BTreeMap, last_run: BTreeMap, } +/// One run in flight. +#[derive(Debug, Default)] +struct ReconcileClaim { + /// 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, +} + 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 @@ -287,7 +297,7 @@ impl TxoReconcileSchedule { self.steady_session = Some(spv_session); wallets .into_iter() - .filter(|wallet_id| !self.in_flight.contains(wallet_id)) + .filter(|wallet_id| !self.in_flight.contains_key(wallet_id)) .filter(|wallet_id| { rising || self.last_run.get(wallet_id).is_none_or(|last| { @@ -302,27 +312,35 @@ impl TxoReconcileSchedule { /// 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.insert(wallet_id) { + if self.in_flight.contains_key(&wallet_id) { return false; } + self.in_flight.insert(wallet_id, ReconcileClaim::default()); 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. + /// then; `None` for one that did not. A claim whose wallet was + /// forgotten meanwhile records nothing. pub fn finish(&mut self, wallet_id: &WalletId, ran_at: Option) { - self.in_flight.remove(wallet_id); - if let Some(at) = ran_at { + let Some(claim) = self.in_flight.remove(wallet_id) else { + return; + }; + 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. + /// 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; + } } } @@ -834,6 +852,38 @@ mod tests { ); } + #[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 { let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( dashcore::Network::Testnet, From 1c37445e96e78a3e4edd950a2a184ad27615803f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 17:20:43 -0700 Subject: [PATCH 25/28] fix(platform-wallet): release a reconcile claim when begin is dropped `TxoReconciler::begin` claimed the wallet before awaiting its gate and released the claim only when the gate refused. A caller that dropped the future mid-await, while it waited on SPV progress or the wallet lock, left the wallet claimed for the rest of the session: every later begin answered AlreadyRunning and the schedule never reported it again. The claim is now provisional until the gate passes, and a guard releases it on a refusal or a drop. The Android path was already protected by the blocking C adapter and Kotlin's non-cancellable call. Co-Authored-By: Claude Opus 5.5 --- .../src/manager/txo_reconcile.rs | 120 ++++++++++++++++-- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index f087b2b6813..d5fd0c23178 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -26,6 +26,7 @@ //! that keep its persistence callbacks and teardown out of a run's writes. use std::collections::BTreeMap; +use std::future::Future; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; @@ -350,6 +351,51 @@ fn lock_schedule(schedule: &Mutex) -> MutexGuard<'_, TxoRe 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. @@ -379,16 +425,24 @@ impl TxoReconciler { /// [`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. + /// it, and so does dropping this future before it completes. pub async fn begin(&self, wallet_id: &WalletId) -> Result { - if !lock_schedule(&self.schedule).try_begin(*wallet_id, Instant::now()) { - return Err(TxoReconcileSkip::AlreadyRunning); - } - let gate = self.gate(wallet_id).await; - if gate.is_err() { - lock_schedule(&self.schedule).finish(wallet_id, None); - } - gate + 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) } /// Release the claim [`Self::begin`] granted. `ran` is whether the run @@ -446,6 +500,10 @@ impl PlatformWalletManager

{ mod tests { use super::*; + use std::future::pending; + use std::pin::pin; + use std::task::{Context, Waker}; + use dashcore::hashes::Hash; use dashcore::{OutPoint, Txid}; use key_wallet::account::{AccountType, StandardAccountType}; @@ -930,4 +988,48 @@ mod tests { 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) + ); + } } From 133ffdfe7a0ba2a3bb36521cadeaddc52d3b267f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 17:37:30 -0700 Subject: [PATCH 26/28] fix(platform-wallet): hold the engine still until a spend verdict is written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reconcile classified a page, released the engine, and wrote the verdicts afterwards. Its fences there see the host's persistence rounds only, and the engine can move with none: dash-spv follows a fork through `truncate_above` under the wallet-manager 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 is #5380). A flip landing after such a fork retires a live coin for good. `TxoReconciler::classify_held` now answers under an owned read guard on the wallet manager and keeps it in the run's claim until the host has applied the page, so a fork waits for that one Room transaction. It is released by `release_held` (Kotlin calls it in a `finally`), by `finish`, by the next page's classification before it waits, and by the manager's shutdown. Mined spenders are actionable again whether or not a ChainLock covers them; mempool spends still are not. `classify_outpoints` and the Swift path are unchanged. Lock order, written into the module docs: the wallet manager's lock, then the host's callback exclusion, then the store transaction — the same order as the engine calling a persistence callback under its write lock. The schedule mutex is a leaf, and tokio's fair RwLock cannot close a cycle because the guard holder never waits for an engine lock. New C exports: `platform_wallet_txo_reconcile_classify_outpoints`, `platform_wallet_manager_txo_reconcile_release_verdicts`. The JNI `walletManagerClassifyOutpoints` becomes `walletManagerTxoReconcileClassifyOutpoints`, with `walletManagerTxoReconcileReleaseVerdicts` beside it. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/WalletManagerNative.kt | 25 +- .../PlatformWalletPersistenceHandler.kt | 116 ++++-- .../dashsdk/wallet/PlatformWalletManager.kt | 24 +- .../PlatformWalletPersistenceHandlerTest.kt | 189 ++++++++++ .../golden/txo_reconcile_transport_v1.json | 2 +- .../rs-platform-wallet-ffi/src/manager.rs | 25 ++ .../src/manager_diagnostics.rs | 66 ++-- .../src/txo_reconcile.rs | 120 ++++++- .../src/manager/accessors.rs | 16 +- .../rs-platform-wallet/src/manager/mod.rs | 4 + .../src/manager/txo_reconcile.rs | 332 +++++++++++++++++- .../rs-unified-sdk-jni/src/wallet_manager.rs | 46 ++- 12 files changed, 864 insertions(+), 101 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 2dcc6fbff46..25ea149789c 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 @@ -259,13 +259,34 @@ internal object WalletManagerNative { * 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. - */ - external fun walletManagerClassifyOutpoints( + * + * 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; release it with [walletManagerTxoReconcileReleaseVerdicts] + * (finish releases it too). 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, ): ByteArray? + /** + * Release the engine wallet-lock guard + * [walletManagerTxoReconcileClassifyOutpoints] kept for the wallet's + * reconcile run, once its page is applied. Nothing to do when none is + * held. + */ + external fun walletManagerTxoReconcileReleaseVerdicts( + managerHandle: Long, + walletId: ByteArray, + ) + // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // // Each step is a thin extern (one export = one FFI call, per diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 6a2bfd84a2b..33240914e1e 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,7 @@ 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 @@ -1418,11 +1420,12 @@ class PlatformWalletPersistenceHandler( ) /** - * One store row handed to `walletManagerClassifyOutpoints`: 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. + * 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. * @@ -1644,6 +1647,9 @@ class PlatformWalletPersistenceHandler( * [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 @@ -1664,6 +1670,17 @@ class PlatformWalletPersistenceHandler( * close and on wallet deletion, and the run stops between pages, * reporting `completed = false`. * + * Lock order, the same as the engine's own: 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]. 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 @@ -1685,6 +1702,7 @@ class PlatformWalletPersistenceHandler( 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) @@ -1695,7 +1713,9 @@ class PlatformWalletPersistenceHandler( // 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, retryStalePage, tally, walletRef) + classifyStoreRows( + walletId, limit, isCancelled, classifyOutpoints, releaseVerdicts, retryStalePage, tally, walletRef, + ) } val report = tally.toReport() logReconcileSummary(report, walletRef) @@ -1904,6 +1924,18 @@ class PlatformWalletPersistenceHandler( * 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, @@ -1915,6 +1947,7 @@ class PlatformWalletPersistenceHandler( limit: Int, isCancelled: () -> Boolean, classifyOutpoints: suspend (queriesJson: String) -> ByteArray?, + releaseVerdicts: suspend () -> Unit, retryStalePage: (staleInARow: Int) -> Boolean, tally: ReconcileTally, walletRef: String, @@ -1936,40 +1969,46 @@ class PlatformWalletPersistenceHandler( tally.unresolvedAccount += page.unresolvedAccount if (page.rows.isNotEmpty()) { - 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)) { + // 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 } - // 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 + 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() } } - 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) } after = page.lastOutpoint ?: return if (page.fetched < limit) return @@ -5128,8 +5167,9 @@ class PlatformWalletPersistenceHandler( internal fun decodeEngineUtxoPage(pageJson: String): EngineUtxoPage = engineJson.decodeFromString(EngineUtxoPage.serializer(), pageJson) - /** One classification batch, as `walletManagerClassifyOutpoints` - * deserializes it (rs-unified-sdk-jni `OutpointQuery`). */ + /** 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()), 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 dd14a255bae..64f252794c4 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 @@ -1354,8 +1354,9 @@ class PlatformWalletManager( * 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 when it - * ends ([WalletManagerNative.walletManagerTxoReconcileFinish]). + * 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`. * @@ -1411,11 +1412,28 @@ class PlatformWalletManager( }, classifyOutpoints = { queriesJson -> reconcileEngineRead("outpoint classification", walletRef) { - WalletManagerNative.walletManagerClassifyOutpoints( + WalletManagerNative.walletManagerTxoReconcileClassifyOutpoints( managerHandle, walletId, queriesJson, ) } }, + releaseVerdicts = { + // Drops the engine guard only; it never waits on + // the engine. A failure leaves it to `finish`. + runCatching { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileReleaseVerdicts( + managerHandle, walletId, + ) + } + }.onFailure { t -> + android.util.Log.w( + "PlatformWalletManager", + "txos reconcile: verdict release failed " + + "error=${t.javaClass.simpleName} wallet=$walletRef", + ) + } + }, retryStalePage = WalletManagerNative::txoReconcileRetryStalePage, ) ran = true 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 f154b87f263..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 @@ -7063,6 +7063,7 @@ class PlatformWalletPersistenceHandlerTest { pageSize = pageSize, isCancelled = isCancelled, engineUtxoPage = engine::page, + releaseVerdicts = {}, classifyOutpoints = engine::classify, ) @@ -7133,6 +7134,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = { classifyCalls++ if (classifyCalls == 1) { @@ -7157,6 +7159,181 @@ class PlatformWalletPersistenceHandlerTest { ) } + /** + * 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 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, + ) + } + @Test fun aPageThatKeepsGoingStaleStopsTheRunIncomplete() = runTest { // A round commits between every read and apply: the page is @@ -7171,6 +7348,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = { classifyCalls++ handler.onChangesetBegin(walletId) @@ -7215,6 +7393,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = { classifyCalls++ if (classifyCalls == 1) { @@ -7256,6 +7435,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = { ByteArray(2) { PlatformWalletPersistenceHandler.OUTPOINT_CLASS_UNSPENT } }, ) assertTrue(after.completed) @@ -7495,6 +7675,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = engine::classify, ) @@ -7544,6 +7725,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> emptyEnginePage }, + releaseVerdicts = {}, classifyOutpoints = engine::classify, ) @@ -7941,6 +8123,7 @@ class PlatformWalletPersistenceHandlerTest { engineUtxoPage = { cursor, limit -> if (cursor == null) engine.page(cursor, limit) else null }, + releaseVerdicts = {}, classifyOutpoints = engine::classify, ) @@ -7989,6 +8172,7 @@ class PlatformWalletPersistenceHandlerTest { retryStalePage = retryStale, pageSize = 1, engineUtxoPage = engine::page, + releaseVerdicts = {}, classifyOutpoints = { null }, ) @@ -8008,6 +8192,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> null }, + releaseVerdicts = {}, classifyOutpoints = { queriesJson -> classifyCalls++ ByteArray( @@ -8123,6 +8308,7 @@ class PlatformWalletPersistenceHandlerTest { engineUtxoPage = { cursor, limit -> engine.page(cursor, limit).also { cancelled = true } }, + releaseVerdicts = {}, classifyOutpoints = engine::classify, ) @@ -8171,6 +8357,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = engine::page, + releaseVerdicts = {}, classifyOutpoints = { null }, ) @@ -8269,6 +8456,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, retryStalePage = retryStale, engineUtxoPage = { _, _ -> malformed }, + releaseVerdicts = {}, classifyOutpoints = { queriesJson -> ByteArray( kotlinx.serialization.json.Json @@ -8477,6 +8665,7 @@ class PlatformWalletPersistenceHandlerTest { walletId = walletId, pageSize = pageSize, engineUtxoPage = engine::page, + releaseVerdicts = {}, classifyOutpoints = engine::classify, retryStalePage = retryStale, ) 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 index c7d098fbad4..5cb3dc52918 100644 --- 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 @@ -1,5 +1,5 @@ { - "_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 walletManagerClassifyOutpoints for the same four coins. Change both sides together.", + "_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": [ { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 1ae61e57131..61d99d9894b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1575,6 +1575,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_manager_txo_reconcile_release_verdicts, + platform_wallet_txo_reconcile_classify_outpoints, + }; let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_cbs = event_callbacks(); @@ -1628,6 +1632,27 @@ 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 result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + queries.as_ptr(), + queries.len(), + out.as_mut_ptr(), + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + assert_eq!(out, [0, 0, 0]); + let result = unsafe { + platform_wallet_manager_txo_reconcile_release_verdicts(handle, wallet_id.as_ptr()) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); 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 1ff99d5051e..4983206cfff 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -17,9 +17,10 @@ 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, WalletUtxoCursor, WalletUtxoRow, }; @@ -28,9 +29,9 @@ use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, - WalletUtxoCursorFFI, WalletUtxoEntryFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutpointOwnershipQueryFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, + WalletIdentityRowFFI, WalletUtxoCursorFFI, WalletUtxoEntryFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -1163,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(); @@ -1184,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) @@ -1217,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 index 8128a441078..67254b38573 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -1,19 +1,22 @@ //! C ABI over `platform_wallet::manager::txo_reconcile`: the schedule, -//! start gate, heal verdicts and stale-page retry of the host TXO-store -//! reconcile (dashpay/platform#4638). Each export forwards to one Rust -//! operation; the decisions, thresholds and cadence live in -//! `platform-wallet`. Which store rows to mark spent is -//! `platform_wallet_classify_outpoints`'s answer. +//! 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}; use crate::check_ptr; -use crate::core_wallet_types::{WalletUtxoCursorFFI, WalletUtxoEntryFFI}; +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::{ - release_wallet_utxo_entry, wallet_utxo_cursor_from_ffi, wallet_utxo_entry_ffi, + 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; @@ -123,9 +126,10 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( } /// 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. +/// `platform_wallet_manager_txo_reconcile_begin` granted, and the engine +/// guard `platform_wallet_txo_reconcile_classify_outpoints` may still hold +/// for it. `ran` is whether the run went ahead (even if it stopped early), +/// which restarts the wallet's cadence. /// /// # Safety /// `wallet_id` points at 32 readable bytes. @@ -237,6 +241,66 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_free( let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); } +/// 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, 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 with +/// `platform_wallet_manager_txo_reconcile_release_verdicts` once the page +/// is applied (`platform_wallet_manager_txo_reconcile_finish` releases it +/// too). Call it outside the host's persistence-callback exclusion and +/// never from inside a persistence callback: the engine calls those +/// callbacks under its write lock. Fails with nothing held when the wallet +/// holds no run claim. +/// +/// # 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. +#[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, +) -> PlatformWalletFFIResult { + 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)?; + 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(), + ) + }, + ) + }) +} + +/// Release the engine guard `platform_wallet_txo_reconcile_classify_outpoints` +/// kept for `wallet_id`'s reconcile run, once the host has applied that +/// page. Nothing to do when none is held. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_release_verdicts( + manager_handle: Handle, + wallet_id: *const u8, +) -> 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().release_held(&wid)); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() +} + /// 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. @@ -251,6 +315,8 @@ mod tests { 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 [ @@ -340,5 +406,39 @@ mod tests { 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 result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + Handle::MAX, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(classes, [OUTPOINT_CLASS_UNKNOWN]); + + let result = unsafe { + platform_wallet_manager_txo_reconcile_release_verdicts(Handle::MAX, wallet_id.as_ptr()) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); } } 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 08d6332f91e..18643d602ee 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1140,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 index d5fd0c23178..f423e4ef766 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -16,16 +16,51 @@ //! [`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`]. //! -//! Which store rows to mark spent is -//! [`classify_outpoints`](crate::manager::accessors::classify_outpoints)'s -//! verdict. 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. +//! 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. the wallet manager's `RwLock`, read side only, taken by +//! [`TxoReconciler::classify_held`] and kept until the host's apply has +//! committed; +//! 2. the host's callback exclusion (Kotlin's `callbackExclusion`), taken +//! for the apply; +//! 3. 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, 1 before 2. 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. The schedule mutex is a leaf: held only for a few +//! map operations, never across an await or another lock, so it orders +//! with nothing. 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. 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. 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 [`TxoReconciler::classify_held`] +//! drops a run's previous guard 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}; @@ -33,10 +68,13 @@ use std::time::{Duration, Instant}; use arc_swap::ArcSwap; use key_wallet_manager::WalletManager; -use tokio::sync::RwLock; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; use crate::changeset::PlatformWalletPersistence; -use crate::manager::accessors::{wallet_utxos_page, WalletUtxoCursor, WalletUtxoRow}; +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}; @@ -263,14 +301,31 @@ pub struct TxoReconcileSchedule { last_run: BTreeMap, } +/// 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>; + /// One run in flight. -#[derive(Debug, Default)] +#[derive(Default)] struct ReconcileClaim { /// 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 engine read guard the run's last classification is held under + /// until its host has applied the verdicts. Dropped with the claim, so + /// [`TxoReconcileSchedule::finish`] releases it on every path. + held: Option, +} + +impl fmt::Debug for ReconcileClaim { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReconcileClaim") + .field("forgotten", &self.forgotten) + .field("holds_engine", &self.held.is_some()) + .finish() + } } impl TxoReconcileSchedule { @@ -445,9 +500,66 @@ impl TxoReconciler { Ok(tip) } - /// Release the claim [`Self::begin`] granted. `ran` is whether the run - /// went ahead (even if it stopped early): that restarts the wallet's - /// cadence from now. + /// [`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. The host writes the verdicts to its + /// store and then calls [`Self::release_held`]; [`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 guard of its + /// previous page is released before this waits for the next. + /// + /// `None` — nothing classified, nothing held — when the wallet holds no + /// run claim, or its claim was released while this waited. + pub async fn classify_held( + &self, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> Option> { + let previous = lock_schedule(&self.schedule) + .in_flight + .get_mut(wallet_id)? + .held + .take(); + drop(previous); + let engine = Arc::clone(&self.wallet_manager).read_owned().await; + let classes = classify_outpoints(&engine, wallet_id, queries); + let mut schedule = lock_schedule(&self.schedule); + schedule.in_flight.get_mut(wallet_id)?.held = Some(engine); + Some(classes) + } + + /// Release the engine read guard [`Self::classify_held`] kept for + /// `wallet_id`'s run, once the host has applied its verdicts. Nothing + /// to do when none is held. + pub fn release_held(&self, wallet_id: &WalletId) { + let held = lock_schedule(&self.schedule) + .in_flight + .get_mut(wallet_id) + .and_then(|claim| claim.held.take()); + drop(held); + } + + /// 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)); } @@ -490,10 +602,22 @@ impl PlatformWalletManager

{ } } - /// Drop a removed wallet's reconcile cadence. + /// 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 held: Vec = lock_schedule(&self.txo_reconcile_schedule) + .in_flight + .values_mut() + .filter_map(|claim| claim.held.take()) + .collect(); + drop(held); + } } #[cfg(test)] @@ -504,11 +628,18 @@ mod tests { 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; @@ -943,9 +1074,13 @@ mod tests { } fn reconciler() -> TxoReconciler { - let wallet_manager = Arc::new(RwLock::new(WalletManager::::new( + 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())), @@ -1032,4 +1167,173 @@ mod tests { 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); + + #[tokio::test] + async fn a_classification_needs_the_runs_claim() { + let reconciler = reconciler(); + assert_eq!(reconciler.classify_held(&A, &[]).await, None); + assert!(lock_schedule(&reconciler.schedule).try_begin(A, Instant::now())); + assert_eq!(reconciler.classify_held(&A, &[]).await, Some(Vec::new())); + assert!(lock_schedule(&reconciler.schedule).in_flight[&A] + .held + .is_some()); + reconciler.release_held(&A); + assert!(lock_schedule(&reconciler.schedule).in_flight[&A] + .held + .is_none()); + } + + /// 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!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); + + assert_eq!( + reconciler.classify_held(&wallet_id, &queries).await, + Some(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" + ); + + reconciler.release_held(&wallet_id); + 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!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); + assert!(reconciler + .classify_held(&wallet_id, &queries) + .await + .is_some()); + 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"); + } + + /// 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. The previous guard is released + /// first, so the fork goes ahead and the next page sees its outcome. + #[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!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); + assert!(reconciler + .classify_held(&wallet_id, &queries) + .await + .is_some()); + let mut fork = spawn_fork(&reconciler); + assert!(timeout(BLOCKED, &mut fork).await.is_err()); + + let next = timeout(PROCEEDS, reconciler.classify_held(&wallet_id, &queries)) + .await + .expect("the next page is classified, not deadlocked"); + assert_eq!( + next, + Some(vec![KnownUncredited, KnownUncredited, Unspent]), + "the queued fork went first" + ); + timeout(PROCEEDS, fork) + .await + .expect("fork finished") + .expect("fork task"); + reconciler.release_held(&wallet_id); + } } diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 0b34b8172b8..9d6b2304346 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3226,8 +3226,10 @@ fn core_selection_strategy( // 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 outpoint classifier -// (`platform_wallet_classify_outpoints`) and the stale-page retry +// (`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_release_verdicts`) 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`. @@ -3549,8 +3551,9 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } -/// Release the claim a 0 from `walletManagerTxoReconcileBegin` granted; -/// [ran] is whether the run went ahead. Bridges +/// 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( @@ -3575,6 +3578,30 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w }) } +/// Release the engine guard `walletManagerTxoReconcileClassifyOutpoints` +/// kept for the wallet's reconcile run, once its page is applied. Bridges +/// `platform_wallet_manager_txo_reconcile_release_verdicts`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileReleaseVerdicts( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, +) { + 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_release_verdicts( + manager_handle as Handle, + wid.as_ptr(), + ) + }; + let _ = take_pwffi_error(env, result); + }) +} + /// 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. @@ -3740,8 +3767,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w /// `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; release it with +/// `walletManagerTxoReconcileReleaseVerdicts`. 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_walletManagerClassifyOutpoints( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_walletManagerTxoReconcileClassifyOutpoints( mut env: JNIEnv, _class: JClass, manager_handle: jlong, @@ -3803,7 +3837,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w // Caller-allocated answers, one byte per query. let mut classes = vec![platform_wallet_ffi::OUTPOINT_CLASS_UNKNOWN; queries.len()]; let result = unsafe { - platform_wallet_ffi::platform_wallet_classify_outpoints( + platform_wallet_ffi::platform_wallet_txo_reconcile_classify_outpoints( manager_handle as Handle, wid.as_ptr(), queries.as_ptr(), From 7764daf5bc2e2f706540381b9257a4eca52d0bb3 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 18:13:44 -0700 Subject: [PATCH 27/28] fix(platform-wallet-ffi): release held reconcile verdicts without the handle registry The per-page release of a reconcile's engine guard resolved the manager handle in the C API registry, while every other C API path takes the registry first and the engine lock second. A cycle followed: a run holds the guard; a block or fork queues an engine write behind it; a native read parks on the engine behind that write while holding the registry for reading; a manager create or destroy queues a registry write behind that read, and the fair registry lock then parks the release. Every native call hung, and close could not recover. Classification now hands the host its guard as an opaque token, `TxoReconcileHeldVerdicts` (`TxoReconcileHeldVerdictsFFI` in the C API), which `platform_wallet_txo_reconcile_held_verdicts_free` drops without any registry lookup or wait. The JNI classify call returns it in an `outHeld` LongArray, written only together with the answers, and `txoReconcileHeldVerdictsFree` frees it; Kotlin takes it out of an AtomicLong before freeing, so it is released once, in the handler's `finally` and again before `finish`. `finish` and manager shutdown still empty the guard's slot as backstops, and a classification that waited across a finish and a new begin no longer hands its guard to the new claim. The registry lock and the rule that no path holding the guard waits for it are added to the documented lock order. The regression test runs the cycle in a child process: guard held, engine writer queued, registry reader parked behind it, registry writer queued; freeing the token still completes. With a registry lookup put back into the release, the child deadlocks and the test fails. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/WalletManagerNative.kt | 28 +- .../PlatformWalletPersistenceHandler.kt | 26 +- .../dashsdk/wallet/PlatformWalletManager.kt | 48 +-- .../rs-platform-wallet-ffi/src/manager.rs | 25 +- .../src/txo_reconcile.rs | 274 +++++++++++++--- .../src/manager/txo_reconcile.rs | 296 +++++++++++++----- .../rs-unified-sdk-jni/src/wallet_manager.rs | 58 ++-- 7 files changed, 567 insertions(+), 188 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 25ea149789c..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 @@ -264,28 +264,30 @@ internal object WalletManagerNative { * [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; release it with [walletManagerTxoReconcileReleaseVerdicts] - * (finish releases it too). 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. + * 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 wallet-lock guard - * [walletManagerTxoReconcileClassifyOutpoints] kept for the wallet's - * reconcile run, once its page is applied. Nothing to do when none is - * held. + * 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 walletManagerTxoReconcileReleaseVerdicts( - managerHandle: Long, - walletId: ByteArray, - ) + external fun txoReconcileHeldVerdictsFree(held: Long) // ── Core transaction builder (1:1 over `core_wallet_tx_builder_*`) ─ // 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 6ae10700a28..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 @@ -1670,16 +1670,22 @@ class PlatformWalletPersistenceHandler( * close and on wallet deletion, and the run stops between pages, * reporting `completed = false`. * - * Lock order, the same as the engine's own: 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]. The guard covers one page: the - * apply is one Room transaction. + * 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 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 64f252794c4..26b87fad068 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 @@ -1397,6 +1397,9 @@ class PlatformWalletManager( } 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) @@ -1412,33 +1415,30 @@ class PlatformWalletManager( }, classifyOutpoints = { queriesJson -> reconcileEngineRead("outpoint classification", walletRef) { - WalletManagerNative.walletManagerTxoReconcileClassifyOutpoints( - managerHandle, walletId, queriesJson, - ) - } - }, - releaseVerdicts = { - // Drops the engine guard only; it never waits on - // the engine. A failure leaves it to `finish`. - runCatching { - mapNativeErrors { - WalletManagerNative.walletManagerTxoReconcileReleaseVerdicts( - managerHandle, walletId, + 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) } - }.onFailure { t -> - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile: verdict release failed " + - "error=${t.javaClass.simpleName} wallet=$walletRef", - ) } }, + releaseVerdicts = { freeHeldVerdicts(heldVerdicts) }, retryStalePage = WalletManagerNative::txoReconcileRetryStalePage, ) ran = true TxoReconcileOutcome.Reconciled(report) } finally { + // The guard first, without the native registry: `finish` + // resolves the manager handle there, which must never be + // waited for while a guard is held. + freeHeldVerdicts(heldVerdicts) // 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 @@ -1463,6 +1463,18 @@ class PlatformWalletManager( } } + /** + * 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 diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 6baddbfc617..d60b06e2ed3 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1582,8 +1582,8 @@ mod tests { use crate::core_wallet_types::{OutPointFFI, OutpointOwnershipQueryFFI}; use crate::manager_diagnostics::platform_wallet_classify_outpoints; use crate::txo_reconcile::{ - platform_wallet_manager_txo_reconcile_release_verdicts, 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(); @@ -1641,6 +1641,7 @@ mod tests { // 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, @@ -1648,6 +1649,7 @@ mod tests { queries.as_ptr(), queries.len(), out.as_mut_ptr(), + &mut held, ) }; assert_eq!( @@ -1655,10 +1657,29 @@ mod tests { 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_manager_txo_reconcile_release_verdicts(handle, wallet_id.as_ptr()) + 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/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index 67254b38573..814ba3d494e 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -6,7 +6,9 @@ //! `platform-wallet`. use platform_wallet::manager::accessors::WalletUtxoCursor; -use platform_wallet::manager::txo_reconcile::{txo_heal_page, txo_reconcile_retry_stale_page}; +use platform_wallet::manager::txo_reconcile::{ + txo_heal_page, txo_reconcile_retry_stale_page, TxoReconcileHeldVerdicts, +}; use crate::check_ptr; use crate::core_wallet_types::{ @@ -126,10 +128,12 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( } /// Release the claim a `RUN` from -/// `platform_wallet_manager_txo_reconcile_begin` granted, and the engine -/// guard `platform_wallet_txo_reconcile_classify_outpoints` may still hold -/// for it. `ran` is whether the run went ahead (even if it stopped early), -/// which restarts the wallet's cadence. +/// `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. @@ -241,24 +245,41 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_free( 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, 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 with -/// `platform_wallet_manager_txo_reconcile_release_verdicts` once the page -/// is applied (`platform_wallet_manager_txo_reconcile_finish` releases it -/// too). Call it outside the host's persistence-callback exclusion and -/// never from inside a persistence callback: the engine calls those -/// callbacks under its write lock. Fails with nothing held when the wallet -/// holds no run claim. +/// 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. +/// bytes. `out_held` is writable. #[no_mangle] pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_outpoints( manager_handle: Handle, @@ -266,39 +287,40 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_outpoints( 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)?; - 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(), - ) - }, - ) + 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` -/// kept for `wallet_id`'s reconcile run, once the host has applied that -/// page. Nothing to do when none is held. +/// 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 -/// `wallet_id` points at 32 readable bytes. +/// `held` is null or a pointer that call returned, released exactly once. #[no_mangle] -pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_release_verdicts( - manager_handle: Handle, - wallet_id: *const u8, -) -> 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().release_held(&wid)); - unwrap_option_or_return!(option); - PlatformWalletFFIResult::ok() +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 @@ -424,6 +446,7 @@ mod tests { 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, @@ -431,14 +454,189 @@ mod tests { &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_manager_txo_reconcile_release_verdicts(Handle::MAX, wallet_id.as_ptr()) + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + &mut held, + ) }; - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + 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/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index f423e4ef766..950a8702b63 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -30,34 +30,51 @@ //! //! A run touches, in this order and never the other way round: //! -//! 1. the wallet manager's `RwLock`, read side only, taken by -//! [`TxoReconciler::classify_held`] and kept until the host's apply has -//! committed; -//! 2. the host's callback exclusion (Kotlin's `callbackExclusion`), taken +//! 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; -//! 3. the host's store transaction (one Room transaction for one page). +//! 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, 1 before 2. So a host must take the guard before the exclusion +//! 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. The schedule mutex is a leaf: held only for a few -//! map operations, never across an await or another lock, so it orders -//! with nothing. SPV progress is read only by [`TxoReconciler::due_wallets`] -//! and [`TxoReconciler::begin`], before a run holds anything. +//! 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. 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. 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 [`TxoReconciler::classify_held`] -//! drops a run's previous guard before it waits for a new one. +//! 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; @@ -299,29 +316,70 @@ pub struct TxoReconcileSchedule { 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 engine read guard the run's last classification is held under - /// until its host has applied the verdicts. Dropped with the claim, so - /// [`TxoReconcileSchedule::finish`] releases it on every path. - held: Option, + /// 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() @@ -371,7 +429,12 @@ impl TxoReconcileSchedule { if self.in_flight.contains_key(&wallet_id) { return false; } - self.in_flight.insert(wallet_id, ReconcileClaim::default()); + 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 } @@ -379,11 +442,15 @@ impl TxoReconcileSchedule { /// 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. + /// 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); } @@ -502,11 +569,11 @@ impl TxoReconciler { /// [`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. The host writes the verdicts to its - /// store and then calls [`Self::release_held`]; [`Self::finish`] and - /// the manager's shutdown release it too. Only - /// [`OutpointClass::KnownUncredited`] marks a row spent, and a mempool - /// spend never earns it. + /// 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 @@ -523,38 +590,45 @@ impl TxoReconciler { /// /// 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 guard of its - /// previous page is released before this waits for the next. + /// 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. + /// 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> { - let previous = lock_schedule(&self.schedule) - .in_flight - .get_mut(wallet_id)? - .held - .take(); - drop(previous); + ) -> 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); - schedule.in_flight.get_mut(wallet_id)?.held = Some(engine); - Some(classes) - } - - /// Release the engine read guard [`Self::classify_held`] kept for - /// `wallet_id`'s run, once the host has applied its verdicts. Nothing - /// to do when none is held. - pub fn release_held(&self, wallet_id: &WalletId) { - let held = lock_schedule(&self.schedule) + let claim = schedule .in_flight .get_mut(wallet_id) - .and_then(|claim| claim.held.take()); - drop(held); + .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 @@ -611,12 +685,14 @@ impl PlatformWalletManager

{ /// 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 held: Vec = lock_schedule(&self.txo_reconcile_schedule) + let schedule = lock_schedule(&self.txo_reconcile_schedule); + for slot in schedule .in_flight - .values_mut() - .filter_map(|claim| claim.held.take()) - .collect(); - drop(held); + .values() + .filter_map(|claim| claim.held.as_ref()) + { + release_slot(slot); + } } } @@ -1236,19 +1312,39 @@ mod tests { /// 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_eq!(reconciler.classify_held(&A, &[]).await, None); - assert!(lock_schedule(&reconciler.schedule).try_begin(A, Instant::now())); - assert_eq!(reconciler.classify_held(&A, &[]).await, Some(Vec::new())); - assert!(lock_schedule(&reconciler.schedule).in_flight[&A] - .held - .is_some()); - reconciler.release_held(&A); - assert!(lock_schedule(&reconciler.schedule).in_flight[&A] - .held - .is_none()); + 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 @@ -1260,11 +1356,12 @@ mod tests { use OutpointClass::{KnownUncredited, Unspent}; let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; let reconciler = reconciler_over(wm); - assert!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); + assert!(reconciler.claim_without_gate(&wallet_id)); + let (classes, held) = classify(&reconciler, &wallet_id, &queries).await; assert_eq!( - reconciler.classify_held(&wallet_id, &queries).await, - Some(vec![KnownUncredited; 3]), + classes, + vec![KnownUncredited; 3], "a spender above the ChainLock is acted on: the guard keeps it in place" ); let mut fork = spawn_fork(&reconciler); @@ -1273,7 +1370,7 @@ mod tests { "the fork waits while the verdicts are held" ); - reconciler.release_held(&wallet_id); + drop(held); timeout(PROCEEDS, fork) .await .expect("the fork proceeds once they are released") @@ -1290,11 +1387,8 @@ mod tests { 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!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); - assert!(reconciler - .classify_held(&wallet_id, &queries) - .await - .is_some()); + 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()); @@ -1303,37 +1397,69 @@ mod tests { .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. The previous guard is released - /// first, so the fork goes ahead and the next page sees its outcome. + /// 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!(lock_schedule(&reconciler.schedule).try_begin(wallet_id, Instant::now())); - assert!(reconciler - .classify_held(&wallet_id, &queries) - .await - .is_some()); + 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 = timeout(PROCEEDS, reconciler.classify_held(&wallet_id, &queries)) + let (next, held) = timeout(PROCEEDS, classify(&reconciler, &wallet_id, &queries)) .await .expect("the next page is classified, not deadlocked"); assert_eq!( next, - Some(vec![KnownUncredited, KnownUncredited, Unspent]), + vec![KnownUncredited, KnownUncredited, Unspent], "the queued fork went first" ); timeout(PROCEEDS, fork) .await .expect("fork finished") .expect("fork task"); - reconciler.release_held(&wallet_id); + 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-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 9d6b2304346..c4945a98af2 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3229,7 +3229,7 @@ fn core_selection_strategy( // (`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_release_verdicts`) and the stale-page retry +// `…_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`. @@ -3579,26 +3579,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w } /// Release the engine guard `walletManagerTxoReconcileClassifyOutpoints` -/// kept for the wallet's reconcile run, once its page is applied. Bridges -/// `platform_wallet_manager_txo_reconcile_release_verdicts`. +/// 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_walletManagerTxoReconcileReleaseVerdicts( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_txoReconcileHeldVerdictsFree( mut env: JNIEnv, _class: JClass, - manager_handle: jlong, - wallet_id: JByteArray, + held: jlong, ) { - 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_release_verdicts( - manager_handle as Handle, - wid.as_ptr(), - ) - }; - let _ = take_pwffi_error(env, result); + guard(&mut env, (), |_env| unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_held_verdicts_free( + held as *mut platform_wallet_ffi::TxoReconcileHeldVerdictsFFI, + ) }) } @@ -3770,8 +3764,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w /// /// 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; release it with -/// `walletManagerTxoReconcileReleaseVerdicts`. Call outside the persistence +/// 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] @@ -3781,11 +3777,16 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w 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(_) => { @@ -3836,6 +3837,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w // 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, @@ -3843,6 +3845,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w queries.as_ptr(), queries.len(), classes.as_mut_ptr(), + &mut held, ) }; // `scripts` must outlive the call above; this keeps that explicit @@ -3851,9 +3854,20 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w if take_pwffi_error(env, result) { return ptr::null_mut(); } - env.byte_array_from_slice(&classes) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) + // The guard reaches Kotlin only with answers it can apply; on any + // failure from here it is released at once rather than leaked. + let answers = env.byte_array_from_slice(&classes).map(|a| a.into_raw()); + // SAFETY: Kotlin allocates `outHeld` as a LongArray(1). + let out = unsafe { jni::objects::JLongArray::from_raw(out_held) }; + match answers { + Ok(answers) if env.set_long_array_region(&out, 0, &[held as jlong]).is_ok() => answers, + _ => { + unsafe { + platform_wallet_ffi::platform_wallet_txo_reconcile_held_verdicts_free(held) + }; + ptr::null_mut() + } + } }) } /// Read a 32-byte id from a Java `byte[]`; throws + returns None on the From d7a267431c1db4edda084d97293e91f6da7bbb0d Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 24 Oct 2026 18:21:07 -0700 Subject: [PATCH 28/28] fix(kotlin-sdk): never skip the reconcile finish or leak a held-verdicts token Two gaps on the Android path of the reconcile's held engine guard. The JNI classify call freed the token itself on its ordinary failure paths, but a panic between the native classify and the write of `outHeld` leaked it. Only `finish` would then release the guard, and `finish` resolves the manager handle in the native registry while the guard is held, the lock cycle the token exists to avoid. The token now sits in a drop guard from the moment the native call returns; it is freed on every exit, unwinding included, and disarmed only once `outHeld` has been written. In Kotlin, a throwing `txoReconcileHeldVerdictsFree` in the run's `finally` skipped `finish` and left the wallet claimed for the session. The run now ends through `endTxoReconcileRun`, inside the same NonCancellable context: the free, then the finish, each failure logged by exception type and wallet reference only, neither skipping the other. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/wallet/PlatformWalletManager.kt | 47 +++++-- .../dashsdk/wallet/TxoReconcileRunEndTest.kt | 52 ++++++++ .../rs-unified-sdk-jni/src/wallet_manager.rs | 123 ++++++++++++++++-- 3 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/TxoReconcileRunEndTest.kt 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 26b87fad068..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 @@ -1435,25 +1435,20 @@ class PlatformWalletManager( ran = true TxoReconcileOutcome.Reconciled(report) } finally { - // The guard first, without the native registry: `finish` - // resolves the manager handle there, which must never be - // waited for while a guard is held. - freeHeldVerdicts(heldVerdicts) // 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) { - runCatching { - mapNativeErrors { - WalletManagerNative.walletManagerTxoReconcileFinish(managerHandle, walletId, ran) - } - }.onFailure { t -> - android.util.Log.w( - "PlatformWalletManager", - "txos reconcile: release failed error=${t.javaClass.simpleName} wallet=$walletRef", - ) - } + endTxoReconcileRun( + walletRef = walletRef, + freeHeldVerdicts = { freeHeldVerdicts(heldVerdicts) }, + finish = { + mapNativeErrors { + WalletManagerNative.walletManagerTxoReconcileFinish(managerHandle, walletId, ran) + } + }, + ) } } } @@ -3055,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/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/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index c4945a98af2..dba7151b4a2 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -3848,28 +3848,71 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_w &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(); } - // The guard reaches Kotlin only with answers it can apply; on any - // failure from here it is released at once rather than leaked. - let answers = env.byte_array_from_slice(&classes).map(|a| a.into_raw()); + 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) }; - match answers { - Ok(answers) if env.set_long_array_region(&out, 0, &[held as jlong]).is_ok() => answers, - _ => { - unsafe { - platform_wallet_ffi::platform_wallet_txo_reconcile_held_verdicts_free(held) - }; - ptr::null_mut() - } + 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]> { @@ -5093,4 +5136,62 @@ mod tests { ); } } + + 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); + } + } }