diff --git a/Cargo.lock b/Cargo.lock index 3c59ed9fc84..40d1c1edba5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1775,7 +1775,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "dash-params", "grovedb-bincode", @@ -1786,7 +1786,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "dash-network", ] @@ -1957,7 +1957,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "async-trait", "bip324", @@ -2014,7 +2014,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "anyhow", "base64 0.23.1", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "dashcore-crypto" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "bitcoin-crypto", "dash-network", @@ -2061,12 +2061,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" [[package]] name = "dashcore-rpc" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "dashcore-rpc-json", "hex-conservative 1.3.0", @@ -2079,7 +2079,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "dashcore", "grovedb-bincode", @@ -2094,7 +2094,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "bitcoin_hashes 1.2.0", "dash-pow", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" [[package]] name = "glob" @@ -4399,7 +4399,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "async-trait", "bip39", @@ -4412,6 +4412,7 @@ dependencies = [ "hex-conservative 1.3.0", "serde", "serde_json", + "thiserror 2.0.18", "tracing", "unicode-normalization", "zeroize", @@ -4420,7 +4421,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4#c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" +source = "git+https://github.com/dashpay/rust-dashcore?rev=6f206d482d38def57b866f9dae1a6e98f84d25a9#6f206d482d38def57b866f9dae1a6e98f84d25a9" dependencies = [ "async-trait", "dashcore", @@ -5503,17 +5504,20 @@ dependencies = [ "bs58", "cbindgen", "dash-sdk", + "dash-spv", "dashcore", "dpp", "grovedb-bincode", "hex", "key-wallet", + "key-wallet-manager", "lazy_static", "once_cell", "parking_lot", "platform-wallet", "rs-sdk-ffi", "serde_json", + "thiserror 2.0.18", "tokio", "tokio-metrics", "tracing", @@ -5528,10 +5532,12 @@ dependencies = [ "apple-native-keyring-store", "argon2", "assert_cmd", + "async-trait", "chacha20poly1305", "chrono", "clap", "dash-sdk", + "dash-spv", "dashcore", "dbus-secret-service-keyring-store", "dpp", diff --git a/Cargo.toml b/Cargo.toml index 393766eb6a9..ac979aa7609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,14 +70,15 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f5 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } -dashcore-crypto = { git = "https://github.com/dashpay/rust-dashcore", rev = "c9aeac0d0dcbd4b91d780a70d61b9bb693199ca4" } +# TODO(upstream-merge): move this wallet-restore authorization pin to merged rust-dashcore dev after PR #1336 lands. +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } +dashcore-crypto = { git = "https://github.com/dashpay/rust-dashcore", rev = "6f206d482d38def57b866f9dae1a6e98f84d25a9" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index cd07f035bd2..87508ac761c 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -47,6 +47,7 @@ hex = "0.4" # Persistence loader emits structured warnings for skipped / # corrupt rows so operators can detect snapshot drift without a # native debugger attached. +thiserror = "=2.0.18" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } @@ -64,6 +65,8 @@ bs58 = "0.5" zeroize = { version = "1", features = ["derive"] } [dev-dependencies] +dash-spv = { workspace = true, features = ["test-utils"] } +key-wallet-manager = { workspace = true, features = ["test-utils"] } dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } platform-wallet = { path = "../rs-platform-wallet", features = ["test-utils"] } diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 2943926d9f8..e1336e82d74 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1,5 +1,7 @@ //! C-compatible types for core wallet changeset FFI. +use key_wallet::managed_account::transaction_record::TransactionDirection; +use platform_wallet::changeset::WalletAccounting; use platform_wallet::masternode::{provider_payload_fields, MasternodeRecord}; use std::os::raw::c_char; @@ -363,6 +365,38 @@ pub struct AccountChangeSetFFI { pub has_internal_highest_used: bool, } +/// One transaction's Core accounting from ONE wallet's point of view, as +/// `platform_wallet::changeset::WalletAccounting` computes it. The host +/// persists it keyed by `(txid, wallet_id)` (the wallet is the callback's +/// `wallet_id`) and displays it; it never derives or corrects it. +/// +/// [`TransactionRecordFFI`]'s own `net_amount` / `direction` / `fee` stay +/// for hosts that predate this row. A record with no accounting evidence +/// yields NO row here, so the host keeps what it stored. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransactionAccountingFFI { + pub txid: [u8; 32], + /// `Σ owned outputs − Σ owned inputs` in duffs (positive = received). + pub net_amount: i64, + /// 0=incoming, 1=outgoing, 2=internal, 3=coinJoin. + pub direction: u32, + /// Meaningful only when `has_fee`. + pub fee: u64, + pub has_fee: bool, +} + +/// One stored Core transaction without this wallet's accounting row, handed +/// back for the load-time backfill (see +/// `on_load_core_history_for_accounting_fn`). Host-owned until the matching +/// free callback. +#[repr(C)] +pub struct CoreHistoryTransactionFFI { + /// Consensus-encoded transaction body. + pub tx_bytes: *const u8, + pub tx_bytes_len: usize, +} + // --------------------------------------------------------------------------- // Top-level wallet changeset // --------------------------------------------------------------------------- @@ -487,15 +521,8 @@ impl WalletChangeSetFFI { /// snapshots delivered through the `BalanceUpdateHandler`, not as /// a delta on the persistence path. /// - /// TODO(events): wire `instant_locks_for_non_final_records` through - /// the FFI surface. Today the Swift side learns about IS-lock state - /// only when a re-emitted `TransactionRecord` flows through `records` - /// with `context = InstantSend(..)`. The standalone IS-lock map is - /// dropped here. Acceptable as long as the event adapter re-emits - /// affected records (it does for `TransactionDetected`, - /// `BlockProcessed` and `TransactionInstantLocked`). - /// When the standalone IS-lock event needs to flow to Swift, add - /// a `BTreeMap`-shaped FFI field here and populate it. + /// Raw InstantLocks ride the size-gated persistence extension callback; + /// they do not change this legacy struct's allocation or array stride. pub fn from_changeset(cs: &platform_wallet::changeset::CoreChangeSet) -> Self { use key_wallet::account::AccountType; use std::ffi::CString; @@ -1523,6 +1550,37 @@ pub(crate) fn masternode_entry_v2_ffi( } } +/// `direction` wire values of [`TransactionRecordFFI`] and +/// [`TransactionAccountingFFI`], exported so hosts name them from the +/// generated header instead of re-declaring them. +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING: u32 = 0; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING: u32 = 1; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL: u32 = 2; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN: u32 = 3; + +fn direction_to_u32(direction: TransactionDirection) -> u32 { + match direction { + TransactionDirection::Incoming => PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING, + TransactionDirection::Outgoing => PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING, + TransactionDirection::Internal => PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL, + TransactionDirection::CoinJoin => PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN, + } +} + +impl From<&WalletAccounting> for TransactionAccountingFFI { + fn from(row: &WalletAccounting) -> Self { + let mut txid = [0u8; 32]; + txid.copy_from_slice(row.txid.as_ref()); + Self { + txid, + net_amount: row.net_amount, + direction: direction_to_u32(row.direction), + fee: row.fee.unwrap_or(0), + has_fee: row.fee.is_some(), + } + } +} + fn tx_record_to_ffi( tr: &key_wallet::managed_account::transaction_record::TransactionRecord, ) -> TransactionRecordFFI { @@ -1569,12 +1627,7 @@ fn tx_record_to_ffi( } }; - let dir_val = match tr.direction { - key_wallet::managed_account::transaction_record::TransactionDirection::Incoming => 0u32, - key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing => 1, - key_wallet::managed_account::transaction_record::TransactionDirection::Internal => 2, - key_wallet::managed_account::transaction_record::TransactionDirection::CoinJoin => 3, - }; + let dir_val = direction_to_u32(tr.direction); let type_str = CString::new(format!("{:?}", tr.transaction_type)) .unwrap_or_else(|_| CString::new("Unknown").unwrap()); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd0675fca9f..10637eb5f8f 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,13 +8,16 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + ApplyReplayEffectsFn, FFIPersister, FreeCoreHistoryForAccountingFn, FreeCoreInstantLockFn, + FreeReplayClaimsFn, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, + LoadCoreHistoryForAccountingFn, LoadCoreInstantLockFn, LoadIdentityBalanceBlockTimeFn, + LoadReplayClaimsFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + PersistCoreHistoryAccountedFn, PersistCoreInstantLockFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistTransactionAccountingFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, - PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + ReplayWalletFn, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -290,6 +293,29 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + persist_transaction_accounting: slot!( + on_persist_transaction_accounting_fn, + PersistTransactionAccountingFn + ), + load_core_history_for_accounting: slot!( + on_load_core_history_for_accounting_fn, + LoadCoreHistoryForAccountingFn + ), + load_core_history_for_accounting_free: slot!( + on_load_core_history_for_accounting_free_fn, + FreeCoreHistoryForAccountingFn + ), + persist_core_history_accounted: slot!( + on_persist_core_history_accounted_fn, + PersistCoreHistoryAccountedFn + ), + persist_core_instant_lock: slot!(on_persist_core_instant_lock_fn, PersistCoreInstantLockFn), + load_core_instant_lock: slot!(on_load_core_instant_lock_fn, LoadCoreInstantLockFn), + free_core_instant_lock: slot!(on_load_core_instant_lock_free_fn, FreeCoreInstantLockFn), + replay_wallet: slot!(on_replay_wallet_fn, ReplayWalletFn), + load_replay_claims: slot!(on_load_replay_claims_fn, LoadReplayClaimsFn), + free_replay_claims: slot!(on_load_replay_claims_free_fn, FreeReplayClaimsFn), + apply_replay_effects: slot!(on_apply_replay_effects_fn, ApplyReplayEffectsFn), } } @@ -671,6 +697,10 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit /// [`platform_wallet_manager_get_wallet`] per `wallet_id` it knows /// about. /// +/// History requiring InstantLock evidence needs a started SPV client. Missing +/// evidence returns a load error with transaction IDs; keep SPV running and +/// retry explicitly after synchronization. Startup alone does not prove readiness. +/// /// On error the handle stays valid and the manager is unchanged: fix the /// store and call again, or destroy the manager and reconstruct it. Reopening /// the same store path requires all persister references to be released. @@ -1372,6 +1402,55 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + #[test] + fn should_size_gate_raw_instant_lock_callbacks_after_accounting() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut *const u8, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const u8, _: usize) {} + let mut ext = PersistenceCallbacksExtension { + on_persist_core_instant_lock_fn: Some(persist), + on_load_core_instant_lock_fn: Some(load), + on_load_core_instant_lock_free_fn: Some(free), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_some()); + assert!(parsed.load_core_instant_lock.is_some()); + assert!(parsed.free_core_instant_lock.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_instant_lock_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_none()); + assert!(parsed.load_core_instant_lock.is_none()); + assert!(parsed.free_core_instant_lock.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_instant_lock_free_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_some()); + assert!(parsed.load_core_instant_lock.is_some()); + assert!(parsed.free_core_instant_lock.is_none()); + } + #[test] fn should_size_gate_asset_lock_operation_id_slots_independently() { unsafe extern "C" fn persist( @@ -1415,6 +1494,137 @@ mod tests { assert!(parsed.load_asset_lock_operation_id.is_none()); } + /// Each transaction-accounting slot is read only when the host's + /// `struct_size` covers it, so a host built before any of them simply + /// never has them read. + #[test] + fn should_size_gate_transaction_accounting_slots_independently() { + use crate::core_wallet_types::{CoreHistoryTransactionFFI, TransactionAccountingFFI}; + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const TransactionAccountingFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut bool, + _: *mut *const CoreHistoryTransactionFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const CoreHistoryTransactionFFI, _: usize) {} + unsafe extern "C" fn accounted(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_transaction_accounting_fn: Some(persist), + on_load_core_history_for_accounting_fn: Some(load), + on_load_core_history_for_accounting_free_fn: Some(free), + on_persist_core_history_accounted_fn: Some(accounted), + ..Default::default() + }; + let present = |ext: &PersistenceCallbacksExtension| { + let parsed = unsafe { persistence_extension_callbacks(ext) }; + [ + parsed.persist_transaction_accounting.is_some(), + parsed.load_core_history_for_accounting.is_some(), + parsed.load_core_history_for_accounting_free.is_some(), + parsed.persist_core_history_accounted.is_some(), + ] + }; + assert_eq!(present(&ext), [true; 4]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_history_accounted_fn + ); + assert_eq!(present(&ext), [true, true, true, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_free_fn + ); + assert_eq!(present(&ext), [true, true, false, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_fn + ); + assert_eq!(present(&ext), [true, false, false, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_transaction_accounting_fn + ); + assert_eq!(present(&ext), [false; 4]); + } + + #[test] + fn should_size_gate_every_atomic_replay_callback_without_reading_a_partial_slot() { + unsafe extern "C" fn scope( + _: *mut c_void, + _: *const u8, + _: ReplayOperationFn, + _: *mut c_void, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const ReplayClaimRestoreFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} + unsafe extern "C" fn apply( + _: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + 0 + } + use crate::persistence::{ + HistoryReplayEffectsFFI, ReplayClaimRestoreFFI, ReplayOperationFn, + }; + let mut ext = PersistenceCallbacksExtension { + on_replay_wallet_fn: Some(scope), + on_load_replay_claims_fn: Some(load), + on_load_replay_claims_free_fn: Some(free), + on_apply_replay_effects_fn: Some(apply), + ..Default::default() + }; + let present = |ext: &PersistenceCallbacksExtension| { + let parsed = unsafe { persistence_extension_callbacks(ext) }; + [ + parsed.replay_wallet.is_some(), + parsed.load_replay_claims.is_some(), + parsed.free_replay_claims.is_some(), + parsed.apply_replay_effects.is_some(), + ] + }; + assert_eq!(present(&ext), [true; 4]); + for (index, offset) in [ + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn), + ] + .into_iter() + .enumerate() + { + for length in [ + offset, + offset + std::mem::size_of::>() - 1, + ] { + ext.struct_size = length; + assert_eq!(present(&ext), std::array::from_fn(|slot| slot < index)); + } + } + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index eb5865fc361..146f8b36861 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -9,6 +9,13 @@ //! `rs-unified-sdk-jni`'s `RESOLVE_*` mnemonic-resolver codes, which reuse the //! same integers on a different callback family. +mod replay; +use replay::ReplayCallbacks; +pub use replay::{ + ApplyReplayEffectsFn, FreeReplayClaimsFn, HistoryReplayEffectsFFI, LoadReplayClaimsFn, + ReplayClaimEffectFFI, ReplayClaimRestoreFFI, ReplayOperationFn, ReplayWalletFn, +}; + use bincode::config; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType, StandardAccountType}; @@ -30,11 +37,13 @@ use crate::types::{FFINetwork, Network}; use platform_wallet::changeset::provider_key_account::{ rebuild_provider_key_account, ProviderAccountRebuildError, }; +use platform_wallet::changeset::record_accounting; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, + ProviderKeyExtendedPubKey, RecordedHistory, StoredTransaction, WalletAccounting, + PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::asset_lock::restored_record::held_output_details; use platform_wallet::wallet::core::stored_coin::{reconcile_with_parent, StoredParentState}; @@ -58,8 +67,9 @@ use crate::contact_persistence::{ use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ build_dashpay_backfill_covered_for_callback, build_sweep_batches_for_callback, - build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, - DashPayBackfillCoveredContactFFI, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, + build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, CoreHistoryTransactionFFI, + DashPayBackfillCoveredContactFFI, SweepBatchFFI, TransactionAccountingFFI, + UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -76,8 +86,9 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnconfirmedOutgoingTxRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + ProviderSpecialTxRestoreEntryFFI, RecordedTransactionRestoreFFI, StandardAccountTypeTagFFI, + UnconfirmedOutgoingTxRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, + WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -98,6 +109,8 @@ use std::ffi::CStr; /// `Mempool` vs no-evidence); see each match's comment. pub(crate) const TX_CONTEXT_RAW_IN_BLOCK: u32 = 2; pub(crate) const TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK: u32 = 3; +/// The mempool discriminant, used to distinguish unconfirmed rows from unknown values. +pub(crate) const TX_CONTEXT_RAW_MEMPOOL: u32 = 0; /// The InstantSend discriminant of the same encoding. pub(crate) const TX_CONTEXT_RAW_INSTANT_SEND: u32 = 1; @@ -161,6 +174,30 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; +/// Load hands back the stored transaction history through +/// `WalletRestoreEntryFFI::recorded_transactions`. Honoured only with the +/// wallet-list load callback pair wired. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_HISTORY_RESTORE: u64 = 1 << 14; + +/// Persist consensus InstantLock bytes inside the existing changeset transaction. +pub type PersistCoreInstantLockFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + bytes: *const u8, + len: usize, +) -> i32; +/// Read an optional raw lock; null/zero means absent, never verified. +pub type LoadCoreInstantLockFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + out_bytes: *mut *const u8, + out_len: *mut usize, +) -> i32; +/// Free the exact allocation returned by the raw-lock loader, including error paths. +pub type FreeCoreInstantLockFn = + unsafe extern "C" fn(context: *mut c_void, bytes: *const u8, len: usize); /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -323,6 +360,54 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; +/// Carries one wallet's accounting rows (see [`TransactionAccountingFFI`]). +/// The host upserts each keyed by `(txid, wallet_id)`. Always fired inside a +/// begin/end round: right after `on_persist_wallet_changeset_fn`, so the +/// transaction rows the accounting belongs to already exist, when the round +/// has a record with accounting evidence; and in the load-time backfill's +/// own round, before [`PersistCoreHistoryAccountedFn`]. A non-zero return +/// fails the round like any other per-kind callback. +pub type PersistTransactionAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, +) -> i32; + +/// The load-time accounting backfill's read. Sets `*out_backfilled = true` +/// (and returns no rows) once [`PersistCoreHistoryAccountedFn`] has been +/// stored for this wallet. Otherwise returns the wallet's stored +/// transactions that have no accounting row for it; any subset is safe, +/// since Rust fetches the funding transactions it needs itself +/// (`on_get_core_tx_record_fn`, where a hit in any context is used and a +/// non-zero return fails the backfill) and a transaction left out only +/// stays without a row. A null array with a non-zero count is an error. The +/// host keeps the array valid until [`FreeCoreHistoryForAccountingFn`]. +pub type LoadCoreHistoryForAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_backfilled: *mut bool, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, +) -> i32; + +/// Release an array returned by [`LoadCoreHistoryForAccountingFn`]. +pub type FreeCoreHistoryForAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + rows: *const CoreHistoryTransactionFFI, + count: usize, +); + +/// Record that this wallet's history backfill is complete, so the next +/// [`LoadCoreHistoryForAccountingFn`] reports it backfilled. Fired in the +/// backfill's round after its rows, and committed with them, only when every +/// transaction got its row. A round that had to leave a transaction +/// unaccounted (a wallet-signed input whose funding transaction is not +/// available yet) commits its rows without it, so the next load reads the +/// rest again. +pub type PersistCoreHistoryAccountedFn = + unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -489,6 +574,94 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// Per-wallet transaction accounting (see + /// [`PersistTransactionAccountingFn`]). Additive like the slots above: a + /// host without it keeps reading `TransactionRecordFFI`'s own values. + pub on_persist_transaction_accounting_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32, + >, + /// Stored history for the load-time accounting backfill (see + /// [`LoadCoreHistoryForAccountingFn`]). Read only together with the free + /// slot below, the persist slot above and the completion slot. + pub on_load_core_history_for_accounting_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_backfilled: *mut bool, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, + ) -> i32, + >, + pub on_load_core_history_for_accounting_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + rows: *const CoreHistoryTransactionFFI, + count: usize, + ), + >, + /// Backfill completion marker (see [`PersistCoreHistoryAccountedFn`]). + /// The backfill runs only when all four accounting slots and the + /// changeset begin/end pair are present. + pub on_persist_core_history_accounted_fn: + Option i32>, + /// Raw InstantLock sidecar; the legacy transaction row stride remains unchanged. + pub on_persist_core_instant_lock_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + bytes: *const u8, + len: usize, + ) -> i32, + >, + /// Returns consensus bytes, which Rust must verify against the SPV quorum. + pub on_load_core_instant_lock_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + out_bytes: *mut *const u8, + out_len: *mut usize, + ) -> i32, + >, + pub on_load_core_instant_lock_free_fn: + Option, + /// Synchronous exactly-once scope; rollback if the operation or commit fails. + pub on_replay_wallet_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + operation: unsafe extern "C" fn(operation_context: *mut c_void) -> i32, + operation_context: *mut c_void, + ) -> i32, + >, + pub on_load_replay_claims_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const ReplayClaimRestoreFFI, + out_count: *mut usize, + ) -> i32, + >, + pub on_load_replay_claims_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + rows: *const ReplayClaimRestoreFFI, + count: usize, + ), + >, + pub on_apply_replay_effects_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + effects: *const HistoryReplayEffectsFFI, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -509,6 +682,17 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_persist_transaction_accounting_fn: None, + on_load_core_history_for_accounting_fn: None, + on_load_core_history_for_accounting_free_fn: None, + on_persist_core_history_accounted_fn: None, + on_persist_core_instant_lock_fn: None, + on_load_core_instant_lock_fn: None, + on_load_core_instant_lock_free_fn: None, + on_replay_wallet_fn: None, + on_load_replay_claims_fn: None, + on_load_replay_claims_free_fn: None, + on_apply_replay_effects_fn: None, } } } @@ -530,6 +714,17 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub persist_transaction_accounting: Option, + pub load_core_history_for_accounting: Option, + pub load_core_history_for_accounting_free: Option, + pub persist_core_history_accounted: Option, + pub persist_core_instant_lock: Option, + pub load_core_instant_lock: Option, + pub free_core_instant_lock: Option, + pub replay_wallet: Option, + pub load_replay_claims: Option, + pub free_replay_claims: Option, + pub apply_replay_effects: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1471,6 +1666,18 @@ pub struct FFIPersister { tracked_masternodes_callbacks: PersistenceExtensionCallbacks, persist_identity_balance_block_time_callback: Option, load_identity_balance_block_time_callback: Option, + /// Per-wallet accounting rows, fired after each round's changeset and by + /// the load-time backfill. `None` keeps a host on `TransactionRecordFFI`'s + /// values, as before the slot existed. + persist_transaction_accounting_callback: Option, + /// The load-time backfill's read, free and completion slots; the + /// backfill runs only when all of them and the persist slot are wired. + load_core_history_for_accounting_callback: Option, + load_core_history_for_accounting_free_callback: Option, + persist_core_history_accounted_callback: Option, + persist_core_instant_lock_callback: Option, + load_core_instant_lock_callback: Option, + free_core_instant_lock_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1489,6 +1696,7 @@ pub struct FFIPersister { /// each round atomic with respect to every other round and the complete /// restore read (including additive sidecars). round_lock: Mutex, + replay_callbacks: ReplayCallbacks, } /// Releases the host callback context when the persister's last owner @@ -1596,976 +1804,1326 @@ impl FFIPersister { persist_identity_balance_block_time_callback: extensions .persist_identity_balance_block_time, load_identity_balance_block_time_callback: extensions.load_identity_balance_block_time, + persist_transaction_accounting_callback: extensions.persist_transaction_accounting, + load_core_history_for_accounting_callback: extensions.load_core_history_for_accounting, + load_core_history_for_accounting_free_callback: extensions + .load_core_history_for_accounting_free, + persist_core_history_accounted_callback: extensions.persist_core_history_accounted, + persist_core_instant_lock_callback: extensions.persist_core_instant_lock, + load_core_instant_lock_callback: extensions.load_core_instant_lock, + free_core_instant_lock_callback: extensions.free_core_instant_lock, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), + replay_callbacks: ReplayCallbacks { + transaction: extensions.replay_wallet, + load_claims: extensions.load_replay_claims, + free_claims: extensions.free_replay_claims, + apply: extensions.apply_replay_effects, + }, } } - /// Narrow a round's failure kind to what the caller may safely act on: - /// `Transient` survives only where - /// [`Self::store_transient_is_reissuable`] holds, since losing a retry - /// opportunity costs less than the rows a re-sent partial round would - /// duplicate. `Constraint` and `Fatal` invite no retry, so they pass - /// through. - fn reportable_round_kind(&self, reported: PersistenceErrorKind) -> PersistenceErrorKind { - match reported { - PersistenceErrorKind::Transient if !self.store_transient_is_reissuable() => { - PersistenceErrorKind::Fatal - } - kind => kind, - } - } - - /// Compute the callback contracts that are structurally complete in this - /// vtable. This mask is only an upper bound: the host must separately attest - /// the semantics it actually implements. - fn callback_capabilities(&self) -> PersistenceCapabilities { - let mut capabilities = PersistenceCapabilities::NONE; + /// Read under the caller's round guard; atomic replay must not acquire it recursively. + fn load_snapshot_unlocked( + &self, + wallet_id: Option, + ) -> Result { + // If Swift hasn't wired up `on_load_wallet_list_fn` there's + // nothing to restore — treat as a fresh client. + let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { + return Ok(ClientStartState::default()); + }; - if self.callbacks.on_changeset_begin_fn.is_some() - && self.callbacks.on_changeset_end_fn.is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::ATOMIC_CHANGESETS); - } - if self.callbacks.on_persist_invitations_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); - } - if self.dpns_name_states_callback.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); - } - let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() - && self.callbacks.on_load_wallet_list_free_fn.is_some(); - if self.callbacks.on_persist_account_registrations_fn.is_some() - && self.callbacks.on_persist_account_address_pools_fn.is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES); + // Swift allocates the entries array (and every nested byte + // buffer); we read it under a `Guard` that calls the matching + // free callback on drop, so failures inside the loop don't + // leak. + let mut entries_ptr: *const WalletRestoreEntryFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_wallet_list_fn returned error code {}", rc), + )); } - if wallet_restore { - capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); + let _guard = LoadGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_wallet_list_free_fn, + entries: entries_ptr, + count, + }; + + let mut out = ClientStartState::default(); + if entries_ptr.is_null() && count != 0 { + return Err(PersistenceError::backend( + "wallet restore: missing row buffer", + )); } - if self.callbacks.on_persist_asset_locks_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + if count == 0 { + return Ok(out); } - if wallet_restore - && self.callbacks.on_persist_asset_locks_fn.is_some() - && self - .tracked_masternodes_callbacks - .persist_asset_lock_operation_id - .is_some() - && self + + // SAFETY: Swift guarantees `entries_ptr` points to `count` + // contiguous `WalletRestoreEntryFFI` values for the lifetime + // of the callback window. `_guard` ensures the free callback + // fires before we leave this function. + let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; + for entry in entries { + if wallet_id.is_some_and(|wallet_id| entry.wallet_id != wallet_id) { + continue; + } + if out.wallets.contains_key(&entry.wallet_id) { + return Err(PersistenceError::backend( + "wallet restore: duplicate wallet ID", + )); + } + let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + self.load_core_instant_locks(&entry.wallet_id, &mut wallet_state.recorded_history)?; + if let Some(cb) = self .tracked_masternodes_callbacks .load_asset_lock_operation_id - .is_some() - { - // The declaration additionally attests UUID preservation, consumed - // tombstones and fail-closed loads. Legacy hosts do not set this bit. - capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS); - } - if self - .tracked_masternodes_callbacks - .persist_tracked_masternodes - .is_some() - && self - .tracked_masternodes_callbacks - .load_tracked_masternodes - .is_some() - && self - .tracked_masternodes_callbacks - .load_tracked_masternodes_free - .is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); + { + load_asset_lock_operation_ids( + cb, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.unused_asset_locks, + )?; + } + if let Some(cb) = self.load_identity_balance_block_time_callback { + for identities in wallet_state.identity_manager.wallet_identities.values_mut() { + for managed in identities.values_mut() { + let mut found = false; + let mut stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + let rc = unsafe { + cb( + self.callbacks.context, + entry.wallet_id.as_ptr(), + managed.id().as_bytes().as_ptr(), + &mut found, + &mut stamp, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading identity balance block time failed".to_string(), + )); + } + managed.last_updated_balance_block_time = found.then(|| stamp.into()); + } + } + } + out.wallets.insert(entry.wallet_id, wallet_state); + if let Some(platform_address_state) = platform_address_state { + out.platform_addresses + .insert(entry.wallet_id, platform_address_state); + } } - if self.callbacks.on_persist_wallet_changeset_fn.is_some() - && wallet_restore - && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) + + // Restore shielded sub-wallet state if the host has wired + // up the optional callbacks. Notes and per-subwallet sync + // states travel separately so the host can populate them + // from independent SwiftData fetch descriptors. Both arms + // walk the same `(wallet_id, account_index)` key space and + // funnel into a single `SubwalletId` map on + // `ClientStartState.shielded`. + #[cfg(feature = "shielded")] { - capabilities = capabilities.union(PersistenceCapabilities::PROVIDER_TRANSACTIONS); - } - if self.callbacks.on_persist_token_balances_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); - } - // The dashpay-payments slot is what the sweep's Failed flip rides - // (`dashpay_payments_overlay` on the store round). A host that - // never wired it — Android deliberately keeps payment recording - // in-memory-only — must not read as payment-durable, or the - // wallet-event adapter would couple the flip to a round that - // silently drops it: the accepted-and-ignored shape the sweep - // bit's own gating exists to prevent, reproduced one channel over. - if self.callbacks.on_persist_dashpay_payments_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); - } - // Sweeps travel through the size-tagged extension callback, so — - // unlike the legacy `on_persist_wallet_changeset_fn`, whose - // unchanged C signature proves nothing about what a host actually - // reads — this slot being `Some` is a genuine structural - // attestation: it exists only when the host's declared extension - // `struct_size` covered the field. The changeset callback is still - // required alongside it because a sweep only corrects state that - // callback persists; a sweeps slot with no changeset slot would - // attest removals against rows the host never writes. The bit is - // still additionally gated by `declared_capabilities` in - // `persistence_capabilities()` below, like every other bit: the - // host must attest the semantic contract, not just wire pointers. - // - // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, - // and only for this bit, because moving sweeps onto their own slot - // split one logical `CoreChangeSet` across two calls. Without a - // round that commits or rolls back as a unit, the changeset call - // can make the watermark and the additive rows durable and the - // process can stop before the sweep call applies the removal — - // leaving a host that restarts past a deletion it never performed - // and reloads the dead transaction. Nothing before sweeps could - // fail this way: every core field arrived through one callback. - if self.wallet_changeset_sweeps_callback.is_some() - && self.callbacks.on_persist_wallet_changeset_fn.is_some() - && self.callbacks.on_changeset_begin_fn.is_some() - && self.callbacks.on_changeset_end_fn.is_some() - && self - .declared_capabilities - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) - { - capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); - } - #[cfg(feature = "shielded")] - if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() - && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() - && self - .callbacks - .on_load_shielded_viewing_keys_free_fn - .is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS); - } - - // Deliberately absent: PersistenceCallbacks has no deferred contact - // crypto add/clear callback pair, so FFI hosts cannot attest it. - capabilities - } -} - -impl PlatformWalletPersistence for FFIPersister { - /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back - /// whole; nothing of a rolled-back round survives on this side either, so - /// the changeset is the caller's to re-issue. The capability is the - /// declaration intersected with the wired brackets, so an attestation - /// without an `end` callback to roll anything back does not count. - fn store_transient_is_reissuable(&self) -> bool { - self.persistence_capabilities() - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) - } - - // Fan-out coverage note: `pending_contact_crypto_added` / - // `pending_contact_crypto_cleared` have no vtable slots yet, so the - // deferred contact-crypto queue is NOT durable on FFI hosts — the - // recurring sweep re-enqueues after a restart (see the field docs on - // `PlatformWalletChangeSet`). Wire host callbacks before relying on - // restart-immediate drains. - - fn persistence_capabilities(&self) -> PersistenceCapabilities { - self.declared_capabilities - .intersection(self.callback_capabilities()) - } + use crate::shielded_persistence::*; + use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; + use platform_wallet::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, ShieldedNote, + ShieldedOutgoingNote, SubwalletId, + }; - fn persist_tracked_masternodes( - &self, - network: dashcore::Network, - records: &[platform_wallet::masternode::TrackedMasternode], - ) -> Result<(), PersistenceError> { - // No callback ⇒ the honest default: a session-scoped no-op. The - // TRACKED_MASTERNODES capability bit is not attested in that case, - // so callers know the difference. - let Some(persist) = self - .tracked_masternodes_callbacks - .persist_tracked_masternodes - else { - return Ok(()); - }; - let network_c = - CString::new(network.to_string()).expect("network names contain no interior NUL"); - // Own every string for the duration of the call. - let storage: Vec<(Option, CString)> = records - .iter() - .map(|record| { - let label = record.label.as_deref().and_then(|l| CString::new(l).ok()); - let snapshot = CString::new(platform_wallet::masternode::snapshot_to_json( - &record.snapshot, - )) - .expect("snapshot JSON contains no interior NUL"); - (label, snapshot) - }) - .collect(); - let rows: Vec = records - .iter() - .zip(storage.iter()) - .map(|(record, (label, snapshot))| TrackedMasternodeFFI { - pro_tx_hash: record.pro_tx_hash, - label: label - .as_ref() - .map(|l| l.as_ptr()) - .unwrap_or(std::ptr::null()), - added_at: record.added_at, - snapshot_json: snapshot.as_ptr(), - }) - .collect(); - let rc = unsafe { - persist( - self.callbacks.context, - network_c.as_ptr(), - if rows.is_empty() { - std::ptr::null() - } else { - rows.as_ptr() - }, - rows.len(), - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_persist_tracked_masternodes_fn returned error code {rc}"), - )); - } - Ok(()) - } + let mut shielded_state = ShieldedSyncStartState::default(); - fn load_tracked_masternodes( - &self, - network: dashcore::Network, - ) -> Result, PersistenceError> { - let Some(load) = self.tracked_masternodes_callbacks.load_tracked_masternodes else { - return Ok(Vec::new()); - }; - // Fail closed on a half-wired pair: without the free callback the - // host-allocated rows could never be returned, so every load would - // leak. Same rule as the shielded load/free arms. - let Some(free) = self - .tracked_masternodes_callbacks - .load_tracked_masternodes_free - else { - return Err(PersistenceError::backend( - "on_load_tracked_masternodes_fn requires on_load_tracked_masternodes_free_fn; \ - wire both or neither", - )); - }; - let network_c = - CString::new(network.to_string()).expect("network names contain no interior NUL"); - let mut rows_ptr: *const TrackedMasternodeFFI = std::ptr::null(); - let mut count: usize = 0; - let rc = unsafe { - load( - self.callbacks.context, - network_c.as_ptr(), - &mut rows_ptr, - &mut count, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_tracked_masternodes_fn returned error code {rc}"), - )); - } - let mut out = Vec::with_capacity(count); - if !rows_ptr.is_null() && count > 0 { - let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; - for row in rows { - let label = if row.label.is_null() { - None - } else { - unsafe { CStr::from_ptr(row.label) } - .to_str() - .ok() - .map(str::to_string) - }; - let snapshot = if row.snapshot_json.is_null() { - platform_wallet::masternode::TrackedMasternodeSnapshot::default() - } else { - platform_wallet::masternode::snapshot_from_json( - unsafe { CStr::from_ptr(row.snapshot_json) } - .to_str() - .unwrap_or(""), - ) - }; - out.push(platform_wallet::masternode::TrackedMasternode { - pro_tx_hash: row.pro_tx_hash, - label, - added_at: row.added_at, - snapshot, - }); + // Fail fast on a half-wired callback pair: a loader without + // its matching free callback leaks the host-allocated buffer + // on every successful load (the guard's `Drop` is a no-op + // when `free_fn` is `None`). + if self.callbacks.on_load_shielded_notes_fn.is_some() + != self.callbacks.on_load_shielded_notes_free_fn.is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ + provided together", + )); } - } - unsafe { free(self.callbacks.context, rows_ptr, count) }; - Ok(out) - } - - fn stores_dashpay_backfill(&self) -> bool { - // The record reaches the host only through its extension slot; a - // host without it cannot store one, nor replace one it holds. And - // coverage vouches for the cursor it rides with, which reaches the - // host only through the changeset callback: `store` withholds every - // covering record from a host without one, so claiming the record - // here would let the owning layer count coverage as stored that the - // host never received. Such a host is treated like any other that - // cannot keep coverage durably, which still sends it the empty - // record that clears coverage it holds. - self.wallet_dashpay_backfill_callback.is_some() - && self.callbacks.on_persist_wallet_changeset_fn.is_some() - } - - fn store_commits_inline(&self) -> bool { - // The end callback commits (or rolls back) the host transaction before - // `store` returns. `flush` is only a later general-purpose notification. - self.callbacks.on_changeset_end_fn.is_some() - } - - fn store( - &self, - wallet_id: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - // Serialize the ENTIRE begin→per-kind→end round against every - // other round producer (see `round_lock`'s field doc and - // dashpay/platform#4069). The lock is a synchronous - // `parking_lot::Mutex`, NOT a `tokio::sync::Mutex`: `store()` - // is a synchronous trait method invoked directly (blocking) from - // both async tasks and blocking FFI entry points, so an async - // mutex cannot be `.await`ed here and `blocking_lock()` panics - // inside a runtime. Serialization — not async yielding — is the - // requirement; callers already block for the round's duration, so - // the sync mutex only adds waiting under genuine round contention. - let mut round = self.round_lock.lock(); - - // An empty backfill record is an invalidation: Rust sends one to - // clear coverage the host handed back on load, before publishing a - // wallet that will scan without it. A host without the backfill slot - // can still hand coverage back — its load path decodes the record - // whether or not the write slot is wired — but it cannot replace it. - // Acknowledging the round would leave that coverage on disk beside - // cursors scanned without its accounts, for a later session with the - // slot to trust. So the round fails before anything is applied, and - // the publication with it (dashpay/platform#4302 review). - if self.wallet_dashpay_backfill_callback.is_none() - && changeset - .dashpay_backfill - .as_ref() - .is_some_and(|record| record.is_empty()) - { - return Err(PersistenceError::backend_with_kind( - PersistenceErrorKind::Fatal, - "the host holds a DashPay backfill record but registered no backfill \ - callback to invalidate it", - )); - } - - // Open the round on the Rust side (rejects a nested begin / an - // unclean round left open by a prior unwind — error, never - // panic). Matched 1:1 with the `round.end_round()` below. - round.begin_round()?; - - // Bracket the whole per-kind callback sequence with a - // begin/end pair so clients (Swift, etc.) can treat the - // round as a single atomic transaction: begin opens a - // batch, each per-kind callback mutates staged state, and - // end either commits (`success == true`) or rolls back - // (`success == false`). `success` flips to false on any - // sub-callback returning non-zero; the result is advisory - // for clients (Swift uses it to decide save vs rollback). - if let Some(cb) = self.callbacks.on_changeset_begin_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - // A nonzero begin means the client could NOT open its - // transaction. Proceeding would run every per-kind - // callback against no batch and then fire an unmatched - // `end`. Close the Rust-side round (so `in_round` doesn't - // wedge) and fail now, before any per-kind write — nothing - // was applied, so the host's own classification of `result` - // is reported as-is. - let _ = round.end_round(); - return Err(persist_callback_error( - result, - format!( - "changeset-begin callback returned error code {result}; \ - round aborted before any write" - ), + if self.callbacks.on_load_shielded_sync_states_fn.is_some() + != self + .callbacks + .on_load_shielded_sync_states_free_fn + .is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ + must be provided together", + )); + } + if self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() + != self + .callbacks + .on_load_shielded_outgoing_notes_free_fn + .is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_outgoing_notes_fn and \ + on_load_shielded_outgoing_notes_free_fn must be provided together", )); } - } - let mut outcome = RoundOutcome::default(); - // Whether this round's `synced_height` reached the host. A backfill - // record rides the round that lowers the cursor it vouches for, and - // must not reach the host when that cursor did not: a host without - // atomic rounds would keep the coverage over its old cursor, and no - // rollback on this side can take it back (dashpay/platform#4302 - // review). - let mut cursor_delivered = false; - // Wallet-registration metadata. Fires at most once per round - // (registration emits the entry; subsequent rounds carry - // `wallet_metadata: None` so no callback fires). - if let Some(meta) = changeset.wallet_metadata.as_ref() { - if let Some(cb) = self.callbacks.on_persist_wallet_metadata_fn { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - meta.network.into(), - meta.wallet_group_id.as_ptr(), - meta.birth_height, - ) + // 1) notes + if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { + let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); + let mut notes_count: usize = 0; + let rc = + unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_notes_fn returned error code {}", rc), + )); + } + struct NotesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + } + impl Drop for NotesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _notes_guard = NotesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_notes_free_fn, + entries: notes_ptr, + count: notes_count, + }; + if !notes_ptr.is_null() && notes_count > 0 { + let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; + for ffi in slice { + if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { + continue; + } + let note_data = unsafe { + std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) + .to_vec() + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.notes.push(ShieldedNote { + position: ffi.position, + cmx: ffi.cmx, + nullifier: ffi.nullifier, + block_height: ffi.block_height, + is_spent: ffi.is_spent != 0, + value: ffi.value, + note_data, + }); + } + } + } + + // 2) outgoing (sent) notes recovered via OVK + if let Some(load_outgoing) = self.callbacks.on_load_shielded_outgoing_notes_fn { + let mut out_ptr: *const ShieldedOutgoingNoteRestoreFFI = std::ptr::null(); + let mut out_count: usize = 0; + let rc = + unsafe { load_outgoing(self.callbacks.context, &mut out_ptr, &mut out_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!( + "on_load_shielded_outgoing_notes_fn returned error code {}", + rc + ), + )); + } + struct OutgoingGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedOutgoingNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedOutgoingNoteRestoreFFI, + count: usize, + } + impl Drop for OutgoingGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _outgoing_guard = OutgoingGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_outgoing_notes_free_fn, + entries: out_ptr, + count: out_count, }; - if result != 0 { - eprintln!( - "Wallet metadata persistence callback returned error code {}", - result - ); - outcome.record(result); + if !out_ptr.is_null() && out_count > 0 { + let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; + for ffi in slice { + let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { + Vec::new() + } else { + unsafe { + std::slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() + } + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.outgoing_notes.push(ShieldedOutgoingNote { + cmx: ffi.cmx, + recipient: ffi.recipient.to_vec(), + value: ffi.value, + memo, + block_height: ffi.block_height, + }); + } } } - } - // Per-account registration entries. The `_xpub_bytes_storage` - // Vec keeps the bincoded xpub buffers alive for the callback - // window — `AccountSpecFFI.account_xpub_bytes` borrows into - // it. Same lifetime discipline the prior dedicated callback - // used. - if !changeset.account_registrations.is_empty() - || !changeset.provider_key_account_registrations.is_empty() - { - if let Some(cb) = self.callbacks.on_persist_account_registrations_fn { - match build_account_specs_for_callback( - &changeset.account_registrations, - &changeset.provider_key_account_registrations, - ) { - Ok((specs, _xpub_bytes_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - specs.as_ptr(), - specs.len(), - ) - }; - // Force the spec / byte buffers to live until after - // the callback even though their drop happens on - // scope exit anyway. - drop(specs); - drop(_xpub_bytes_storage); - if result != 0 { - eprintln!( - "Account registrations persistence callback returned error code {}", - result - ); - outcome.record(result); + // 3) per-subwallet sync states + if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { + let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); + let mut states_count: usize = 0; + let rc = unsafe { + load_states(self.callbacks.context, &mut states_ptr, &mut states_count) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_sync_states_fn returned error code {}", rc), + )); + } + struct StatesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + } + impl Drop for StatesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; } } - Err(e) => { - eprintln!("Failed to encode account registration specs: {}", e); - outcome.record_fatal(); + } + let _states_guard = StatesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, + entries: states_ptr, + count: states_count, + }; + if !states_ptr.is_null() && states_count > 0 { + let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.last_synced_index = ffi.last_synced_index; + entry.has_sync_state = true; } } } - } - // Per-account address-pool snapshots. The `_string_storage` - // Vec keeps every owned `CString` alive for the callback - // window; `_address_storage` keeps every per-pool - // `Vec` alive (each pool holds pointers - // into a sibling string buffer); `_pools` is the heap-array - // the callback iterates over. - if !changeset.account_address_pools.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - match build_address_pools_for_callback(&changeset.account_address_pools) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) + // 4) derived activity entries + if self.callbacks.on_load_shielded_activity_fn.is_some() + != self.callbacks.on_load_shielded_activity_free_fn.is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_activity_fn and on_load_shielded_activity_free_fn must be \ + provided together", + )); + } + if let Some(load_activity) = self.callbacks.on_load_shielded_activity_fn { + let mut act_ptr: *const ShieldedActivityRestoreFFI = std::ptr::null(); + let mut act_count: usize = 0; + let rc = + unsafe { load_activity(self.callbacks.context, &mut act_ptr, &mut act_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_activity_fn returned error code {}", rc), + )); + } + struct ActivityGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedActivityRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedActivityRestoreFFI, + count: usize, + } + impl Drop for ActivityGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _activity_guard = ActivityGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_activity_free_fn, + entries: act_ptr, + count: act_count, + }; + if !act_ptr.is_null() && act_count > 0 { + let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; + for ffi in slice { + let kind = match ffi.kind_tag { + 0 => ShieldedActivityKind::Shield, + 1 => ShieldedActivityKind::ShieldFromAssetLock, + 2 => ShieldedActivityKind::Received, + 3 => ShieldedActivityKind::Sent, + 4 => ShieldedActivityKind::Unshield, + 5 => ShieldedActivityKind::Withdrawal, + 6 => ShieldedActivityKind::IdentityCreate { + identity_id: ffi.identity_id, + }, + 8 => ShieldedActivityKind::ShieldFromIdentity { + identity_id: ffi.identity_id, + }, + 9 => ShieldedActivityKind::IdentityTopUp { + identity_id: ffi.identity_id, + }, + // 7 and any unknown tag fall back to the + // residual — a forward-compat tag we don't yet + // model still loads as an opaque spend rather + // than getting dropped. + _ => ShieldedActivityKind::ShieldedSpend, + }; + let direction = match ffi.direction { + 0 => ShieldedDirection::In, + 1 => ShieldedDirection::Out, + 2 => ShieldedDirection::SelfTransfer, + other => { + // Unlike kind_tag (whose residual + // `ShieldedSpend` variant is a designed + // forward-compat catch-all), direction has + // no "unknown" bucket — make a corrupted / + // future byte loud instead of silently + // reading as a real classification. + tracing::warn!( + direction = other, + "unknown shielded-activity direction byte on load; folding to SelfTransfer" + ); + ShieldedDirection::SelfTransfer + } + }; + let status = activity_status_from_tag(ffi.status); + let counterparty = if ffi.counterparty_ptr.is_null() + || ffi.counterparty_len == 0 + { + None + } else { + Some(unsafe { + slice::from_raw_parts(ffi.counterparty_ptr, ffi.counterparty_len) + .to_vec() + }) + }; + let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { + None + } else { + Some(unsafe { + slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() + }) + }; + let note_cmxs = + unsafe { decode_cmx_array(ffi.note_cmxs_ptr, ffi.note_cmxs_count) }; + let spent_nullifiers = unsafe { + decode_cmx_array(ffi.spent_nullifiers_ptr, ffi.spent_nullifiers_count) }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Account address pools persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode account address pool entries: {}", e); - outcome.record_fatal(); - } - } - } - } - // Send incremental address balance updates before merging. - if let Some(ref addr_cs) = changeset.platform_addresses { - if let Some(cb) = self.callbacks.on_persist_address_balances_fn { - let entries: Vec = addr_cs - .addresses - .iter() - .map(|entry| AddressBalanceEntryFFI { - address: entry.address.into(), - balance: entry.funds.balance, - nonce: entry.funds.nonce, - account_index: entry.account_index, - address_index: entry.address_index, - as_of_height: entry.funds.as_of_height, - }) - .collect(); - if !entries.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), - ) - }; - if result != 0 { - eprintln!( - "Address balance persistence callback returned error code {}", - result - ); - outcome.record(result); + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.activity.push(ShieldedActivityEntry { + id: ffi.entry_id, + kind, + direction, + amount: ffi.amount, + fee: if ffi.has_fee != 0 { + Some(ffi.fee) + } else { + None + }, + counterparty, + memo, + block_height: if ffi.has_block_height != 0 { + Some(ffi.block_height) + } else { + None + }, + status, + created_at_ms: ffi.created_at_ms, + min_note_position: if ffi.has_min_note_position != 0 { + Some(ffi.min_note_position) + } else { + None + }, + note_cmxs, + spent_nullifiers, + }); } } } - } - // Send core wallet changeset (accounts, transactions, UTXOs). - if let Some(ref core_cs) = changeset.core { - // Fan out gap-limit-extension addresses BEFORE the wallet - // changeset itself: the changeset's UTXOs reference these - // addresses, and the Swift-side `upsertUtxo`'s - // `coreAddress` link lookup is keyed on the address row - // existing. Emitting the pool snapshot first means a - // brand-new TXO landing on a freshly-derived address - // finds the matching `PersistentCoreAddress` in the same - // changeset round and the cascade-delete chain stays - // intact. Reuses `on_persist_account_address_pools_fn` - // so the Swift side handles both registration-time emit - // and event-time emit through `persistAccountAddresses` - // — single Swift code path covers both. - if !core_cs.addresses_derived.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - match build_address_pools_from_derived(&core_cs.addresses_derived) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) - }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Derived-address persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode derived address pool entries: {}", e); - outcome.record_fatal(); + // 5) persisted Orchard viewing keys (raw 96-byte FVK + // encodings), consumed by + // `PlatformWallet::bind_shielded_from_persisted` so a + // launch-time rebind needs no mnemonic resolve. + if self.callbacks.on_load_shielded_viewing_keys_fn.is_some() + != self + .callbacks + .on_load_shielded_viewing_keys_free_fn + .is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_viewing_keys_fn and \ + on_load_shielded_viewing_keys_free_fn must be provided together", + )); + } + if let Some(load_viewing_keys) = self.callbacks.on_load_shielded_viewing_keys_fn { + let mut vk_ptr: *const ShieldedViewingKeyRestoreFFI = std::ptr::null(); + let mut vk_count: usize = 0; + let rc = unsafe { + load_viewing_keys(self.callbacks.context, &mut vk_ptr, &mut vk_count) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!( + "on_load_shielded_viewing_keys_fn returned error code {}", + rc + ), + )); + } + struct ViewingKeysGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedViewingKeyRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedViewingKeyRestoreFFI, + count: usize, + } + impl Drop for ViewingKeysGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; } } } - } - - // Fan out used-flag flips AFTER the derived-address emit: - // a tx can land on a freshly-derived address in the same - // round, and the Swift-side `persistAccountAddresses` - // overwrites `isUsed` with whatever the latest emit says — - // derived-first (`is_used: false`) then marked-used - // (`is_used: true`) leaves the row correctly flipped. - // These entries carry the authoritative post-mark - // `AddressInfo` from the wallet's pools (see - // `CoreChangeSet::addresses_marked_used`), so reusing the - // whole-pool snapshot encoder is exact, not approximate. - if !core_cs.addresses_marked_used.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - let entries = - group_marked_used_into_pool_entries(&core_cs.addresses_marked_used); - match build_address_pools_for_callback(&entries) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) - }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Marked-used address persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode marked-used address pool entries: {}", e); - outcome.record_fatal(); - } + let _viewing_keys_guard = ViewingKeysGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_viewing_keys_free_fn, + entries: vk_ptr, + count: vk_count, + }; + if !vk_ptr.is_null() && vk_count > 0 { + let slice = unsafe { slice::from_raw_parts(vk_ptr, vk_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + shielded_state + .viewing_keys + .insert(id, ffi.fvk_bytes.to_vec()); } } } - // The engine's credit verdicts ride their own size-negotiated - // extension slot (see the layout note on `WalletChangeSetFFI`) - // and are fired BEFORE the changeset callback: a persister that - // derives its UTXO rows from record roles needs the verdicts in - // hand while it materialises this round's `utxos_added` - // entries, or it stages the very phantom row the verdict exists - // to prevent. Fired only when the round carries a verdict, so a - // host without the slot — or a round where every output was - // credited — behaves exactly as before. - if !core_cs.utxo_credit_verdicts.is_empty() { - if let Some(cb) = self.wallet_changeset_utxo_verdicts_callback { - let verdicts = build_utxo_credit_verdicts_for_callback(core_cs); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - verdicts.as_ptr(), - verdicts.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet changeset credit-verdict persistence callback returned error \ - code {}", - result - ); - outcome.record(result); - } + out.shielded = shielded_state; + } + + Ok(out) + } + + fn load_core_instant_locks( + &self, + wallet_id: &WalletId, + history: &mut RecordedHistory, + ) -> Result<(), PersistenceError> { + let (load, free) = match ( + self.load_core_instant_lock_callback, + self.free_core_instant_lock_callback, + ) { + (None, None) => return Ok(()), + (Some(load), Some(free)) => (load, free), + _ => { + return Err(PersistenceError::backend( + "raw InstantLock load/free callbacks must be provided together", + )) + } + }; + struct Guard { + context: *mut c_void, + bytes: *const u8, + len: usize, + free: FreeCoreInstantLockFn, + } + impl Drop for Guard { + fn drop(&mut self) { + if !self.bytes.is_null() { + unsafe { (self.free)(self.context, self.bytes, self.len) }; } } + } + for record in &history.transactions { + let mut bytes = std::ptr::null(); + let mut len = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + AsRef::<[u8]>::as_ref(&record.txid).as_ptr(), + &mut bytes, + &mut len, + ) + }; + let _guard = Guard { + context: self.callbacks.context, + bytes, + len, + free, + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "raw InstantLock load failed".to_owned(), + )); + } + if bytes.is_null() && len == 0 { + continue; + } + if bytes.is_null() || len == 0 || len > dashcore::consensus::encode::MAX_VEC_SIZE { + return Err(PersistenceError::backend( + "raw InstantLock loader returned an invalid or oversized buffer", + )); + } + let raw = unsafe { slice::from_raw_parts(bytes, len) }; + let lock = dashcore::consensus::deserialize(raw).map_err(PersistenceError::backend)?; + history.instant_locks.insert(record.txid, lock); + } + Ok(()) + } - if let Some(cb) = self.callbacks.on_persist_wallet_changeset_fn { - let ffi_cs = WalletChangeSetFFI::from_changeset(core_cs); - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &ffi_cs) }; - unsafe { free_wallet_changeset_ffi(&ffi_cs) }; - if result != 0 { - eprintln!( - "Wallet changeset persistence callback returned error code {}", - result - ); - outcome.record(result); - } else if core_cs.synced_height.is_some() { - cursor_delivered = true; - } + /// Narrow a round's failure kind to what the caller may safely act on: + /// `Transient` survives only where + /// [`Self::store_transient_is_reissuable`] holds, since losing a retry + /// opportunity costs less than the rows a re-sent partial round would + /// duplicate. `Constraint` and `Fatal` invite no retry, so they pass + /// through. + fn reportable_round_kind(&self, reported: PersistenceErrorKind) -> PersistenceErrorKind { + match reported { + PersistenceErrorKind::Transient if !self.store_transient_is_reissuable() => { + PersistenceErrorKind::Fatal } + kind => kind, + } + } - // The numeric chainlock height rides its own size-negotiated - // extension slot for the same layout reason the sweeps below do: - // the bincode blob on the changeset struct is opaque to a - // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow - // a numeric field. Fired before the sweeps so a round carrying - // both has the boundary stored before any tombstone the sweep - // writes could be measured against it. - if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { - if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { - let result = - unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; - if result != 0 { - eprintln!( - "Wallet changeset chainlock-height persistence callback returned \ - error code {}", - result - ); - outcome.record(result); - } - } + /// Compute the callback contracts that are structurally complete in this + /// vtable. This mask is only an upper bound: the host must separately attest + /// the semantics it actually implements. + fn callback_capabilities(&self) -> PersistenceCapabilities { + let mut capabilities = PersistenceCapabilities::NONE; + + if self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::ATOMIC_CHANGESETS); + } + if self.callbacks.on_persist_invitations_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); + } + if self.dpns_name_states_callback.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); + } + let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() + && self.callbacks.on_load_wallet_list_free_fn.is_some(); + if self.callbacks.on_persist_account_registrations_fn.is_some() + && self.callbacks.on_persist_account_address_pools_fn.is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES); + } + if wallet_restore { + // History rides the same wallet-list load; a host that wires it + // must still declare the bit to attest it fills the rows. + capabilities = capabilities.union(PersistenceCapabilities::CORE_HISTORY_RESTORE); + if self.replay_callbacks.complete() { + capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); } + } + if self.callbacks.on_persist_asset_locks_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + } + // Replay cleanup has no durable operation tombstones yet; UUID callbacks + // alone cannot attest ASSET_LOCK_OPERATION_IDS after conflicting history is removed. + if self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); + } + if self.callbacks.on_persist_wallet_changeset_fn.is_some() + && wallet_restore + && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) + { + capabilities = capabilities.union(PersistenceCapabilities::PROVIDER_TRANSACTIONS); + } + if self.callbacks.on_persist_token_balances_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); + } + // The dashpay-payments slot is what the sweep's Failed flip rides + // (`dashpay_payments_overlay` on the store round). A host that + // never wired it — Android deliberately keeps payment recording + // in-memory-only — must not read as payment-durable, or the + // wallet-event adapter would couple the flip to a round that + // silently drops it: the accepted-and-ignored shape the sweep + // bit's own gating exists to prevent, reproduced one channel over. + if self.callbacks.on_persist_dashpay_payments_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); + } + // Sweeps travel through the size-tagged extension callback, so — + // unlike the legacy `on_persist_wallet_changeset_fn`, whose + // unchanged C signature proves nothing about what a host actually + // reads — this slot being `Some` is a genuine structural + // attestation: it exists only when the host's declared extension + // `struct_size` covered the field. The changeset callback is still + // required alongside it because a sweep only corrects state that + // callback persists; a sweeps slot with no changeset slot would + // attest removals against rows the host never writes. The bit is + // still additionally gated by `declared_capabilities` in + // `persistence_capabilities()` below, like every other bit: the + // host must attest the semantic contract, not just wire pointers. + // + // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, + // and only for this bit, because moving sweeps onto their own slot + // split one logical `CoreChangeSet` across two calls. Without a + // round that commits or rolls back as a unit, the changeset call + // can make the watermark and the additive rows durable and the + // process can stop before the sweep call applies the removal — + // leaving a host that restarts past a deletion it never performed + // and reloads the dead transaction. Nothing before sweeps could + // fail this way: every core field arrived through one callback. + if self.wallet_changeset_sweeps_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self + .declared_capabilities + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + { + capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + } + #[cfg(feature = "shielded")] + if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() + && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() + && self + .callbacks + .on_load_shielded_viewing_keys_free_fn + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS); + } - // The round's sweeps ride their own size-negotiated extension - // callback rather than the changeset struct (see the layout note - // on `WalletChangeSetFFI`), fired after the changeset callback - // and after the chainlock-height slot, still inside the same - // begin/end bracket — so the additive half of the round, a - // wallet-relevant winner's own record included, is already - // staged when the removal decides which links point at a dead - // transaction. - // - // A host without the slot simply never sees them, and this block - // stays silent about that on purpose: such a host can never - // attest `CORE_SWEEP_REMOVAL` (the derivation below requires the - // slot structurally), so the round is refused one layer up - // instead. NOTE: that refusal is the watermark-strip gate in the - // core bridge, which lands with the producer — nothing in THIS - // crate consults `CORE_SWEEP_REMOVAL` yet, and until the - // producer exists no round can carry sweeps at all. - if !core_cs.sweeps.is_empty() { - if let Some(cb) = self.wallet_changeset_sweeps_callback { - let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - batches.as_ptr(), - batches.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet changeset sweeps persistence callback returned error code {}", - result - ); - outcome.record(result); - } + // Deliberately absent: PersistenceCallbacks has no deferred contact + // crypto add/clear callback pair, so FFI hosts cannot attest it. + capabilities + } +} + +/// What `on_get_core_tx_record_fn` answered for one txid. +enum CoreTxRead { + /// The callback is unset, or the host does not store the txid. + Missing, + /// The callback returned this non-zero code. + Failed(i32), + /// The host reported a hit but handed over no decodable body. + Unreadable(&'static str), + /// A hit: the host's raw context fields and the decoded body. + Found { + context_kind: u8, + block_height: u32, + block_hash: [u8; 32], + block_timestamp: u32, + transaction: dashcore::Transaction, + }, +} + +impl FFIPersister { + /// Call `on_get_core_tx_record_fn` for `txid` and decode its answer, + /// handing the tx-bytes buffer back to the host on every path. Each + /// caller applies its own policy to the outcome. + fn read_core_tx(&self, wallet_id: WalletId, txid: &dashcore::Txid) -> CoreTxRead { + use dashcore::consensus::Decodable; + + let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { + return CoreTxRead::Missing; + }; + + let txid_bytes: [u8; 32] = *txid.as_byte_array(); + let mut context_kind: u8 = 0; + let mut block_height: u32 = 0; + let mut block_hash: [u8; 32] = [0u8; 32]; + let mut block_timestamp: u32 = 0; + let mut tx_bytes_ptr: *const u8 = std::ptr::null(); + let mut tx_bytes_len: usize = 0; + let mut found: bool = false; + + // SAFETY: All output pointers reference Rust-owned stack + // locals that outlive the callback invocation. `wallet_id` + // and `txid` are fixed-size byte arrays. + let rc = unsafe { + get_cb( + self.callbacks.context, + wallet_id.as_ptr(), + txid_bytes.as_ptr(), + &mut context_kind, + &mut block_height, + block_hash.as_mut_ptr(), + &mut block_timestamp, + &mut tx_bytes_ptr, + &mut tx_bytes_len, + &mut found, + ) + }; + + // RAII guard so the tx-bytes free callback fires on every + // exit path past this point, handing the buffer back to the host. + struct TxBytesGuard { + ptr: *const u8, + len: usize, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + tx_bytes: *const u8, + tx_bytes_len: usize, + ), + >, + ctx: *mut c_void, + } + impl Drop for TxBytesGuard { + fn drop(&mut self) { + if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { + // SAFETY: ptr+len match the values the lookup + // callback wrote; the host owns the allocation + // until this free fires. + unsafe { free(self.ctx, self.ptr, self.len) }; } } } + let _bytes_guard = TxBytesGuard { + ptr: tx_bytes_ptr, + len: tx_bytes_len, + free_fn: self.callbacks.on_get_core_tx_record_free_fn, + ctx: self.callbacks.context, + }; - // The DashPay backfill record rides its own size-negotiated slot, - // after the core changeset callback so the lowered cursor a rescan - // round carries is staged before the record that vouches for it. - // Whole-record semantics: the host replaces what it holds. - // - // A host that registered no changeset callback stores no cursor at - // all, so no record can vouch for one it holds: it never receives - // the record, not even on a record-only round. Withholding only the - // cursor-bearing round is not enough — that round still returns - // `Ok`, the reconcile records its cursor as durable, and a later - // record-only round would then deliver coverage beside a host cursor - // that never moved. - // - // Nor does a host whose rounds are not atomic: callbacks that run - // after this one (identities, payments, tokens, shielded) can still - // fail the round, and such a host would keep the cursor and the - // coverage from a round Rust reports as failed — coverage that then - // suppresses the rescan on the next launch. Only a host attesting - // `ATOMIC_CHANGESETS` rolls the whole round back, record included. - // - // Both gates guard COVERAGE. An empty record covers no contact, so it - // vouches for no cursor and is safe beside any, on any host. It is - // how Rust invalidates coverage a host already holds: - // `settle_published_backfill` sends one before publishing a wallet to - // a host that cannot keep coverage durably, so the host cannot - // restore an earlier run's coverage beside cursors this run scanned - // without it. Withholding it here would let `store` return `Ok` with - // the old coverage still on disk. So an empty record goes to every - // host with the slot, and a failing callback fails the round, which - // fails the publication. A host without the slot never received a - // record, so it holds none to invalidate. - let host_stores_cursors = self.callbacks.on_persist_wallet_changeset_fn.is_some() - && self - .persistence_capabilities() - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS); - let backfill_cursor_owed = changeset - .core - .as_ref() - .is_some_and(|core| core.synced_height.is_some()); - if let Some(record) = changeset.dashpay_backfill.as_ref().filter(|record| { - record.is_empty() - || (host_stores_cursors && (!backfill_cursor_owed || cursor_delivered)) - }) { - if let Some(cb) = self.wallet_dashpay_backfill_callback { - let covered = build_dashpay_backfill_covered_for_callback(record); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - record.floor, - record.rewound_from, - if covered.is_empty() { - std::ptr::null() - } else { - covered.as_ptr() - }, - covered.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet DashPay backfill persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } + if rc != 0 { + return CoreTxRead::Failed(rc); + } + if !found { + return CoreTxRead::Missing; + } + if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { + return CoreTxRead::Unreadable("handed over no transaction bytes"); + } + // SAFETY: the host guarantees `tx_bytes_ptr` points to + // `tx_bytes_len` valid bytes until the free callback — + // `_bytes_guard` keeps that window open until this returns. + let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; + match dashcore::Transaction::consensus_decode(&mut &tx_slice[..]) { + Ok(transaction) => CoreTxRead::Found { + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + }, + Err(_) => CoreTxRead::Unreadable("its transaction bytes do not decode"), } + } +} - // Send identity scalar changeset — upserts and removals. - // Swift handler maps these onto `PersistentIdentity` row - // upserts / tombstones. - if let Some(ref id_cs) = changeset.identities { - if let Some(cb) = self.callbacks.on_persist_identities_fn { - // Build heap-allocated mirrors. Scoped so the Vec - // drops only after the free-loop runs even if the - // callback invocation panics. - let mut upserts: Vec = id_cs - .identities - .values() - .map(IdentityEntryFFI::from_entry) - .collect(); - let removed: Vec<[u8; 32]> = - id_cs.removed.iter().map(|id| id.to_buffer()).collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - // Release every heap-allocated field on the FFI - // entries before the Vec drops its storage. - for entry in upserts.iter_mut() { - unsafe { free_identity_entry_ffi(entry) }; - } - if result != 0 { - eprintln!( - "Identity changeset persistence callback returned error code {}", - result - ); - outcome.record(result); +impl PlatformWalletPersistence for FFIPersister { + /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back + /// whole; nothing of a rolled-back round survives on this side either, so + /// the changeset is the caller's to re-issue. The capability is the + /// declaration intersected with the wired brackets, so an attestation + /// without an `end` callback to roll anything back does not count. + fn store_transient_is_reissuable(&self) -> bool { + self.persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + } + + // Fan-out coverage note: `pending_contact_crypto_added` / + // `pending_contact_crypto_cleared` have no vtable slots yet, so the + // deferred contact-crypto queue is NOT durable on FFI hosts — the + // recurring sweep re-enqueues after a restart (see the field docs on + // `PlatformWalletChangeSet`). Wire host callbacks before relying on + // restart-immediate drains. + + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.declared_capabilities + .intersection(self.callback_capabilities()) + } + + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[platform_wallet::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + // No callback ⇒ the honest default: a session-scoped no-op. The + // TRACKED_MASTERNODES capability bit is not attested in that case, + // so callers know the difference. + let Some(persist) = self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + else { + return Ok(()); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + // Own every string for the duration of the call. + let storage: Vec<(Option, CString)> = records + .iter() + .map(|record| { + let label = record.label.as_deref().and_then(|l| CString::new(l).ok()); + let snapshot = CString::new(platform_wallet::masternode::snapshot_to_json( + &record.snapshot, + )) + .expect("snapshot JSON contains no interior NUL"); + (label, snapshot) + }) + .collect(); + let rows: Vec = records + .iter() + .zip(storage.iter()) + .map(|(record, (label, snapshot))| TrackedMasternodeFFI { + pro_tx_hash: record.pro_tx_hash, + label: label + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(std::ptr::null()), + added_at: record.added_at, + snapshot_json: snapshot.as_ptr(), + }) + .collect(); + let rc = unsafe { + persist( + self.callbacks.context, + network_c.as_ptr(), + if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + }, + rows.len(), + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_persist_tracked_masternodes_fn returned error code {rc}"), + )); + } + Ok(()) + } + + fn load_core_history_for_accounting( + &self, + wallet_id: WalletId, + ) -> Result>, PersistenceError> { + // A backfill that could not be stored or completed, or not as one + // round, is not worth reading. + let (Some(load), Some(free), Some(_), Some(_), Some(_), Some(_)) = ( + self.load_core_history_for_accounting_callback, + self.load_core_history_for_accounting_free_callback, + self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, + ) else { + return Ok(None); + }; + let mut backfilled = false; + let mut rows_ptr: *const CoreHistoryTransactionFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut backfilled, + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_core_history_for_accounting_fn returned error code {rc}"), + )); + } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let bytes = unsafe { slice_from_raw(row.tx_bytes, row.tx_bytes_len) }; + match dashcore::consensus::encode::deserialize::(bytes) { + Ok(transaction) => out.push(transaction), + // One undecodable row only goes without accounting. + Err(e) => tracing::warn!( + error = %e, + "load: skipping an undecodable stored transaction in the accounting backfill" + ), } } } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok((!backfilled).then_some(out)) + } - // Stamp sidecars share the begin/end transaction with identity scalars. - // Run after the legacy callback has staged any new identity rows. - if let (Some(id_cs), Some(cb)) = ( - changeset.identities.as_ref(), - self.persist_identity_balance_block_time_callback, - ) { - for entry in id_cs.identities.values() { - // The restore ABI carries only identities owned by this wallet. - // Clear any previous sidecar when ownership is absent or changed; - // observed identities must not leave an unrestorable watermark. - let stamp = entry - .last_updated_balance_block_time - .filter(|_| entry.wallet_id == Some(wallet_id)) - .map(crate::types::BlockTime::from); - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entry.id.as_bytes().as_ptr(), - stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), - ) - }; - if rc != 0 { - outcome.record(rc); - } + /// The backfill's rows and, when `complete`, its completion marker, as + /// one round under `round_lock`: they commit together or not at all, and + /// never land in another wallet's open round. + fn store_history_accounting( + &self, + wallet_id: WalletId, + rows: &[WalletAccounting], + complete: bool, + ) -> Result<(), PersistenceError> { + let (Some(persist_rows), Some(accounted)) = ( + self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, + ) else { + return Ok(()); + }; + // Rows and marker must commit as one unit; without the round pair + // nothing would make them, so nothing is written. + let (Some(begin), Some(end)) = ( + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, + ) else { + return Err(PersistenceError::backend( + "the accounting backfill needs the changeset begin/end pair to commit \ + atomically; nothing was written", + )); + }; + let mut round = self.round_lock.lock(); + round.begin_round()?; + let result = unsafe { begin(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + accounting backfill aborted before any write" + ), + )); + } + let mut outcome = RoundOutcome::default(); + if !rows.is_empty() { + let rows: Vec = + rows.iter().map(TransactionAccountingFFI::from).collect(); + let result = unsafe { + persist_rows( + self.callbacks.context, + wallet_id.as_ptr(), + rows.as_ptr(), + rows.len(), + ) + }; + if result != 0 { + outcome.record(result); } - for identity_id in &id_cs.removed { - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - identity_id.as_bytes().as_ptr(), - std::ptr::null(), + } + if complete && outcome.is_success() { + let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + outcome.record(result); + } + } + let result = unsafe { + end( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + if outcome.is_success() { + outcome.record(result); + } else { + outcome.record_fatal(); + } + } + round.end_round()?; + match outcome.failure_kind() { + Some(kind) => Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(kind), + "the accounting backfill round failed and was rolled back", + )), + None => Ok(()), + } + } + + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + let Some(load) = self.tracked_masternodes_callbacks.load_tracked_masternodes else { + return Ok(Vec::new()); + }; + // Fail closed on a half-wired pair: without the free callback the + // host-allocated rows could never be returned, so every load would + // leak. Same rule as the shielded load/free arms. + let Some(free) = self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + else { + return Err(PersistenceError::backend( + "on_load_tracked_masternodes_fn requires on_load_tracked_masternodes_free_fn; \ + wire both or neither", + )); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + let mut rows_ptr: *const TrackedMasternodeFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + network_c.as_ptr(), + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_tracked_masternodes_fn returned error code {rc}"), + )); + } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let label = if row.label.is_null() { + None + } else { + unsafe { CStr::from_ptr(row.label) } + .to_str() + .ok() + .map(str::to_string) + }; + let snapshot = if row.snapshot_json.is_null() { + platform_wallet::masternode::TrackedMasternodeSnapshot::default() + } else { + platform_wallet::masternode::snapshot_from_json( + unsafe { CStr::from_ptr(row.snapshot_json) } + .to_str() + .unwrap_or(""), ) }; - if rc != 0 { - outcome.record(rc); - } + out.push(platform_wallet::masternode::TrackedMasternode { + pro_tx_hash: row.pro_tx_hash, + label, + added_at: row.added_at, + snapshot, + }); } } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok(out) + } - // Send DashPay payment-history rows — the `dashpay_payments_overlay` - // ONLY. `record_dashpay_payment`, the single writer for every - // payment mutation (live sends, confirm-sweep flips, reconstruction - // upserts), emits exactly the changed `(owner, txid)` row on the - // overlay, so per-round work here is bounded by the delta. The - // full-map `IdentityEntry.dashpay_payments` snapshots riding - // `changeset.identities` are deliberately NOT projected: replaying - // an identity's complete history on every snapshot (including - // unrelated scalar mutations) is unbounded per-call work as history - // grows, against the persistence trait's bounded-work guidance — - // history bootstrap is the host restore buffer's job, and the - // getter-backed reconciler covers gaps. Fires AFTER the identities - // callback so a brand-new owner's `PersistentIdentity` row is - // already staged in the same round when the host resolves the - // payment's owner link. - if let Some(ref overlay) = changeset.dashpay_payments_overlay { - if let Some(cb) = self.callbacks.on_persist_dashpay_payments_fn { - let (entries, _string_storage) = build_payment_persist_entries(overlay); - if !entries.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), - ) - }; - drop(entries); - drop(_string_storage); - if result != 0 { - eprintln!( - "DashPay payment persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } + fn stores_dashpay_backfill(&self) -> bool { + // The record reaches the host only through its extension slot; a + // host without it cannot store one, nor replace one it holds. And + // coverage vouches for the cursor it rides with, which reaches the + // host only through the changeset callback: `store` withholds every + // covering record from a host without one, so claiming the record + // here would let the owning layer count coverage as stored that the + // host never received. Such a host is treated like any other that + // cannot keep coverage durably, which still sends it the empty + // record that clears coverage it holds. + self.wallet_dashpay_backfill_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + } + + fn store_commits_inline(&self) -> bool { + // The end callback commits (or rolls back) the host transaction before + // `store` returns. `flush` is only a later general-purpose notification. + self.callbacks.on_changeset_end_fn.is_some() + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + // Serialize the ENTIRE begin→per-kind→end round against every + // other round producer (see `round_lock`'s field doc and + // dashpay/platform#4069). The lock is a synchronous + // `parking_lot::Mutex`, NOT a `tokio::sync::Mutex`: `store()` + // is a synchronous trait method invoked directly (blocking) from + // both async tasks and blocking FFI entry points, so an async + // mutex cannot be `.await`ed here and `blocking_lock()` panics + // inside a runtime. Serialization — not async yielding — is the + // requirement; callers already block for the round's duration, so + // the sync mutex only adds waiting under genuine round contention. + let mut round = self.round_lock.lock(); + + // An empty backfill record is an invalidation: Rust sends one to + // clear coverage the host handed back on load, before publishing a + // wallet that will scan without it. A host without the backfill slot + // can still hand coverage back — its load path decodes the record + // whether or not the write slot is wired — but it cannot replace it. + // Acknowledging the round would leave that coverage on disk beside + // cursors scanned without its accounts, for a later session with the + // slot to trust. So the round fails before anything is applied, and + // the publication with it (dashpay/platform#4302 review). + if self.wallet_dashpay_backfill_callback.is_none() + && changeset + .dashpay_backfill + .as_ref() + .is_some_and(|record| record.is_empty()) + { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Fatal, + "the host holds a DashPay backfill record but registered no backfill \ + callback to invalidate it", + )); + } + + // Open the round on the Rust side (rejects a nested begin / an + // unclean round left open by a prior unwind — error, never + // panic). Matched 1:1 with the `round.end_round()` below. + round.begin_round()?; + + // Bracket the whole per-kind callback sequence with a + // begin/end pair so clients (Swift, etc.) can treat the + // round as a single atomic transaction: begin opens a + // batch, each per-kind callback mutates staged state, and + // end either commits (`success == true`) or rolls back + // (`success == false`). `success` flips to false on any + // sub-callback returning non-zero; the result is advisory + // for clients (Swift uses it to decide save vs rollback). + if let Some(cb) = self.callbacks.on_changeset_begin_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + // A nonzero begin means the client could NOT open its + // transaction. Proceeding would run every per-kind + // callback against no batch and then fire an unmatched + // `end`. Close the Rust-side round (so `in_round` doesn't + // wedge) and fail now, before any per-kind write — nothing + // was applied, so the host's own classification of `result` + // is reported as-is. + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + round aborted before any write" + ), + )); } } + let mut outcome = RoundOutcome::default(); + // Whether this round's `synced_height` reached the host. A backfill + // record rides the round that lowers the cursor it vouches for, and + // must not reach the host when that cursor did not: a host without + // atomic rounds would keep the coverage over its old cursor, and no + // rollback on this side can take it back (dashpay/platform#4302 + // review). + let mut cursor_delivered = false; - // Send identity-keys changeset — per-key upserts + - // `(identity_id, key_id)` removals. Maps onto Swift's - // `PersistentPublicKey` rows. - if let Some(ref keys_cs) = changeset.identity_keys { - if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { - let mut upserts: Vec = keys_cs - .upserts - .values() - .map(IdentityKeyEntryFFI::from_entry) - .collect(); - let removed: Vec = keys_cs - .removed - .iter() - .map(|(id, key_id)| IdentityKeyRemovalFFI { - identity_id: id.to_buffer(), - key_id: *key_id, - }) - .collect(); + // Wallet-registration metadata. Fires at most once per round + // (registration emits the entry; subsequent rounds carry + // `wallet_metadata: None` so no callback fires). + if let Some(meta) = changeset.wallet_metadata.as_ref() { + if let Some(cb) = self.callbacks.on_persist_wallet_metadata_fn { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), + meta.network.into(), + meta.wallet_group_id.as_ptr(), + meta.birth_height, ) }; - for entry in upserts.iter_mut() { - unsafe { free_identity_key_entry_ffi(entry) }; - } if result != 0 { eprintln!( - "Identity keys changeset persistence callback returned error code {}", + "Wallet metadata persistence callback returned error code {}", result ); outcome.record(result); @@ -2573,161 +3131,113 @@ impl PlatformWalletPersistence for FFIPersister { } } - // Send token-balance changeset — `(identity_id, token_id) -> - // balance` upserts and `(identity_id, token_id)` tombstones. - // Maps onto Swift's `PersistentTokenBalance` rows. - if let Some(ref tb_cs) = changeset.token_balances { - if let Some(cb) = self.callbacks.on_persist_token_balances_fn { - let upserts: Vec = tb_cs - .balances - .iter() - .map(|((iid, tid), amount)| TokenBalanceUpsertFFI { - identity_id: iid.to_buffer(), - token_id: tid.to_buffer(), - balance: *amount, - }) - .collect(); - let removals: Vec = tb_cs - .removed_balances - .iter() - .map(|(iid, tid)| TokenBalanceRemovalFFI { - identity_id: iid.to_buffer(), - token_id: tid.to_buffer(), - }) - .collect(); - if !upserts.is_empty() || !removals.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removals.is_empty() { - std::ptr::null() - } else { - removals.as_ptr() - }, - removals.len(), - ) - }; - if result != 0 { - eprintln!( - "Token balance persistence callback returned error code {}", - result - ); - outcome.record(result); + // Per-account registration entries. The `_xpub_bytes_storage` + // Vec keeps the bincoded xpub buffers alive for the callback + // window — `AccountSpecFFI.account_xpub_bytes` borrows into + // it. Same lifetime discipline the prior dedicated callback + // used. + if !changeset.account_registrations.is_empty() + || !changeset.provider_key_account_registrations.is_empty() + { + if let Some(cb) = self.callbacks.on_persist_account_registrations_fn { + match build_account_specs_for_callback( + &changeset.account_registrations, + &changeset.provider_key_account_registrations, + ) { + Ok((specs, _xpub_bytes_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + specs.as_ptr(), + specs.len(), + ) + }; + // Force the spec / byte buffers to live until after + // the callback even though their drop happens on + // scope exit anyway. + drop(specs); + drop(_xpub_bytes_storage); + if result != 0 { + eprintln!( + "Account registrations persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode account registration specs: {}", e); + outcome.record_fatal(); } } } } - // Send asset-lock changeset — tracked-lock upserts (one row - // per credit output, addressed by outpoint) and outpoint - // tombstones (consumed-by-registration removals). Maps onto - // Swift's `PersistentAssetLock` rows. - if let Some(ref al_cs) = changeset.asset_locks { - if let Some(cb) = self.callbacks.on_persist_asset_locks_fn { - let upsert_refs: Vec<&platform_wallet::changeset::AssetLockEntry> = - al_cs.asset_locks.values().collect(); - let (upserts, _storage) = build_asset_lock_entries(&upsert_refs); - let removed: Vec<[u8; 36]> = al_cs.removed.iter().map(outpoint_to_bytes).collect(); - if !upserts.is_empty() || !removed.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - // Pin both byte-buffer storage (`_storage`) and - // the FFI Vec until after the callback so the - // pointers stay valid through the C call. - drop(upserts); - drop(_storage); - if result != 0 { - eprintln!( - "Asset lock persistence callback returned error code {}", - result - ); - outcome.record(result); + // Per-account address-pool snapshots. The `_string_storage` + // Vec keeps every owned `CString` alive for the callback + // window; `_address_storage` keeps every per-pool + // `Vec` alive (each pool holds pointers + // into a sibling string buffer); `_pools` is the heap-array + // the callback iterates over. + if !changeset.account_address_pools.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + match build_address_pools_for_callback(&changeset.account_address_pools) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Account address pools persistence callback returned error code {}", + result + ); + outcome.record(result); + } } - } - } - // Preserve the legacy row stride. UUIDs are an additive sidecar, - // written only after their base rows in the same atomic round. - for entry in al_cs.asset_locks.values() { - if let Some(operation_id) = entry.operation_id { - let Some(cb) = self - .tracked_masternodes_callbacks - .persist_asset_lock_operation_id - else { - outcome.record(1); - continue; - }; - let out_point = outpoint_to_bytes(&entry.out_point); - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - out_point.as_ptr(), - operation_id.as_ptr(), - ) - }; - if rc != 0 { - outcome.record(rc); + Err(e) => { + eprintln!("Failed to encode account address pool entries: {}", e); + outcome.record_fatal(); } } } } - // Send invitation changeset — DIP-13 sent-invitation records, one - // upsert row per funded voucher (keyed by outpoint) plus outpoint - // tombstones. All-POD entries, so no owned-buffer storage to pin. - // Maps onto Swift's `PersistentInvitation` rows. - if let Some(ref inv_cs) = changeset.invitations { - if let Some(cb) = self.callbacks.on_persist_invitations_fn { - let upsert_refs: Vec<&platform_wallet::changeset::InvitationEntry> = - inv_cs.invitations.values().collect(); - let upserts = build_invitation_entries(&upsert_refs); - let removed: Vec<[u8; 36]> = inv_cs.removed.iter().map(outpoint_to_bytes).collect(); - if !upserts.is_empty() || !removed.is_empty() { + // Send incremental address balance updates before merging. + if let Some(ref addr_cs) = changeset.platform_addresses { + if let Some(cb) = self.callbacks.on_persist_address_balances_fn { + let entries: Vec = addr_cs + .addresses + .iter() + .map(|entry| AddressBalanceEntryFFI { + address: entry.address.into(), + balance: entry.funds.balance, + nonce: entry.funds.nonce, + account_index: entry.account_index, + address_index: entry.address_index, + as_of_height: entry.funds.as_of_height, + }) + .collect(); + if !entries.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), + entries.as_ptr(), + entries.len(), ) }; - drop(upserts); if result != 0 { eprintln!( - "Invitation persistence callback returned error code {}", + "Address balance persistence callback returned error code {}", result ); outcome.record(result); @@ -2736,273 +3246,431 @@ impl PlatformWalletPersistence for FFIPersister { } } - // Send the DPNS username-marketplace changeset — one upsert row - // per tracked `domain` document (keyed by document id) plus - // document-id tombstones. Maps onto the host's DPNS-name rows, - // whose marketplace columns (price, sale status, counterparty) - // these rows own. - // - // Fires AFTER the identities callback so a brand-new identity's - // row is already staged in the same round when the host resolves - // a marketplace row's owning identity. - if let Some(ref dpns_cs) = changeset.dpns_name_states { - if let Some(cb) = self.dpns_name_states_callback { - let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = - dpns_cs.names.values().collect(); - let mut upserts = build_dpns_name_state_entries(&upsert_refs); - let removed: Vec<[u8; 32]> = - dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); - if !upserts.is_empty() || !removed.is_empty() { + // Send core wallet changeset (accounts, transactions, UTXOs). + if let Some(ref core_cs) = changeset.core { + // Fan out gap-limit-extension addresses BEFORE the wallet + // changeset itself: the changeset's UTXOs reference these + // addresses, and the Swift-side `upsertUtxo`'s + // `coreAddress` link lookup is keyed on the address row + // existing. Emitting the pool snapshot first means a + // brand-new TXO landing on a freshly-derived address + // finds the matching `PersistentCoreAddress` in the same + // changeset round and the cascade-delete chain stays + // intact. Reuses `on_persist_account_address_pools_fn` + // so the Swift side handles both registration-time emit + // and event-time emit through `persistAccountAddresses` + // — single Swift code path covers both. + if !core_cs.addresses_derived.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + match build_address_pools_from_derived(&core_cs.addresses_derived) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Derived-address persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode derived address pool entries: {}", e); + outcome.record_fatal(); + } + } + } + } + + // Fan out used-flag flips AFTER the derived-address emit: + // a tx can land on a freshly-derived address in the same + // round, and the Swift-side `persistAccountAddresses` + // overwrites `isUsed` with whatever the latest emit says — + // derived-first (`is_used: false`) then marked-used + // (`is_used: true`) leaves the row correctly flipped. + // These entries carry the authoritative post-mark + // `AddressInfo` from the wallet's pools (see + // `CoreChangeSet::addresses_marked_used`), so reusing the + // whole-pool snapshot encoder is exact, not approximate. + if !core_cs.addresses_marked_used.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + let entries = + group_marked_used_into_pool_entries(&core_cs.addresses_marked_used); + match build_address_pools_for_callback(&entries) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Marked-used address persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode marked-used address pool entries: {}", e); + outcome.record_fatal(); + } + } + } + } + + // The engine's credit verdicts ride their own size-negotiated + // extension slot (see the layout note on `WalletChangeSetFFI`) + // and are fired BEFORE the changeset callback: a persister that + // derives its UTXO rows from record roles needs the verdicts in + // hand while it materialises this round's `utxos_added` + // entries, or it stages the very phantom row the verdict exists + // to prevent. Fired only when the round carries a verdict, so a + // host without the slot — or a round where every output was + // credited — behaves exactly as before. + if !core_cs.utxo_credit_verdicts.is_empty() { + if let Some(cb) = self.wallet_changeset_utxo_verdicts_callback { + let verdicts = build_utxo_credit_verdicts_for_callback(core_cs); let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), + verdicts.as_ptr(), + verdicts.len(), ) }; - // Release the per-row label strings on EVERY path, - // including the callback-reported-failure one, before - // the Vec drops its storage. - unsafe { free_dpns_name_state_entries(&mut upserts) }; if result != 0 { eprintln!( - "DPNS name state persistence callback returned error code {}", + "Wallet changeset credit-verdict persistence callback returned error \ + code {}", result ); outcome.record(result); } } } - } - // Send DashPay contact-request changeset. - // - // The flat upsert array is built by walking every source - // bucket on the changeset: - // - `sent_requests` ⇒ one outgoing row per entry - // - `incoming_requests` ⇒ one incoming row per entry - // - `established` ⇒ two rows per entry (the underlying - // outgoing + incoming `ContactRequest` on - // `EstablishedContact`) so the Swift uniqueness key - // `(network, owner, contact, is_outgoing)` upserts both - // directions cleanly. The auto-establishment contract on - // the Rust side drops any matching `sent_requests` / - // `incoming_requests` entry when promoting to established, - // so this projection never produces a duplicate row in a - // single round. - // - // Removal arrays mirror the changeset's two tombstone fields - // 1:1 — Swift deletes rows by `(owner, contact, is_outgoing)` - // with the direction implied by which bucket they came from. - if let Some(ref contacts_cs) = changeset.contacts { - if let Some(cb) = self.callbacks.on_persist_contacts_fn { - let mut upserts: Vec = Vec::with_capacity( - contacts_cs.sent_requests.len() - + contacts_cs.incoming_requests.len() - + contacts_cs.established.len() * 2, - ); - for (key, entry) in &contacts_cs.sent_requests { - upserts.push(ContactRequestFFI::from_outgoing( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &entry.request, - )); - } - for (key, entry) in &contacts_cs.incoming_requests { - upserts.push(ContactRequestFFI::from_incoming( - key.owner_id.to_buffer(), - key.sender_id.to_buffer(), - &entry.request, - )); - } - for (key, established) in &contacts_cs.established { - // Replicate the relationship's broken-channel flag - // and owner-private metadata (alias/note/hidden — - // contactInfo, M3) onto BOTH the outgoing and - // incoming row — they are properties of the - // established pair, not of one direction, so the - // Swift handler persists them on each - // `(owner, contact, is_outgoing)` row. - upserts.push(ContactRequestFFI::from_established_outgoing( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &established.outgoing_request, - established.payment_channel_broken, - established.alias.as_deref(), - established.note.as_deref(), - established.is_hidden, - &established.accepted_accounts, - established.external_account_reference, - )); - upserts.push(ContactRequestFFI::from_established_incoming( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &established.incoming_request, - established.payment_channel_broken, - established.alias.as_deref(), - established.note.as_deref(), - established.is_hidden, - // Direction-specific: the contact's account label - // rides only the incoming row. - established.contact_account_label.as_deref(), - &established.accepted_accounts, - established.external_account_reference, - )); - } - let removed_sent: Vec = contacts_cs - .removed_sent - .iter() - .map(|key| ContactRequestRemovalFFI { - owner_id: key.owner_id.to_buffer(), - contact_id: key.recipient_id.to_buffer(), - }) - .collect(); - let removed_incoming: Vec = contacts_cs - .removed_incoming + if let Some(cb) = self.callbacks.on_persist_wallet_changeset_fn { + let ffi_cs = WalletChangeSetFFI::from_changeset(core_cs); + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &ffi_cs) }; + unsafe { free_wallet_changeset_ffi(&ffi_cs) }; + if result != 0 { + eprintln!( + "Wallet changeset persistence callback returned error code {}", + result + ); + outcome.record(result); + } else if core_cs.synced_height.is_some() { + cursor_delivered = true; + } + } + + // Per-wallet accounting rides its own size-negotiated slot: the + // frozen `TransactionRecordFFI` stride cannot say "this record + // carries no accounting", and this row can, by being absent. + // Fired after the changeset callback so the host already holds + // the transaction rows these belong to. + if let Some(cb) = self.persist_transaction_accounting_callback { + let rows: Vec = core_cs + .records .iter() - .map(|key| ContactRequestRemovalFFI { - owner_id: key.owner_id.to_buffer(), - contact_id: key.sender_id.to_buffer(), - }) + .filter_map(record_accounting) + .map(|row| TransactionAccountingFFI::from(&row)) .collect(); - // Per-sender ignore deltas, keyed `(owner, sender)`. The - // `ignored` set projects to rows with `is_ignored == true` - // (persist the ignored-sender row); the `unignored` set to - // rows with `is_ignored == false` (delete it). Both ride a - // single array so the host applies a mixed delta in one - // callback. - let ignored: Vec = - contacts_cs - .ignored - .iter() - .map(|(owner, sender)| ContactIgnoredSenderFFI::new(owner, sender, true)) - .chain(contacts_cs.unignored.iter().map(|(owner, sender)| { - ContactIgnoredSenderFFI::new(owner, sender, false) - })) - .collect(); - if !upserts.is_empty() - || !removed_sent.is_empty() - || !removed_incoming.is_empty() - || !ignored.is_empty() - { + if !rows.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed_sent.is_empty() { - std::ptr::null() - } else { - removed_sent.as_ptr() - }, - removed_sent.len(), - if removed_incoming.is_empty() { - std::ptr::null() - } else { - removed_incoming.as_ptr() - }, - removed_incoming.len(), - if ignored.is_empty() { - std::ptr::null() - } else { - ignored.as_ptr() - }, - ignored.len(), + rows.as_ptr(), + rows.len(), ) }; - // Release every heap-allocated payload before the - // outer Vec drops its storage. - if !upserts.is_empty() { - unsafe { free_contact_requests_ffi(upserts.as_mut_ptr(), upserts.len()) }; + if result != 0 { + eprintln!( + "Transaction accounting persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + if let Some(callback) = self.persist_core_instant_lock_callback { + let mut locks = core_cs.instant_locks_for_non_final_records.clone(); + for record in &core_cs.records { + if let key_wallet::transaction_checking::TransactionContext::InstantSend(lock) = + &record.context + { + locks.entry(record.txid).or_insert_with(|| lock.clone()); + } + } + for (txid, lock) in locks { + let bytes = dashcore::consensus::serialize(&lock); + let rc = unsafe { + callback( + self.callbacks.context, + wallet_id.as_ptr(), + AsRef::<[u8]>::as_ref(&txid).as_ptr(), + bytes.as_ptr(), + bytes.len(), + ) + }; + if rc != 0 { + outcome.record(rc); } + } + } + + // The numeric chainlock height rides its own size-negotiated + // extension slot for the same layout reason the sweeps below do: + // the bincode blob on the changeset struct is opaque to a + // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow + // a numeric field. Fired before the sweeps so a round carrying + // both has the boundary stored before any tombstone the sweep + // writes could be measured against it. + if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { + if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { + let result = + unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; if result != 0 { eprintln!( - "Contact persistence callback returned error code {}", + "Wallet changeset chainlock-height persistence callback returned \ + error code {}", + result + ); + outcome.record(result); + } + } + } + + // The round's sweeps ride their own size-negotiated extension + // callback rather than the changeset struct (see the layout note + // on `WalletChangeSetFFI`), fired after the changeset callback + // and after the chainlock-height slot, still inside the same + // begin/end bracket — so the additive half of the round, a + // wallet-relevant winner's own record included, is already + // staged when the removal decides which links point at a dead + // transaction. + // + // A host without the slot simply never sees them, and this block + // stays silent about that on purpose: such a host can never + // attest `CORE_SWEEP_REMOVAL` (the derivation below requires the + // slot structurally), so the round is refused one layer up + // instead. NOTE: that refusal is the watermark-strip gate in the + // core bridge, which lands with the producer — nothing in THIS + // crate consults `CORE_SWEEP_REMOVAL` yet, and until the + // producer exists no round can carry sweeps at all. + if !core_cs.sweeps.is_empty() { + if let Some(cb) = self.wallet_changeset_sweeps_callback { + let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + batches.as_ptr(), + batches.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset sweeps persistence callback returned error code {}", result ); outcome.record(result); } } } - } - - // Send sync state updates. - if let Some(ref addr_cs) = changeset.platform_addresses { - if let Some(cb) = self.callbacks.on_persist_sync_state_fn { - let height = addr_cs.sync_height.unwrap_or(0); - let timestamp = addr_cs.sync_timestamp.unwrap_or(0); - let recent = addr_cs.last_known_recent_block.unwrap_or(0); - if height > 0 || timestamp > 0 || recent > 0 { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - height, - timestamp, - recent, - ) - }; - if result != 0 { - eprintln!( - "Sync state persistence callback returned error code {}", - result - ); - outcome.record(result); - } + } + + // The DashPay backfill record rides its own size-negotiated slot, + // after the core changeset callback so the lowered cursor a rescan + // round carries is staged before the record that vouches for it. + // Whole-record semantics: the host replaces what it holds. + // + // A host that registered no changeset callback stores no cursor at + // all, so no record can vouch for one it holds: it never receives + // the record, not even on a record-only round. Withholding only the + // cursor-bearing round is not enough — that round still returns + // `Ok`, the reconcile records its cursor as durable, and a later + // record-only round would then deliver coverage beside a host cursor + // that never moved. + // + // Nor does a host whose rounds are not atomic: callbacks that run + // after this one (identities, payments, tokens, shielded) can still + // fail the round, and such a host would keep the cursor and the + // coverage from a round Rust reports as failed — coverage that then + // suppresses the rescan on the next launch. Only a host attesting + // `ATOMIC_CHANGESETS` rolls the whole round back, record included. + // + // Both gates guard COVERAGE. An empty record covers no contact, so it + // vouches for no cursor and is safe beside any, on any host. It is + // how Rust invalidates coverage a host already holds: + // `settle_published_backfill` sends one before publishing a wallet to + // a host that cannot keep coverage durably, so the host cannot + // restore an earlier run's coverage beside cursors this run scanned + // without it. Withholding it here would let `store` return `Ok` with + // the old coverage still on disk. So an empty record goes to every + // host with the slot, and a failing callback fails the round, which + // fails the publication. A host without the slot never received a + // record, so it holds none to invalidate. + let host_stores_cursors = self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self + .persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS); + let backfill_cursor_owed = changeset + .core + .as_ref() + .is_some_and(|core| core.synced_height.is_some()); + if let Some(record) = changeset.dashpay_backfill.as_ref().filter(|record| { + record.is_empty() + || (host_stores_cursors && (!backfill_cursor_owed || cursor_delivered)) + }) { + if let Some(cb) = self.wallet_dashpay_backfill_callback { + let covered = build_dashpay_backfill_covered_for_callback(record); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + record.floor, + record.rewound_from, + if covered.is_empty() { + std::ptr::null() + } else { + covered.as_ptr() + }, + covered.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet DashPay backfill persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + // Send identity scalar changeset — upserts and removals. + // Swift handler maps these onto `PersistentIdentity` row + // upserts / tombstones. + if let Some(ref id_cs) = changeset.identities { + if let Some(cb) = self.callbacks.on_persist_identities_fn { + // Build heap-allocated mirrors. Scoped so the Vec + // drops only after the free-loop runs even if the + // callback invocation panics. + let mut upserts: Vec = id_cs + .identities + .values() + .map(IdentityEntryFFI::from_entry) + .collect(); + let removed: Vec<[u8; 32]> = + id_cs.removed.iter().map(|id| id.to_buffer()).collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Release every heap-allocated field on the FFI + // entries before the Vec drops its storage. + for entry in upserts.iter_mut() { + unsafe { free_identity_entry_ffi(entry) }; + } + if result != 0 { + eprintln!( + "Identity changeset persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + // Stamp sidecars share the begin/end transaction with identity scalars. + // Run after the legacy callback has staged any new identity rows. + if let (Some(id_cs), Some(cb)) = ( + changeset.identities.as_ref(), + self.persist_identity_balance_block_time_callback, + ) { + for entry in id_cs.identities.values() { + // The restore ABI carries only identities owned by this wallet. + // Clear any previous sidecar when ownership is absent or changed; + // observed identities must not leave an unrestorable watermark. + let stamp = entry + .last_updated_balance_block_time + .filter(|_| entry.wallet_id == Some(wallet_id)) + .map(crate::types::BlockTime::from); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entry.id.as_bytes().as_ptr(), + stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + for identity_id in &id_cs.removed { + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + identity_id.as_bytes().as_ptr(), + std::ptr::null(), + ) + }; + if rc != 0 { + outcome.record(rc); } } } - // Shielded changeset (Orchard): four flat callback batches - // mirroring the four `ShieldedChangeSet` fields. Notes - // first so a follow-up `mark_spent` for the same nullifier - // upserts onto an existing row instead of falling on - // missing-row floor. - #[cfg(feature = "shielded")] - if let Some(ref shielded_cs) = changeset.shielded { - use crate::shielded_persistence::*; - - // 1) notes_saved - if !shielded_cs.notes_saved.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { - // Flatten the per-subwallet map into a single - // contiguous Vec so the callback gets one - // `entries: *const ShieldedNoteFFI` slice. The - // host copies `note_data` bytes during the call. - let entries: Vec = shielded_cs - .notes_saved - .iter() - .flat_map(|(id, notes)| { - notes.iter().map(|n| ShieldedNoteFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - position: n.position, - cmx: n.cmx, - nullifier: n.nullifier, - block_height: n.block_height, - is_spent: u8::from(n.is_spent), - value: n.value, - note_data_ptr: n.note_data.as_ptr(), - note_data_len: n.note_data.len(), - }) - }) - .collect(); + // Send DashPay payment-history rows — the `dashpay_payments_overlay` + // ONLY. `record_dashpay_payment`, the single writer for every + // payment mutation (live sends, confirm-sweep flips, reconstruction + // upserts), emits exactly the changed `(owner, txid)` row on the + // overlay, so per-round work here is bounded by the delta. The + // full-map `IdentityEntry.dashpay_payments` snapshots riding + // `changeset.identities` are deliberately NOT projected: replaying + // an identity's complete history on every snapshot (including + // unrelated scalar mutations) is unbounded per-call work as history + // grows, against the persistence trait's bounded-work guidance — + // history bootstrap is the host restore buffer's job, and the + // getter-backed reconciler covers gaps. Fires AFTER the identities + // callback so a brand-new owner's `PersistentIdentity` row is + // already staged in the same round when the host resolves the + // payment's owner link. + if let Some(ref overlay) = changeset.dashpay_payments_overlay { + if let Some(cb) = self.callbacks.on_persist_dashpay_payments_fn { + let (entries, _string_storage) = build_payment_persist_entries(overlay); + if !entries.is_empty() { let result = unsafe { cb( self.callbacks.context, @@ -3011,964 +3679,939 @@ impl PlatformWalletPersistence for FFIPersister { entries.len(), ) }; + drop(entries); + drop(_string_storage); if result != 0 { eprintln!( - "Shielded notes persistence callback returned error code {}", + "DashPay payment persistence callback returned error code {}", result ); outcome.record(result); } } } + } - // 2) nullifiers_spent - if !shielded_cs.nullifiers_spent.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { - let entries: Vec = shielded_cs - .nullifiers_spent - .iter() - .flat_map(|(id, nfs)| { - nfs.iter().map(|nf| ShieldedNullifierSpentFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - nullifier: *nf, - }) - }) - .collect(); + // Send identity-keys changeset — per-key upserts + + // `(identity_id, key_id)` removals. Maps onto Swift's + // `PersistentPublicKey` rows. + if let Some(ref keys_cs) = changeset.identity_keys { + if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { + let mut upserts: Vec = keys_cs + .upserts + .values() + .map(IdentityKeyEntryFFI::from_entry) + .collect(); + let removed: Vec = keys_cs + .removed + .iter() + .map(|(id, key_id)| IdentityKeyRemovalFFI { + identity_id: id.to_buffer(), + key_id: *key_id, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + for entry in upserts.iter_mut() { + unsafe { free_identity_key_entry_ffi(entry) }; + } + if result != 0 { + eprintln!( + "Identity keys changeset persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + // Send token-balance changeset — `(identity_id, token_id) -> + // balance` upserts and `(identity_id, token_id)` tombstones. + // Maps onto Swift's `PersistentTokenBalance` rows. + if let Some(ref tb_cs) = changeset.token_balances { + if let Some(cb) = self.callbacks.on_persist_token_balances_fn { + let upserts: Vec = tb_cs + .balances + .iter() + .map(|((iid, tid), amount)| TokenBalanceUpsertFFI { + identity_id: iid.to_buffer(), + token_id: tid.to_buffer(), + balance: *amount, + }) + .collect(); + let removals: Vec = tb_cs + .removed_balances + .iter() + .map(|(iid, tid)| TokenBalanceRemovalFFI { + identity_id: iid.to_buffer(), + token_id: tid.to_buffer(), + }) + .collect(); + if !upserts.is_empty() || !removals.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removals.is_empty() { + std::ptr::null() + } else { + removals.as_ptr() + }, + removals.len(), ) }; if result != 0 { eprintln!( - "Shielded nullifier-spent persistence callback returned error code {}", + "Token balance persistence callback returned error code {}", result ); outcome.record(result); } } } + } - // 3) outgoing_notes (OVK-recovered send history). Each - // `memo_ptr` borrows into the changeset's owned `memo` - // Vec, which lives for the whole `store()` call, so the - // pointer stays valid for the callback window (same - // discipline as `note_data_ptr` above). - if !shielded_cs.outgoing_notes.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_outgoing_notes_fn { - let entries: Vec = shielded_cs - .outgoing_notes - .iter() - .flat_map(|(id, notes)| { - notes.iter().filter_map(|n| { - // `recipient` is a 43-byte raw Orchard address - // stored as a `Vec` (serde-derive only covers - // arrays <= 32). It is always exactly 43 bytes - // from OVK recovery; reject (skip + warn) a - // malformed row rather than silently zero-padding - // it into a wrong address. - let recipient: [u8; 43] = match n.recipient.as_slice().try_into() { - Ok(r) => r, - Err(_) => { - tracing::warn!( - recipient_len = n.recipient.len(), - "skipping outgoing-note persist row: \ - recipient is not the expected 43 bytes" - ); - return None; - } - }; - Some(ShieldedOutgoingNoteFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - cmx: n.cmx, - recipient, - value: n.value, - block_height: n.block_height, - memo_ptr: n.memo.as_ptr(), - memo_len: n.memo.len(), - }) - }) - }) - .collect(); + // Send asset-lock changeset — tracked-lock upserts (one row + // per credit output, addressed by outpoint) and outpoint + // tombstones (consumed-by-registration removals). Maps onto + // Swift's `PersistentAssetLock` rows. + if let Some(ref al_cs) = changeset.asset_locks { + if let Some(cb) = self.callbacks.on_persist_asset_locks_fn { + let upsert_refs: Vec<&platform_wallet::changeset::AssetLockEntry> = + al_cs.asset_locks.values().collect(); + let (upserts, _storage) = build_asset_lock_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = al_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), ) }; + // Pin both byte-buffer storage (`_storage`) and + // the FFI Vec until after the callback so the + // pointers stay valid through the C call. + drop(upserts); + drop(_storage); if result != 0 { eprintln!( - "Shielded outgoing-notes persistence callback returned error code {}", + "Asset lock persistence callback returned error code {}", result ); outcome.record(result); } } } - - // 4) synced_indices - if !shielded_cs.synced_indices.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { - let entries: Vec = shielded_cs - .synced_indices - .iter() - .map(|(id, &idx)| ShieldedSyncedIndexFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - last_synced_index: idx, - }) - .collect(); - let result = unsafe { + // Preserve the legacy row stride. UUIDs are an additive sidecar, + // written only after their base rows in the same atomic round. + for entry in al_cs.asset_locks.values() { + if let Some(operation_id) = entry.operation_id { + let Some(cb) = self + .tracked_masternodes_callbacks + .persist_asset_lock_operation_id + else { + outcome.record(1); + continue; + }; + let out_point = outpoint_to_bytes(&entry.out_point); + let rc = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + out_point.as_ptr(), + operation_id.as_ptr(), ) }; - if result != 0 { - eprintln!( - "Shielded synced-index persistence callback returned error code {}", - result - ); - outcome.record(result); + if rc != 0 { + outcome.record(rc); } } } + } - // 5) viewing keys (raw 96-byte FVK encodings). Fixed-size - // rows, no borrowed pointers. A malformed length can - // only come from a corrupted changeset; skip + warn so - // one bad row doesn't sink the flush. - if !shielded_cs.viewing_keys.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_viewing_keys_fn { - let entries: Vec = shielded_cs - .viewing_keys - .iter() - .filter_map(|(id, fvk)| { - let fvk_bytes: [u8; 96] = match fvk.as_slice().try_into() { - Ok(b) => b, - Err(_) => { - tracing::warn!( - fvk_len = fvk.len(), - "skipping viewing-key persist row: \ - FVK is not the expected 96 bytes" - ); - return None; - } - }; - Some(ShieldedViewingKeyFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - fvk_bytes, - }) - }) - .collect(); + // Send invitation changeset — DIP-13 sent-invitation records, one + // upsert row per funded voucher (keyed by outpoint) plus outpoint + // tombstones. All-POD entries, so no owned-buffer storage to pin. + // Maps onto Swift's `PersistentInvitation` rows. + if let Some(ref inv_cs) = changeset.invitations { + if let Some(cb) = self.callbacks.on_persist_invitations_fn { + let upsert_refs: Vec<&platform_wallet::changeset::InvitationEntry> = + inv_cs.invitations.values().collect(); + let upserts = build_invitation_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = inv_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), ) }; + drop(upserts); if result != 0 { eprintln!( - "Shielded viewing-key persistence callback returned error code {}", + "Invitation persistence callback returned error code {}", result ); outcome.record(result); } } } + } - // 6) activity entries (derived activity log). The variable- - // length fields (counterparty / memo / cmx + nullifier - // arrays) borrow into `backing`, a Vec of owned byte - // buffers that outlives the callback — same pointer-validity - // discipline as `note_data_ptr` / `memo_ptr` above. The - // host upserts by `(wallet_id, account_index, entry_id)`. - if !shielded_cs.activity_entries.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_activity_fn { - // One pass pairs each entry with its owned cmx / - // nullifier buffers STRUCTURALLY (same tuple), so the - // pointer-into-backing invariant can't silently - // mis-pair if either side is ever filtered or - // reordered. The buffers live in `rows` (immutable - // once built) for the whole callback window; an inner - // `Vec`'s heap allocation is stable even as the - // outer Vec grows. - let rows: Vec<( - &platform_wallet::wallet::shielded::SubwalletId, - &platform_wallet::wallet::shielded::ShieldedActivityEntry, - Vec, - Vec, - )> = shielded_cs - .activity_entries - .iter() - .flat_map(|(id, entries)| entries.iter().map(move |e| (id, e))) - .map(|(id, e)| { - let mut cmx_buf = Vec::with_capacity(e.note_cmxs.len() * 32); - for c in &e.note_cmxs { - cmx_buf.extend_from_slice(c); - } - let mut nf_buf = Vec::with_capacity(e.spent_nullifiers.len() * 32); - for n in &e.spent_nullifiers { - nf_buf.extend_from_slice(n); - } - (id, e, cmx_buf, nf_buf) - }) - .collect(); - let entries: Vec = rows - .iter() - .map(|(id, e, cmx_buf, nf_buf)| { - let (identity_id, has_identity_id) = match &e.kind { - platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityCreate { - identity_id, - } - | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { - identity_id, - } - | platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityTopUp { - identity_id, - } => (*identity_id, 1u8), - _ => ([0u8; 32], 0u8), - }; - let (counterparty_ptr, counterparty_len) = match &e.counterparty { - Some(c) if !c.is_empty() => (c.as_ptr(), c.len()), - _ => (std::ptr::null(), 0), - }; - let (memo_ptr, memo_len) = match &e.memo { - Some(m) if !m.is_empty() => (m.as_ptr(), m.len()), - _ => (std::ptr::null(), 0), - }; - ShieldedActivityFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - entry_id: e.id, - kind_tag: e.kind.tag(), - direction: activity_direction_tag(&e.direction), - status: activity_status_tag(&e.status), - amount: e.amount, - fee: e.fee.unwrap_or(0), - has_fee: u8::from(e.fee.is_some()), - block_height: e.block_height.unwrap_or(0), - has_block_height: u8::from(e.block_height.is_some()), - created_at_ms: e.created_at_ms, - min_note_position: e.min_note_position.unwrap_or(0), - has_min_note_position: u8::from(e.min_note_position.is_some()), - identity_id, - has_identity_id, - counterparty_ptr, - counterparty_len, - memo_ptr, - memo_len, - // Match the documented "valid or null" - // contract (and the counterparty/memo - // siblings): an empty Vec's `as_ptr()` is - // a dangling non-null sentinel, so emit a - // real null when there's nothing to point - // at. - note_cmxs_ptr: if cmx_buf.is_empty() { - std::ptr::null() - } else { - cmx_buf.as_ptr() - }, - note_cmxs_count: cmx_buf.len() / 32, - spent_nullifiers_ptr: if nf_buf.is_empty() { - std::ptr::null() - } else { - nf_buf.as_ptr() - }, - spent_nullifiers_count: nf_buf.len() / 32, - } - }) - .collect(); + // Send the DPNS username-marketplace changeset — one upsert row + // per tracked `domain` document (keyed by document id) plus + // document-id tombstones. Maps onto the host's DPNS-name rows, + // whose marketplace columns (price, sale status, counterparty) + // these rows own. + // + // Fires AFTER the identities callback so a brand-new identity's + // row is already staged in the same round when the host resolves + // a marketplace row's owning identity. + if let Some(ref dpns_cs) = changeset.dpns_name_states { + if let Some(cb) = self.dpns_name_states_callback { + let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = + dpns_cs.names.values().collect(); + let mut upserts = build_dpns_name_state_entries(&upsert_refs); + let removed: Vec<[u8; 32]> = + dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); + if !upserts.is_empty() || !removed.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), ) }; + // Release the per-row label strings on EVERY path, + // including the callback-reported-failure one, before + // the Vec drops its storage. + unsafe { free_dpns_name_state_entries(&mut upserts) }; if result != 0 { eprintln!( - "Shielded activity persistence callback returned error code {}", + "DPNS name state persistence callback returned error code {}", result ); outcome.record(result); } - // `rows` and `entries` drop here, after the callback - // has copied everything it needs. - drop(rows); } } } - // Close the round. Clients use this to commit (if the round - // succeeded) or roll back (otherwise) the - // staged writes accumulated across the per-kind callbacks - // above, making the whole store() call a single atomic - // transaction from their perspective. - if let Some(cb) = self.callbacks.on_changeset_end_fn { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - outcome.is_success(), - ) - }; - if result != 0 { - eprintln!("Changeset-end callback returned error code {}", result); - // Either way `store()` must return `Err`, so the caller does - // NOT advance / clear its in-memory state (pending queues, - // cleared drain entries, ignored-sender deltas) against data - // that never reached durable storage. What differs is what the - // caller may then do about it. - if outcome.is_success() { - // A clean round: `end` is the COMMIT (the SwiftData atomic - // `save()`), and its failure applied nothing. The host's - // classification of that is legitimate. - outcome.record(result); - } else { - // A failing round: `end` fired with `success = false` to - // drive the ROLLBACK, and it failed. The round's - // disposition is now unknown, whatever the host classifies - // it as — and unknown is never retryable, because a - // re-issued changeset merges by appending. - outcome.record_fatal(); + // Send DashPay contact-request changeset. + // + // The flat upsert array is built by walking every source + // bucket on the changeset: + // - `sent_requests` ⇒ one outgoing row per entry + // - `incoming_requests` ⇒ one incoming row per entry + // - `established` ⇒ two rows per entry (the underlying + // outgoing + incoming `ContactRequest` on + // `EstablishedContact`) so the Swift uniqueness key + // `(network, owner, contact, is_outgoing)` upserts both + // directions cleanly. The auto-establishment contract on + // the Rust side drops any matching `sent_requests` / + // `incoming_requests` entry when promoting to established, + // so this projection never produces a duplicate row in a + // single round. + // + // Removal arrays mirror the changeset's two tombstone fields + // 1:1 — Swift deletes rows by `(owner, contact, is_outgoing)` + // with the direction implied by which bucket they came from. + if let Some(ref contacts_cs) = changeset.contacts { + if let Some(cb) = self.callbacks.on_persist_contacts_fn { + let mut upserts: Vec = Vec::with_capacity( + contacts_cs.sent_requests.len() + + contacts_cs.incoming_requests.len() + + contacts_cs.established.len() * 2, + ); + for (key, entry) in &contacts_cs.sent_requests { + upserts.push(ContactRequestFFI::from_outgoing( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &entry.request, + )); } - } - } - - // Close the round: its `end` callback has fired (committing or - // rolling back the client transaction), or there was no end - // callback wired. Clear the state-machine flag now — BEFORE any - // early return below — so a rejected round doesn't wedge the - // persister into permanent "round already open" rejection on the - // next `store()`. (`end_round` only errors on an unmatched end, - // which cannot happen here since `begin_round` succeeded above.) - round.end_round()?; - - if let Some(kind) = outcome.failure_kind() { - return Err(PersistenceError::backend_with_kind( - self.reportable_round_kind(kind), - "one or more persistence callbacks failed; changeset was rolled back", - )); - } - - // Preserve the legacy notification phase. With an end callback, the - // host transaction is already committed and a notification failure is - // advisory. Without that atomic boundary, preserve the established - // `store()` error contract for legacy hosts that use this callback as - // their durable-write boundary. - if let Some(cb) = self.callbacks.on_store_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - if self.callbacks.on_changeset_end_fn.is_some() { - eprintln!( - "Persistence store callback returned post-commit error code {result}; \ - ignored" - ); - } else { - // No end callback, so the per-kind writes already landed - // individually and the round is not all-or-nothing — - // `reportable_round_kind` withholds a retryable verdict. - return Err(PersistenceError::backend_with_kind( - self.reportable_round_kind(persist_rc_kind(result)), - format!("Persistence store callback returned error code {result}"), + for (key, entry) in &contacts_cs.incoming_requests { + upserts.push(ContactRequestFFI::from_incoming( + key.owner_id.to_buffer(), + key.sender_id.to_buffer(), + &entry.request, )); } - } - } - - Ok(()) - } - - fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { - // TODO: deferred — trailing-byte validation on decoded FFI - // payloads is not yet applied here. - // Notify caller. - if let Some(cb) = self.callbacks.on_flush_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - return Err(persist_callback_error( - result, - format!("Persistence flush callback returned error code {}", result), - )); - } - } - - Ok(()) - } - - fn load(&self) -> Result { - // Host loaders may read the same context that store() stages into. - // Keep base rows and every sidecar in one committed snapshot: a load - // between per-kind callbacks and end must never expose a Consumed row - // whose commit can still fail. Callbacks must not re-enter this API. - let round = self.round_lock.lock(); - if round.in_round { - return Err(PersistenceError::backend( - "FFIPersister: cannot restore from an unclosed changeset round", - )); - } - // If Swift hasn't wired up `on_load_wallet_list_fn` there's - // nothing to restore — treat as a fresh client. - let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { - return Ok(ClientStartState::default()); - }; - - // Swift allocates the entries array (and every nested byte - // buffer); we read it under a `Guard` that calls the matching - // free callback on drop, so failures inside the loop don't - // leak. - let mut entries_ptr: *const WalletRestoreEntryFFI = std::ptr::null(); - let mut count: usize = 0; - let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_wallet_list_fn returned error code {}", rc), - )); - } - let _guard = LoadGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_wallet_list_free_fn, - entries: entries_ptr, - count, - }; - - let mut out = ClientStartState::default(); - if entries_ptr.is_null() && count != 0 { - return Err(PersistenceError::backend( - "wallet restore: missing row buffer", - )); - } - if count == 0 { - return Ok(out); + for (key, established) in &contacts_cs.established { + // Replicate the relationship's broken-channel flag + // and owner-private metadata (alias/note/hidden — + // contactInfo, M3) onto BOTH the outgoing and + // incoming row — they are properties of the + // established pair, not of one direction, so the + // Swift handler persists them on each + // `(owner, contact, is_outgoing)` row. + upserts.push(ContactRequestFFI::from_established_outgoing( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &established.outgoing_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, + &established.accepted_accounts, + established.external_account_reference, + )); + upserts.push(ContactRequestFFI::from_established_incoming( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &established.incoming_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, + // Direction-specific: the contact's account label + // rides only the incoming row. + established.contact_account_label.as_deref(), + &established.accepted_accounts, + established.external_account_reference, + )); + } + let removed_sent: Vec = contacts_cs + .removed_sent + .iter() + .map(|key| ContactRequestRemovalFFI { + owner_id: key.owner_id.to_buffer(), + contact_id: key.recipient_id.to_buffer(), + }) + .collect(); + let removed_incoming: Vec = contacts_cs + .removed_incoming + .iter() + .map(|key| ContactRequestRemovalFFI { + owner_id: key.owner_id.to_buffer(), + contact_id: key.sender_id.to_buffer(), + }) + .collect(); + // Per-sender ignore deltas, keyed `(owner, sender)`. The + // `ignored` set projects to rows with `is_ignored == true` + // (persist the ignored-sender row); the `unignored` set to + // rows with `is_ignored == false` (delete it). Both ride a + // single array so the host applies a mixed delta in one + // callback. + let ignored: Vec = + contacts_cs + .ignored + .iter() + .map(|(owner, sender)| ContactIgnoredSenderFFI::new(owner, sender, true)) + .chain(contacts_cs.unignored.iter().map(|(owner, sender)| { + ContactIgnoredSenderFFI::new(owner, sender, false) + })) + .collect(); + if !upserts.is_empty() + || !removed_sent.is_empty() + || !removed_incoming.is_empty() + || !ignored.is_empty() + { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed_sent.is_empty() { + std::ptr::null() + } else { + removed_sent.as_ptr() + }, + removed_sent.len(), + if removed_incoming.is_empty() { + std::ptr::null() + } else { + removed_incoming.as_ptr() + }, + removed_incoming.len(), + if ignored.is_empty() { + std::ptr::null() + } else { + ignored.as_ptr() + }, + ignored.len(), + ) + }; + // Release every heap-allocated payload before the + // outer Vec drops its storage. + if !upserts.is_empty() { + unsafe { free_contact_requests_ffi(upserts.as_mut_ptr(), upserts.len()) }; + } + if result != 0 { + eprintln!( + "Contact persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } } - // SAFETY: Swift guarantees `entries_ptr` points to `count` - // contiguous `WalletRestoreEntryFFI` values for the lifetime - // of the callback window. `_guard` ensures the free callback - // fires before we leave this function. - let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; - for entry in entries { - if out.wallets.contains_key(&entry.wallet_id) { - return Err(PersistenceError::backend( - "wallet restore: duplicate wallet ID", - )); - } - let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; - if let Some(cb) = self - .tracked_masternodes_callbacks - .load_asset_lock_operation_id - { - load_asset_lock_operation_ids( - cb, - self.callbacks.context, - &entry.wallet_id, - &mut wallet_state.unused_asset_locks, - )?; - } - if let Some(cb) = self.load_identity_balance_block_time_callback { - for identities in wallet_state.identity_manager.wallet_identities.values_mut() { - for managed in identities.values_mut() { - let mut found = false; - let mut stamp = crate::types::BlockTime { - height: 0, - core_height: 0, - timestamp: 0, - }; - let rc = unsafe { - cb( - self.callbacks.context, - entry.wallet_id.as_ptr(), - managed.id().as_bytes().as_ptr(), - &mut found, - &mut stamp, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - "Loading identity balance block time failed".to_string(), - )); - } - managed.last_updated_balance_block_time = found.then(|| stamp.into()); + // Send sync state updates. + if let Some(ref addr_cs) = changeset.platform_addresses { + if let Some(cb) = self.callbacks.on_persist_sync_state_fn { + let height = addr_cs.sync_height.unwrap_or(0); + let timestamp = addr_cs.sync_timestamp.unwrap_or(0); + let recent = addr_cs.last_known_recent_block.unwrap_or(0); + if height > 0 || timestamp > 0 || recent > 0 { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + height, + timestamp, + recent, + ) + }; + if result != 0 { + eprintln!( + "Sync state persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - out.wallets.insert(entry.wallet_id, wallet_state); - if let Some(platform_address_state) = platform_address_state { - out.platform_addresses - .insert(entry.wallet_id, platform_address_state); - } } - // Restore shielded sub-wallet state if the host has wired - // up the optional callbacks. Notes and per-subwallet sync - // states travel separately so the host can populate them - // from independent SwiftData fetch descriptors. Both arms - // walk the same `(wallet_id, account_index)` key space and - // funnel into a single `SubwalletId` map on - // `ClientStartState.shielded`. + // Shielded changeset (Orchard): four flat callback batches + // mirroring the four `ShieldedChangeSet` fields. Notes + // first so a follow-up `mark_spent` for the same nullifier + // upserts onto an existing row instead of falling on + // missing-row floor. #[cfg(feature = "shielded")] - { + if let Some(ref shielded_cs) = changeset.shielded { use crate::shielded_persistence::*; - use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; - use platform_wallet::wallet::shielded::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, ShieldedNote, - ShieldedOutgoingNote, SubwalletId, - }; - - let mut shielded_state = ShieldedSyncStartState::default(); - - // Fail fast on a half-wired callback pair: a loader without - // its matching free callback leaks the host-allocated buffer - // on every successful load (the guard's `Drop` is a no-op - // when `free_fn` is `None`). - if self.callbacks.on_load_shielded_notes_fn.is_some() - != self.callbacks.on_load_shielded_notes_free_fn.is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ - provided together", - )); - } - if self.callbacks.on_load_shielded_sync_states_fn.is_some() - != self - .callbacks - .on_load_shielded_sync_states_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ - must be provided together", - )); - } - if self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() - != self - .callbacks - .on_load_shielded_outgoing_notes_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_outgoing_notes_fn and \ - on_load_shielded_outgoing_notes_free_fn must be provided together", - )); - } - // 1) notes - if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { - let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); - let mut notes_count: usize = 0; - let rc = - unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_notes_fn returned error code {}", rc), - )); - } - struct NotesGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedNoteRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedNoteRestoreFFI, - count: usize, - } - impl Drop for NotesGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } - } - } - let _notes_guard = NotesGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_notes_free_fn, - entries: notes_ptr, - count: notes_count, - }; - if !notes_ptr.is_null() && notes_count > 0 { - let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; - for ffi in slice { - if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { - continue; - } - let note_data = unsafe { - std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) - .to_vec() - }; - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.notes.push(ShieldedNote { - position: ffi.position, - cmx: ffi.cmx, - nullifier: ffi.nullifier, - block_height: ffi.block_height, - is_spent: ffi.is_spent != 0, - value: ffi.value, - note_data, - }); + // 1) notes_saved + if !shielded_cs.notes_saved.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { + // Flatten the per-subwallet map into a single + // contiguous Vec so the callback gets one + // `entries: *const ShieldedNoteFFI` slice. The + // host copies `note_data` bytes during the call. + let entries: Vec = shielded_cs + .notes_saved + .iter() + .flat_map(|(id, notes)| { + notes.iter().map(|n| ShieldedNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + position: n.position, + cmx: n.cmx, + nullifier: n.nullifier, + block_height: n.block_height, + is_spent: u8::from(n.is_spent), + value: n.value, + note_data_ptr: n.note_data.as_ptr(), + note_data_len: n.note_data.len(), + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded notes persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - // 2) outgoing (sent) notes recovered via OVK - if let Some(load_outgoing) = self.callbacks.on_load_shielded_outgoing_notes_fn { - let mut out_ptr: *const ShieldedOutgoingNoteRestoreFFI = std::ptr::null(); - let mut out_count: usize = 0; - let rc = - unsafe { load_outgoing(self.callbacks.context, &mut out_ptr, &mut out_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!( - "on_load_shielded_outgoing_notes_fn returned error code {}", - rc - ), - )); - } - struct OutgoingGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedOutgoingNoteRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedOutgoingNoteRestoreFFI, - count: usize, - } - impl Drop for OutgoingGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + // 2) nullifiers_spent + if !shielded_cs.nullifiers_spent.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { + let entries: Vec = shielded_cs + .nullifiers_spent + .iter() + .flat_map(|(id, nfs)| { + nfs.iter().map(|nf| ShieldedNullifierSpentFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + nullifier: *nf, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-spent persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _outgoing_guard = OutgoingGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_outgoing_notes_free_fn, - entries: out_ptr, - count: out_count, - }; - if !out_ptr.is_null() && out_count > 0 { - let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; - for ffi in slice { - let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { - Vec::new() - } else { - unsafe { - std::slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() - } - }; - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.outgoing_notes.push(ShieldedOutgoingNote { - cmx: ffi.cmx, - recipient: ffi.recipient.to_vec(), - value: ffi.value, - memo, - block_height: ffi.block_height, - }); + } + + // 3) outgoing_notes (OVK-recovered send history). Each + // `memo_ptr` borrows into the changeset's owned `memo` + // Vec, which lives for the whole `store()` call, so the + // pointer stays valid for the callback window (same + // discipline as `note_data_ptr` above). + if !shielded_cs.outgoing_notes.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_outgoing_notes_fn { + let entries: Vec = shielded_cs + .outgoing_notes + .iter() + .flat_map(|(id, notes)| { + notes.iter().filter_map(|n| { + // `recipient` is a 43-byte raw Orchard address + // stored as a `Vec` (serde-derive only covers + // arrays <= 32). It is always exactly 43 bytes + // from OVK recovery; reject (skip + warn) a + // malformed row rather than silently zero-padding + // it into a wrong address. + let recipient: [u8; 43] = match n.recipient.as_slice().try_into() { + Ok(r) => r, + Err(_) => { + tracing::warn!( + recipient_len = n.recipient.len(), + "skipping outgoing-note persist row: \ + recipient is not the expected 43 bytes" + ); + return None; + } + }; + Some(ShieldedOutgoingNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + cmx: n.cmx, + recipient, + value: n.value, + block_height: n.block_height, + memo_ptr: n.memo.as_ptr(), + memo_len: n.memo.len(), + }) + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded outgoing-notes persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - // 3) per-subwallet sync states - if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { - let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); - let mut states_count: usize = 0; - let rc = unsafe { - load_states(self.callbacks.context, &mut states_ptr, &mut states_count) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_sync_states_fn returned error code {}", rc), - )); - } - struct StatesGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedSubwalletSyncStateFFI, - count: usize, - ), - >, - entries: *const ShieldedSubwalletSyncStateFFI, - count: usize, - } - impl Drop for StatesGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + // 4) synced_indices + if !shielded_cs.synced_indices.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { + let entries: Vec = shielded_cs + .synced_indices + .iter() + .map(|(id, &idx)| ShieldedSyncedIndexFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + last_synced_index: idx, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded synced-index persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _states_guard = StatesGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, - entries: states_ptr, - count: states_count, - }; - if !states_ptr.is_null() && states_count > 0 { - let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; - for ffi in slice { - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.last_synced_index = ffi.last_synced_index; - entry.has_sync_state = true; + } + + // 5) viewing keys (raw 96-byte FVK encodings). Fixed-size + // rows, no borrowed pointers. A malformed length can + // only come from a corrupted changeset; skip + warn so + // one bad row doesn't sink the flush. + if !shielded_cs.viewing_keys.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_viewing_keys_fn { + let entries: Vec = shielded_cs + .viewing_keys + .iter() + .filter_map(|(id, fvk)| { + let fvk_bytes: [u8; 96] = match fvk.as_slice().try_into() { + Ok(b) => b, + Err(_) => { + tracing::warn!( + fvk_len = fvk.len(), + "skipping viewing-key persist row: \ + FVK is not the expected 96 bytes" + ); + return None; + } + }; + Some(ShieldedViewingKeyFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + fvk_bytes, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded viewing-key persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - // 4) derived activity entries - if self.callbacks.on_load_shielded_activity_fn.is_some() - != self.callbacks.on_load_shielded_activity_free_fn.is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_activity_fn and on_load_shielded_activity_free_fn must be \ - provided together", - )); - } - if let Some(load_activity) = self.callbacks.on_load_shielded_activity_fn { - let mut act_ptr: *const ShieldedActivityRestoreFFI = std::ptr::null(); - let mut act_count: usize = 0; - let rc = - unsafe { load_activity(self.callbacks.context, &mut act_ptr, &mut act_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_activity_fn returned error code {}", rc), - )); - } - struct ActivityGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedActivityRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedActivityRestoreFFI, - count: usize, - } - impl Drop for ActivityGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } - } - } - let _activity_guard = ActivityGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_activity_free_fn, - entries: act_ptr, - count: act_count, - }; - if !act_ptr.is_null() && act_count > 0 { - let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; - for ffi in slice { - let kind = match ffi.kind_tag { - 0 => ShieldedActivityKind::Shield, - 1 => ShieldedActivityKind::ShieldFromAssetLock, - 2 => ShieldedActivityKind::Received, - 3 => ShieldedActivityKind::Sent, - 4 => ShieldedActivityKind::Unshield, - 5 => ShieldedActivityKind::Withdrawal, - 6 => ShieldedActivityKind::IdentityCreate { - identity_id: ffi.identity_id, - }, - 8 => ShieldedActivityKind::ShieldFromIdentity { - identity_id: ffi.identity_id, - }, - 9 => ShieldedActivityKind::IdentityTopUp { - identity_id: ffi.identity_id, - }, - // 7 and any unknown tag fall back to the - // residual — a forward-compat tag we don't yet - // model still loads as an opaque spend rather - // than getting dropped. - _ => ShieldedActivityKind::ShieldedSpend, - }; - let direction = match ffi.direction { - 0 => ShieldedDirection::In, - 1 => ShieldedDirection::Out, - 2 => ShieldedDirection::SelfTransfer, - other => { - // Unlike kind_tag (whose residual - // `ShieldedSpend` variant is a designed - // forward-compat catch-all), direction has - // no "unknown" bucket — make a corrupted / - // future byte loud instead of silently - // reading as a real classification. - tracing::warn!( - direction = other, - "unknown shielded-activity direction byte on load; folding to SelfTransfer" - ); - ShieldedDirection::SelfTransfer + // 6) activity entries (derived activity log). The variable- + // length fields (counterparty / memo / cmx + nullifier + // arrays) borrow into `backing`, a Vec of owned byte + // buffers that outlives the callback — same pointer-validity + // discipline as `note_data_ptr` / `memo_ptr` above. The + // host upserts by `(wallet_id, account_index, entry_id)`. + if !shielded_cs.activity_entries.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_activity_fn { + // One pass pairs each entry with its owned cmx / + // nullifier buffers STRUCTURALLY (same tuple), so the + // pointer-into-backing invariant can't silently + // mis-pair if either side is ever filtered or + // reordered. The buffers live in `rows` (immutable + // once built) for the whole callback window; an inner + // `Vec`'s heap allocation is stable even as the + // outer Vec grows. + let rows: Vec<( + &platform_wallet::wallet::shielded::SubwalletId, + &platform_wallet::wallet::shielded::ShieldedActivityEntry, + Vec, + Vec, + )> = shielded_cs + .activity_entries + .iter() + .flat_map(|(id, entries)| entries.iter().map(move |e| (id, e))) + .map(|(id, e)| { + let mut cmx_buf = Vec::with_capacity(e.note_cmxs.len() * 32); + for c in &e.note_cmxs { + cmx_buf.extend_from_slice(c); } - }; - let status = activity_status_from_tag(ffi.status); - let counterparty = if ffi.counterparty_ptr.is_null() - || ffi.counterparty_len == 0 - { - None - } else { - Some(unsafe { - slice::from_raw_parts(ffi.counterparty_ptr, ffi.counterparty_len) - .to_vec() - }) - }; - let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { - None - } else { - Some(unsafe { - slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() - }) - }; - let note_cmxs = - unsafe { decode_cmx_array(ffi.note_cmxs_ptr, ffi.note_cmxs_count) }; - let spent_nullifiers = unsafe { - decode_cmx_array(ffi.spent_nullifiers_ptr, ffi.spent_nullifiers_count) - }; - - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.activity.push(ShieldedActivityEntry { - id: ffi.entry_id, - kind, - direction, - amount: ffi.amount, - fee: if ffi.has_fee != 0 { - Some(ffi.fee) - } else { - None - }, - counterparty, - memo, - block_height: if ffi.has_block_height != 0 { - Some(ffi.block_height) - } else { - None - }, - status, - created_at_ms: ffi.created_at_ms, - min_note_position: if ffi.has_min_note_position != 0 { - Some(ffi.min_note_position) - } else { - None - }, - note_cmxs, - spent_nullifiers, - }); + let mut nf_buf = Vec::with_capacity(e.spent_nullifiers.len() * 32); + for n in &e.spent_nullifiers { + nf_buf.extend_from_slice(n); + } + (id, e, cmx_buf, nf_buf) + }) + .collect(); + let entries: Vec = rows + .iter() + .map(|(id, e, cmx_buf, nf_buf)| { + let (identity_id, has_identity_id) = match &e.kind { + platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityCreate { + identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { + identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityTopUp { + identity_id, + } => (*identity_id, 1u8), + _ => ([0u8; 32], 0u8), + }; + let (counterparty_ptr, counterparty_len) = match &e.counterparty { + Some(c) if !c.is_empty() => (c.as_ptr(), c.len()), + _ => (std::ptr::null(), 0), + }; + let (memo_ptr, memo_len) = match &e.memo { + Some(m) if !m.is_empty() => (m.as_ptr(), m.len()), + _ => (std::ptr::null(), 0), + }; + ShieldedActivityFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + entry_id: e.id, + kind_tag: e.kind.tag(), + direction: activity_direction_tag(&e.direction), + status: activity_status_tag(&e.status), + amount: e.amount, + fee: e.fee.unwrap_or(0), + has_fee: u8::from(e.fee.is_some()), + block_height: e.block_height.unwrap_or(0), + has_block_height: u8::from(e.block_height.is_some()), + created_at_ms: e.created_at_ms, + min_note_position: e.min_note_position.unwrap_or(0), + has_min_note_position: u8::from(e.min_note_position.is_some()), + identity_id, + has_identity_id, + counterparty_ptr, + counterparty_len, + memo_ptr, + memo_len, + // Match the documented "valid or null" + // contract (and the counterparty/memo + // siblings): an empty Vec's `as_ptr()` is + // a dangling non-null sentinel, so emit a + // real null when there's nothing to point + // at. + note_cmxs_ptr: if cmx_buf.is_empty() { + std::ptr::null() + } else { + cmx_buf.as_ptr() + }, + note_cmxs_count: cmx_buf.len() / 32, + spent_nullifiers_ptr: if nf_buf.is_empty() { + std::ptr::null() + } else { + nf_buf.as_ptr() + }, + spent_nullifiers_count: nf_buf.len() / 32, + } + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded activity persistence callback returned error code {}", + result + ); + outcome.record(result); } + // `rows` and `entries` drop here, after the callback + // has copied everything it needs. + drop(rows); } } + } - // 5) persisted Orchard viewing keys (raw 96-byte FVK - // encodings), consumed by - // `PlatformWallet::bind_shielded_from_persisted` so a - // launch-time rebind needs no mnemonic resolve. - if self.callbacks.on_load_shielded_viewing_keys_fn.is_some() - != self - .callbacks - .on_load_shielded_viewing_keys_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_viewing_keys_fn and \ - on_load_shielded_viewing_keys_free_fn must be provided together", - )); + // Close the round. Clients use this to commit (if the round + // succeeded) or roll back (otherwise) the + // staged writes accumulated across the per-kind callbacks + // above, making the whole store() call a single atomic + // transaction from their perspective. + if let Some(cb) = self.callbacks.on_changeset_end_fn { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + eprintln!("Changeset-end callback returned error code {}", result); + // Either way `store()` must return `Err`, so the caller does + // NOT advance / clear its in-memory state (pending queues, + // cleared drain entries, ignored-sender deltas) against data + // that never reached durable storage. What differs is what the + // caller may then do about it. + if outcome.is_success() { + // A clean round: `end` is the COMMIT (the SwiftData atomic + // `save()`), and its failure applied nothing. The host's + // classification of that is legitimate. + outcome.record(result); + } else { + // A failing round: `end` fired with `success = false` to + // drive the ROLLBACK, and it failed. The round's + // disposition is now unknown, whatever the host classifies + // it as — and unknown is never retryable, because a + // re-issued changeset merges by appending. + outcome.record_fatal(); + } } - if let Some(load_viewing_keys) = self.callbacks.on_load_shielded_viewing_keys_fn { - let mut vk_ptr: *const ShieldedViewingKeyRestoreFFI = std::ptr::null(); - let mut vk_count: usize = 0; - let rc = unsafe { - load_viewing_keys(self.callbacks.context, &mut vk_ptr, &mut vk_count) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!( - "on_load_shielded_viewing_keys_fn returned error code {}", - rc - ), + } + + // Close the round: its `end` callback has fired (committing or + // rolling back the client transaction), or there was no end + // callback wired. Clear the state-machine flag now — BEFORE any + // early return below — so a rejected round doesn't wedge the + // persister into permanent "round already open" rejection on the + // next `store()`. (`end_round` only errors on an unmatched end, + // which cannot happen here since `begin_round` succeeded above.) + round.end_round()?; + + if let Some(kind) = outcome.failure_kind() { + return Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(kind), + "one or more persistence callbacks failed; changeset was rolled back", + )); + } + + // Preserve the legacy notification phase. With an end callback, the + // host transaction is already committed and a notification failure is + // advisory. Without that atomic boundary, preserve the established + // `store()` error contract for legacy hosts that use this callback as + // their durable-write boundary. + if let Some(cb) = self.callbacks.on_store_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + if self.callbacks.on_changeset_end_fn.is_some() { + eprintln!( + "Persistence store callback returned post-commit error code {result}; \ + ignored" + ); + } else { + // No end callback, so the per-kind writes already landed + // individually and the round is not all-or-nothing — + // `reportable_round_kind` withholds a retryable verdict. + return Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(persist_rc_kind(result)), + format!("Persistence store callback returned error code {result}"), )); } - struct ViewingKeysGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedViewingKeyRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedViewingKeyRestoreFFI, - count: usize, - } - impl Drop for ViewingKeysGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } - } - } - let _viewing_keys_guard = ViewingKeysGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_viewing_keys_free_fn, - entries: vk_ptr, - count: vk_count, - }; - if !vk_ptr.is_null() && vk_count > 0 { - let slice = unsafe { slice::from_raw_parts(vk_ptr, vk_count) }; - for ffi in slice { - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - shielded_state - .viewing_keys - .insert(id, ffi.fvk_bytes.to_vec()); - } - } } + } - out.shielded = shielded_state; + Ok(()) + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + // TODO: deferred — trailing-byte validation on decoded FFI + // payloads is not yet applied here. + // Notify caller. + if let Some(cb) = self.callbacks.on_flush_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + return Err(persist_callback_error( + result, + format!("Persistence flush callback returned error code {}", result), + )); + } } - Ok(out) + Ok(()) + } + + fn load(&self) -> Result { + let round = self.round_lock.lock(); + if round.in_round { + return Err(PersistenceError::backend( + "FFIPersister: cannot restore from an unclosed changeset round", + )); + } + self.load_snapshot_unlocked(None) + } + + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, + ) -> Result { + self.load_wallet_replay_scope(wallet_id, evidence) + } + + fn load_with_replay( + &self, + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let snapshot = self.load_read_snapshot()?; + let mut loaded = platform_wallet::manager::history_replay::HistoryReplayLoad::default(); + #[cfg(feature = "shielded")] + { + loaded.state.shielded = snapshot.shielded; + } + for wallet_id in snapshot.wallets.keys() { + let one = self.load_wallet_replay_scope(*wallet_id, evidence)?; + loaded.state.wallets.extend(one.state.wallets); + loaded + .state + .platform_addresses + .extend(one.state.platform_addresses); + loaded.outcomes.extend(one.outcomes); + } + Ok(loaded) } /// Look up a transaction record by `txid` via the @@ -3992,115 +4635,72 @@ impl PlatformWalletPersistence for FFIPersister { /// blob that the persister doesn't currently store, so for an IS /// hit we surface `None` (treat as miss) and let the proof /// flow's existing SPV-event-driven wait path complete the - /// proof. - /// - /// Returns `Ok(None)` when the callback is unset, when the - /// callback reports `out_found = false`, when the callback - /// returns a non-zero result code (treated as a transient backend - /// failure per the trait contract — surfaced as `None` rather - /// than propagating), when the callback hands back a null / - /// empty tx-bytes buffer, when the bytes don't decode as a - /// `dashcore::Transaction`, or for an IS hit (see above). - fn get_core_tx_record( - &self, - wallet_id: WalletId, - txid: &dashcore::Txid, - ) -> Result< - Option, - PersistenceError, - > { - use dashcore::consensus::Decodable; - use key_wallet::account::{AccountType, StandardAccountType}; - use key_wallet::managed_account::transaction_record::{ - TransactionDirection, TransactionRecord, - }; - use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; - - let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { - return Ok(None); - }; - - let txid_bytes: [u8; 32] = *txid.as_byte_array(); - let mut context_kind: u8 = 0; - let mut block_height: u32 = 0; - let mut block_hash: [u8; 32] = [0u8; 32]; - let mut block_timestamp: u32 = 0; - let mut tx_bytes_ptr: *const u8 = std::ptr::null(); - let mut tx_bytes_len: usize = 0; - let mut found: bool = false; - - // SAFETY: All output pointers reference Rust-owned stack - // locals that outlive the callback invocation. `wallet_id` - // and `txid` are fixed-size byte arrays. - let rc = unsafe { - get_cb( - self.callbacks.context, - wallet_id.as_ptr(), - txid_bytes.as_ptr(), - &mut context_kind, - &mut block_height, - block_hash.as_mut_ptr(), - &mut block_timestamp, - &mut tx_bytes_ptr, - &mut tx_bytes_len, - &mut found, - ) + /// proof. + /// + /// Returns `Ok(None)` when the callback is unset, when the + /// callback reports `out_found = false`, when the callback + /// returns a non-zero result code (treated as a transient backend + /// failure per the trait contract — surfaced as `None` rather + /// than propagating), when the callback hands back a null / + /// empty tx-bytes buffer, when the bytes don't decode as a + /// `dashcore::Transaction`, or for an IS hit (see above). + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; - // RAII guard so the tx-bytes free callback fires on every - // exit path past this point — early returns for unknown - // context kinds, decode failures, and the IS-skip case all - // correctly hand the buffer back to Swift. - struct TxBytesGuard<'a> { - ptr: *const u8, - len: usize, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - tx_bytes: *const u8, - tx_bytes_len: usize, - ), - >, - ctx: *mut c_void, - _marker: std::marker::PhantomData<&'a ()>, - } - impl<'a> Drop for TxBytesGuard<'a> { - fn drop(&mut self) { - if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { - // SAFETY: ptr+len match the values the lookup - // callback wrote; Swift owns the allocation - // until this free fires. - unsafe { free(self.ctx, self.ptr, self.len) }; + let (context_kind, block_height, block_hash, block_timestamp, transaction) = + match self.read_core_tx(wallet_id, txid) { + CoreTxRead::Missing => return Ok(None), + CoreTxRead::Failed(rc) => { + // TODO(tx-record-read-sentinels): `on_get_core_tx_record_fn` + // collapses every non-zero return, sentinels included, into a + // miss, so the transient-vs-permanent read distinction is + // unreachable from FFI hosts. Deferred deliberately: converting it + // to `persist_callback_error` and letting + // `get_core_tx_record_or_transient_miss` do the collapsing is a + // behaviour change for every host on the current contract. + // `get_core_transaction` already reports it. + tracing::debug!( + txid = %txid, + rc, + "on_get_core_tx_record_fn returned a non-zero result; \ + treating as miss" + ); + return Ok(None); } - } - } - let _bytes_guard = TxBytesGuard { - ptr: tx_bytes_ptr, - len: tx_bytes_len, - free_fn: self.callbacks.on_get_core_tx_record_free_fn, - ctx: self.callbacks.context, - _marker: std::marker::PhantomData, - }; - - if rc != 0 { - // TODO(tx-record-read-sentinels): `on_get_core_tx_record_fn` - // collapses every non-zero return, sentinels included, into a - // miss, so the transient-vs-permanent read distinction is - // unreachable from FFI hosts. Deferred deliberately: converting it - // to `persist_callback_error` and letting - // `get_core_tx_record_or_transient_miss` do the collapsing is a - // behaviour change for every host on the current contract. - tracing::debug!( - txid = %txid, - rc, - "on_get_core_tx_record_fn returned a non-zero result; \ - treating as miss" - ); - return Ok(None); - } - if !found { - return Ok(None); - } + CoreTxRead::Unreadable(reason) => { + tracing::debug!( + txid = %txid, + reason, + "on_get_core_tx_record_fn reported a hit it could not hand \ + over; treating as miss" + ); + return Ok(None); + } + CoreTxRead::Found { + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + } => ( + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + ), + }; let context = match context_kind { 0 => TransactionContext::Mempool, @@ -4136,34 +4736,6 @@ impl PlatformWalletPersistence for FFIPersister { } }; - if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { - tracing::debug!( - txid = %txid, - "on_get_core_tx_record_fn reported a hit but no tx \ - bytes; treating as miss" - ); - return Ok(None); - } - // SAFETY: Swift guarantees `tx_bytes_ptr` points to - // `tx_bytes_len` valid bytes for the duration of the - // callback window — `_bytes_guard` keeps that window open - // until this function returns. - let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; - let transaction = match dashcore::blockdata::transaction::Transaction::consensus_decode( - &mut &tx_slice[..], - ) { - Ok(tx) => tx, - Err(err) => { - tracing::debug!( - txid = %txid, - error = %err, - "on_get_core_tx_record_fn returned undecodable \ - tx bytes; treating as miss" - ); - return Ok(None); - } - }; - Ok(Some(TransactionRecord { transaction, txid: *txid, @@ -4182,6 +4754,30 @@ impl PlatformWalletPersistence for FFIPersister { })) } + /// The body behind `on_get_core_tx_record_fn`, read strictly for the + /// accounting backfill: a hit in any context (InstantSend included) is + /// returned, and a host failure or a hit with no decodable body is an + /// error, so the backfill fails its round instead of reading a wallet + /// input as foreign. [`Self::get_core_tx_record`]'s miss policy is + /// unchanged. + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result, PersistenceError> { + match self.read_core_tx(wallet_id, txid) { + CoreTxRead::Missing => Ok(None), + CoreTxRead::Failed(rc) => Err(persist_callback_error( + rc, + format!("on_get_core_tx_record_fn returned error code {rc} for {txid}"), + )), + CoreTxRead::Unreadable(reason) => Err(PersistenceError::backend(format!( + "on_get_core_tx_record_fn reported {txid} but {reason}" + ))), + CoreTxRead::Found { transaction, .. } => Ok(Some(transaction)), + } + } + fn list_wallet_core_txids( &self, wallet_id: WalletId, @@ -5368,7 +5964,7 @@ impl Drop for LoadGuard { fn decode_unconfirmed_outgoing( entry: &WalletRestoreEntryFFI, ) -> Vec { - use dashcore::consensus::Decodable; + use dashcore::consensus::deserialize; let recs: &[UnconfirmedOutgoingTxRecordFFI] = if entry.unconfirmed_outgoing_tx_records.is_null() || entry.unconfirmed_outgoing_tx_records_count == 0 { @@ -5391,7 +5987,7 @@ fn decode_unconfirmed_outgoing( continue; } let bytes = unsafe { slice::from_raw_parts(rec.tx_bytes, rec.tx_bytes_len) }; - match dashcore::blockdata::transaction::Transaction::consensus_decode(&mut &bytes[..]) { + match deserialize::(bytes) { // The bytes must be the row they were selected from. The // replay runs through the ordinary state-update path, so a // stale or partially-written `transactionData` would apply a @@ -5459,6 +6055,121 @@ fn order_unconfirmed_outgoing( ordered } +/// Decode the stored transaction history the host handed back for replay. +/// +/// Fail-closed per record, never for the load: a record that does not decode, +/// does not hash to its row's txid, or carries an unknown context is dropped +/// with a counted warning. The replay applies each transaction through the +/// ordinary state-update path, so bytes that do not belong to their row would +/// move accounting for unrelated inputs and outputs. +/// +/// InstantSend observations nominate required evidence. Their provisional +/// mempool context is never replayed without the owner's verified lock gate. +fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistory { + use dashcore::consensus::deserialize; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + let recs: &[RecordedTransactionRestoreFFI] = + if entry.recorded_transactions.is_null() || entry.recorded_transactions_count == 0 { + &[] + } else { + unsafe { + slice::from_raw_parts( + entry.recorded_transactions, + entry.recorded_transactions_count, + ) + } + }; + let mut transactions = Vec::with_capacity(recs.len()); + let mut required_instant_locks = std::collections::BTreeSet::new(); + let mut dropped_decode = 0usize; + let mut dropped_identity = 0usize; + let mut dropped_context = 0usize; + for rec in recs { + let bytes = unsafe { slice_from_raw(rec.tx_bytes, rec.tx_bytes_len) }; + if bytes.is_empty() { + dropped_decode += 1; + continue; + } + let transaction = match deserialize::(bytes) { + Ok(tx) if *tx.txid().as_byte_array() == rec.txid => tx, + Ok(_) => { + dropped_identity += 1; + continue; + } + Err(_) => { + dropped_decode += 1; + continue; + } + }; + let context = match rec.context { + TX_CONTEXT_RAW_MEMPOOL => TransactionContext::Mempool, + TX_CONTEXT_RAW_INSTANT_SEND => { + required_instant_locks.insert(transaction.txid()); + TransactionContext::Mempool + } + ctx @ (TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK) => { + let mut info = BlockInfo::new( + rec.block_height, + dashcore::BlockHash::from_byte_array(rec.block_hash), + rec.block_timestamp, + ); + if rec.has_block_position { + info = info.with_position(rec.block_position); + } + if ctx == TX_CONTEXT_RAW_IN_BLOCK { + TransactionContext::InBlock(info) + } else { + TransactionContext::InChainLockedBlock(info) + } + } + _ => { + dropped_context += 1; + continue; + } + }; + let stored_direction = match rec.direction { + 0 => Some(TransactionDirection::Incoming), + 1 => Some(TransactionDirection::Outgoing), + 2 => Some(TransactionDirection::Internal), + 3 => Some(TransactionDirection::CoinJoin), + _ => None, + }; + transactions.push(StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: Some(rec.net_amount), + stored_direction, + // TODO(ffi-recorded-input-details): `RecordedTransactionRestoreFFI` + // carries no per-input ownership, so on the FFI/SwiftData path a + // spend whose funding survives only as a height row replays with no + // owned input. The replay still keeps a redelivered funding from + // re-crediting the coin of a chain-locked spend, but cannot + // attribute it: the sent amount is missing, and a spend paying + // nothing to the wallet gets no record. Needs the host to supply + // per-input ownership across the ABI. + owned_inputs: Vec::new(), + }); + } + if dropped_decode > 0 || dropped_identity > 0 || dropped_context > 0 { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + dropped_decode, + dropped_identity, + dropped_context, + "load: recorded transaction history rows were dropped" + ); + } + RecordedHistory { + required_instant_locks, + owned_prevouts: Default::default(), + transactions, + instant_locks: BTreeMap::new(), + } +} + /// Map a provider-account rebuild failure to a load error naming the /// curve-specific constructor or `AccountCollection` insert that failed. fn provider_rebuild_error( @@ -6176,6 +6887,9 @@ fn build_wallet_start_state( // it. let unconfirmed_outgoing_txs = decode_unconfirmed_outgoing(entry); + // Decode only, like the sends above: the shared load replays the history + // (`platform_wallet::manager::history_replay`) at the async boundary. + let recorded_history = decode_recorded_transactions(entry); let dashpay_backfill = decode_dashpay_backfill(entry); let wallet_state = ClientWalletStartState { @@ -6184,6 +6898,7 @@ fn build_wallet_start_state( identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + recorded_history, dashpay_backfill, }; @@ -9703,8 +10418,161 @@ mod tests { PlatformWalletChangeSet { core: Some(core), ..Default::default() - } - } + } + } + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + wallet_changeset_utxo_verdicts: Some(record_verdicts), + ..Default::default() + }, + ); + // A round with no verdict: the slot stays silent. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("verdict-less round must succeed"); + // A round carrying verdicts: they cross first, in outpoint order. + persister + .store([1u8; 32], verdict_round()) + .expect("verdict round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "verdict txid=ab vout=1 class=1 height=2402896".to_string(), + "verdict txid=cd vout=0 class=2 height=0".to_string(), + "verdict txid=ef vout=2 class=3 height=0".to_string(), + "changeset".to_string(), + ], + ); + drop(persister); + + // Host without the slot: the same round still succeeds. + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store([1u8; 32], verdict_round()) + .expect("slotless-host verdict round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + + /// Per-wallet accounting crosses right after the round's changeset, one + /// row per record that carries details. A detail-less record (an asset + /// lock's funding record rebuilt at load and re-emitted when a chain + /// lock promotes it) crosses as NO row, so the host keeps what it stored + /// instead of overwriting it with the placeholder 0. A host without the + /// slot still succeeds. + #[test] + fn store_delivers_transaction_accounting_after_the_changeset_skipping_detailless_records() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::TransactionType; + use platform_wallet::changeset::CoreChangeSet; + use platform_wallet::test_support::core_fixtures::{ + block, foreign_address, outside, pay, spend_of, + }; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_accounting_rows( + ctx: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let wallet = *wallet_id; + let mut events = sink.events.lock().unwrap(); + for row in slice::from_raw_parts(rows, rows_count) { + events.push(format!( + "accounting wallet={wallet:02x} txid={} net={} direction={} has_fee={}", + hex::encode(row.txid), + row.net_amount, + row.direction, + row.has_fee + )); + } + 0 + } + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let received_tx = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let received = TransactionRecord::new( + received_tx.clone(), + bip44, + block(10), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(foreign_address(2)), + value: 5_000, + }], + 5_000, + ); + let restored_lock = TransactionRecord::new( + spend_of(&[outside(3)], vec![pay(9_000, &foreign_address(4))]), + bip44, + block(11), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + let round = || PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![received.clone(), restored_lock.clone()], + ..Default::default() + }), + ..Default::default() + }; let sink = Sink::default(); let callbacks = PersistenceCallbacks { @@ -9716,40 +10584,25 @@ mod tests { callbacks, PersistenceCapabilities::NONE, PersistenceExtensionCallbacks { - wallet_changeset_utxo_verdicts: Some(record_verdicts), + persist_transaction_accounting: Some(record_accounting_rows), ..Default::default() }, ); - // A round with no verdict: the slot stays silent. - persister - .store( - [1u8; 32], - PlatformWalletChangeSet { - core: Some(CoreChangeSet { - synced_height: Some(10), - ..Default::default() - }), - ..Default::default() - }, - ) - .expect("verdict-less round must succeed"); - // A round carrying verdicts: they cross first, in outpoint order. persister - .store([1u8; 32], verdict_round()) - .expect("verdict round must succeed"); + .store([0xAA; 32], round()) + .expect("accounting round must succeed"); assert_eq!( sink.events.lock().unwrap().clone(), vec![ "changeset".to_string(), - "verdict txid=ab vout=1 class=1 height=2402896".to_string(), - "verdict txid=cd vout=0 class=2 height=0".to_string(), - "verdict txid=ef vout=2 class=3 height=0".to_string(), - "changeset".to_string(), + format!( + "accounting wallet=aa txid={} net=5000 direction=0 has_fee=false", + hex::encode(received_tx.txid().as_byte_array()) + ), ], ); drop(persister); - // Host without the slot: the same round still succeeds. let sink = Sink::default(); let callbacks = PersistenceCallbacks { context: &sink as *const Sink as *mut c_void, @@ -9761,13 +10614,370 @@ mod tests { PersistenceCapabilities::NONE, ); persister - .store([1u8; 32], verdict_round()) - .expect("slotless-host verdict round must still succeed"); + .store([0xAA; 32], round()) + .expect("slotless-host round must still succeed"); assert_eq!( sink.events.lock().unwrap().clone(), vec!["changeset".to_string()] ); + } + + /// The load-time backfill reads the host's unaccounted transactions + /// through the load/free pair (an undecodable body is skipped, not + /// fatal) until the host reports the wallet backfilled. It then writes + /// the rows and the completion marker as ONE round: begin, rows, marker, + /// end. A failing row write rolls the round back with no marker, so the + /// backfill runs again. Without every slot nothing is read. + #[test] + fn should_backfill_accounting_through_the_extension_in_one_round() { + use key_wallet::managed_account::transaction_record::TransactionDirection; + use platform_wallet::test_support::core_fixtures::{ + foreign_address, outside, pay, spend_of, + }; + + struct Host { + backfilled: bool, + rows: Vec, + /// Reported instead of `rows.len()`, with a null array when + /// `rows` is empty. + claimed_count: Option, + fail_rows: bool, + loads: std::sync::Mutex, + frees: std::sync::Mutex, + events: std::sync::Mutex>, + } + unsafe fn host<'a>(ctx: *mut c_void) -> &'a Host { + &*(ctx as *const Host) + } + unsafe extern "C" fn load( + ctx: *mut c_void, + _wallet_id: *const u8, + out_backfilled: *mut bool, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, + ) -> i32 { + let host = host(ctx); + *host.loads.lock().unwrap() += 1; + *out_backfilled = host.backfilled; + if !host.backfilled { + *out_rows = if host.rows.is_empty() { + std::ptr::null() + } else { + host.rows.as_ptr() + }; + *out_count = host.claimed_count.unwrap_or(host.rows.len()); + } + 0 + } + unsafe extern "C" fn free( + ctx: *mut c_void, + _rows: *const CoreHistoryTransactionFFI, + _count: usize, + ) { + *host(ctx).frees.lock().unwrap() += 1; + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32 { + let host = host(ctx); + for row in slice::from_raw_parts(rows, rows_count) { + host.events.lock().unwrap().push(format!( + "row net={} direction={} fee={}", + row.net_amount, row.direction, row.fee + )); + } + i32::from(host.fail_rows) + } + unsafe extern "C" fn accounted(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + host(ctx).events.lock().unwrap().push("accounted".into()); + 0 + } + unsafe extern "C" fn begin(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + host(ctx).events.lock().unwrap().push("begin".into()); + 0 + } + unsafe extern "C" fn end(ctx: *mut c_void, _wallet_id: *const u8, success: bool) -> i32 { + host(ctx) + .events + .lock() + .unwrap() + .push(format!("end success={success}")); + 0 + } + + let first = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let second = spend_of(&[outside(3)], vec![pay(7_000, &foreign_address(4))]); + let bodies = [ + dashcore::consensus::encode::serialize(&first), + vec![0xFF], + dashcore::consensus::encode::serialize(&second), + ]; + let make_host = |backfilled: bool, fail_rows: bool| Host { + backfilled, + rows: bodies + .iter() + .map(|body| CoreHistoryTransactionFFI { + tx_bytes: body.as_ptr(), + tx_bytes_len: body.len(), + }) + .collect(), + claimed_count: None, + fail_rows, + loads: std::sync::Mutex::new(0), + frees: std::sync::Mutex::new(0), + events: std::sync::Mutex::new(Vec::new()), + }; + let extensions = PersistenceExtensionCallbacks { + persist_transaction_accounting: Some(persist), + load_core_history_for_accounting: Some(load), + load_core_history_for_accounting_free: Some(free), + persist_core_history_accounted: Some(accounted), + ..Default::default() + }; + let persister_for = |host: &Host, extensions: PersistenceExtensionCallbacks| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: host as *const Host as *mut c_void, + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + extensions, + ) + }; + let row = WalletAccounting { + txid: first.txid(), + net_amount: -5_000, + direction: TransactionDirection::Outgoing, + fee: Some(250), + }; + + // Not yet backfilled: the decodable bodies come back, the loan is returned. + let host = make_host(false, false); + let persister = persister_for(&host, extensions); + let unaccounted = persister + .load_core_history_for_accounting([1; 32]) + .expect("history read must succeed") + .expect("not backfilled yet"); + assert_eq!( + unaccounted.iter().map(|tx| tx.txid()).collect::>(), + vec![first.txid(), second.txid()] + ); + assert_eq!(*host.frees.lock().unwrap(), 1); + + // Rows and marker commit in one round. + persister + .store_history_accounting([1; 32], &[row], true) + .expect("backfill round must succeed"); + assert_eq!( + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "accounted", + "end success=true" + ] + ); drop(persister); + + // Nothing to write still records completion. + let host = make_host(false, false); + persister_for(&host, extensions) + .store_history_accounting([1; 32], &[], true) + .expect("empty backfill round must succeed"); + assert_eq!( + *host.events.lock().unwrap(), + vec!["begin", "accounted", "end success=true"] + ); + + // An incomplete backfill writes its rows but no marker, so it stays + // open for the next load. + let host = make_host(false, false); + persister_for(&host, extensions) + .store_history_accounting([1; 32], &[row], false) + .expect("incomplete backfill round must succeed"); + assert_eq!( + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "end success=true" + ] + ); + + // A failing row write rolls back, with no marker. + let host = make_host(false, true); + assert!(persister_for(&host, extensions) + .store_history_accounting([1; 32], &[row], true) + .is_err()); + assert_eq!( + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "end success=false" + ] + ); + + // Backfilled: nothing to do. + let host = make_host(true, false); + assert_eq!( + persister_for(&host, extensions) + .load_core_history_for_accounting([1; 32]) + .expect("history read must succeed"), + None + ); + + // Without the completion slot the host is never read. + let host = make_host(false, false); + let without_marker = PersistenceExtensionCallbacks { + persist_core_history_accounted: None, + ..extensions + }; + assert_eq!( + persister_for(&host, without_marker) + .load_core_history_for_accounting([1; 32]) + .expect("a host that cannot complete a backfill is not read"), + None + ); + assert_eq!(*host.loads.lock().unwrap(), 0); + + // A count with no array is a broken answer, not an empty history: + // reading it as empty would let the round record completion. + let mut host = make_host(false, false); + host.rows.clear(); + host.claimed_count = Some(3); + assert!(persister_for(&host, extensions) + .load_core_history_for_accounting([1; 32]) + .is_err()); + assert_eq!(*host.frees.lock().unwrap(), 1, "the loan is still returned"); + + // Without the begin/end pair the rows and the marker could not commit + // as one unit: the host is never read, and a store writes nothing. + let host = make_host(false, false); + let unbracketed = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: &host as *const Host as *mut c_void, + on_changeset_begin_fn: Some(begin), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + extensions, + ); + assert_eq!( + unbracketed + .load_core_history_for_accounting([1; 32]) + .expect("a host without the round pair is not read"), + None + ); + assert_eq!(*host.loads.lock().unwrap(), 0); + assert!(unbracketed + .store_history_accounting([1; 32], &[row], true) + .is_err()); + assert!(host.events.lock().unwrap().is_empty()); + } + + /// The accounting backfill's funding read uses a stored body in any + /// context, InstantSend included, and reports a host failure or an + /// unusable body as an error rather than a miss. `get_core_tx_record` + /// keeps its policy: all three read as a miss there. + #[test] + fn should_read_a_funding_body_strictly_for_the_accounting_backfill() { + use platform_wallet::test_support::core_fixtures::{ + foreign_address, outside, pay, spend_of, + }; + + struct Host { + rc: i32, + found: bool, + context_kind: u8, + body: Vec, + frees: std::sync::Mutex, + } + unsafe extern "C" fn get( + ctx: *mut c_void, + _wallet_id: *const u8, + _txid: *const u8, + out_context_kind: *mut u8, + _out_block_height: *mut u32, + _out_block_hash: *mut u8, + _out_block_timestamp: *mut u32, + out_tx_bytes: *mut *const u8, + out_tx_bytes_len: *mut usize, + out_found: *mut bool, + ) -> i32 { + let host = &*(ctx as *const Host); + *out_context_kind = host.context_kind; + *out_found = host.found; + if !host.body.is_empty() { + *out_tx_bytes = host.body.as_ptr(); + *out_tx_bytes_len = host.body.len(); + } + host.rc + } + unsafe extern "C" fn free(ctx: *mut c_void, _bytes: *const u8, _len: usize) { + *(&*(ctx as *const Host)).frees.lock().unwrap() += 1; + } + + let funding = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let body = dashcore::consensus::encode::serialize(&funding); + let read = |rc: i32, found: bool, context_kind: u8, body: Vec| { + let host = Host { + rc, + found, + context_kind, + body, + frees: std::sync::Mutex::new(0), + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: &host as *const Host as *mut c_void, + on_get_core_tx_record_fn: Some(get), + on_get_core_tx_record_free_fn: Some(free), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks::default(), + ); + let strict = persister.get_core_transaction([1; 32], &funding.txid()); + let record = persister + .get_core_tx_record([1; 32], &funding.txid()) + .expect("the record read keeps reading failures as a miss") + .map(|record| record.transaction); + let frees = *host.frees.lock().unwrap(); + drop(persister); + (strict, record, frees) + }; + + // An InstantSend hit: the body is real, only the IS lock is missing. + let (strict, record, frees) = read(0, true, 1, body.clone()); + assert_eq!(strict.expect("an IS hit is a hit"), Some(funding.clone())); + assert_eq!(record, None, "the proof-facing read still skips IS"); + assert_eq!(frees, 2, "each read hands the buffer back"); + + let (strict, record, _) = read(0, true, TX_CONTEXT_RAW_IN_BLOCK as u8, body.clone()); + assert_eq!(strict.unwrap(), Some(funding.clone())); + assert_eq!(record, Some(funding.clone())); + + let (strict, record, _) = read(0, false, 0, Vec::new()); + assert_eq!(strict.unwrap(), None, "not stored is the only miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(7, false, 0, Vec::new()); + assert!(strict.is_err(), "a failing host is not a miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(0, true, 0, vec![0xFF]); + assert!(strict.is_err(), "an undecodable stored body is not a miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(0, true, 0, Vec::new()); + assert!(strict.is_err(), "a hit without a body is not a miss"); + assert_eq!(record, None); } #[test] @@ -9897,7 +11107,7 @@ mod tests { } #[test] - fn should_require_explicit_operation_id_persistence_attestation() { + fn should_withhold_operation_id_capability_until_replay_preserves_tombstones() { unsafe extern "C" fn persist( _: *mut c_void, _: *const u8, @@ -9939,7 +11149,7 @@ mod tests { .persistence_capabilities() .contains(required) ); - assert!(persister(callbacks(), required, extensions) + assert!(!persister(callbacks(), required, extensions) .persistence_capabilities() .contains(required)); assert!(!declared_persister(callbacks(), required) @@ -10000,6 +11210,41 @@ mod tests { ); } + fn complete_replay_extensions() -> PersistenceExtensionCallbacks { + unsafe extern "C" fn scope( + _: *mut c_void, + _: *const u8, + _: ReplayOperationFn, + _: *mut c_void, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const ReplayClaimRestoreFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} + unsafe extern "C" fn apply( + _: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + 0 + } + PersistenceExtensionCallbacks { + wallet_changeset_sweeps: Some(noop_wallet_changeset_sweeps), + replay_wallet: Some(scope), + load_replay_claims: Some(load), + free_replay_claims: Some(free), + apply_replay_effects: Some(apply), + ..Default::default() + } + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -10014,9 +11259,16 @@ mod tests { } let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; - assert!(declared_persister(complete_callbacks(), required) - .persistence_capabilities() - .contains(required)); + let persister = |callbacks, extensions| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, required, extensions, + ) + }; + assert!( + persister(complete_callbacks(), complete_replay_extensions()) + .persistence_capabilities() + .contains(required) + ); let mut missing_begin = complete_callbacks(); missing_begin.on_changeset_begin_fn = None; @@ -10036,7 +11288,19 @@ mod tests { missing_load, missing_load_free, ] { - assert!(!declared_persister(callbacks, required) + assert!(!persister(callbacks, complete_replay_extensions()) + .persistence_capabilities() + .contains(required)); + } + for missing in 0..4 { + let mut extensions = complete_replay_extensions(); + match missing { + 0 => extensions.replay_wallet = None, + 1 => extensions.load_replay_claims = None, + 2 => extensions.free_replay_claims = None, + _ => extensions.apply_replay_effects = None, + } + assert!(!persister(complete_callbacks(), extensions) .persistence_capabilities() .contains(required)); } @@ -10054,6 +11318,7 @@ mod tests { .union(PersistenceCapabilities::PROVIDER_TRANSACTIONS) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::WALLET_RESTORE) + .union(PersistenceCapabilities::CORE_HISTORY_RESTORE) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); cb.on_changeset_begin_fn = Some(noop_begin); @@ -10068,11 +11333,10 @@ mod tests { cb.on_persist_token_balances_fn = Some(noop_token_balances); // "Fully wired" includes the extension's sweeps slot — the legacy // vtable alone can no longer attest CORE_SWEEP_REMOVAL. - let capabilities = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + let capabilities = FFIPersister::new_with_persistence_capabilities_and_extensions( cb, expected, - None, - Some(noop_wallet_changeset_sweeps), + complete_replay_extensions(), ) .persistence_capabilities(); @@ -10180,94 +11444,179 @@ mod tests { ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_tracked_masternodes_free_fn + on_load_tracked_masternodes_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_tracked_masternodes_free_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_sweeps_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_chain_lock_height_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_utxo_verdicts_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_utxo_verdicts_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_identity_balance_block_time_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_identity_balance_block_time_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_dashpay_backfill_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_tracked_masternodes_free_fn - ) + std::mem::size_of::>(), + on_persist_wallet_dashpay_backfill_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_sweeps_fn + on_persist_asset_lock_operation_id_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_sweeps_fn - ) + std::mem::size_of::>(), + on_persist_asset_lock_operation_id_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_chain_lock_height_fn + on_load_asset_lock_operation_id_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_chain_lock_height_fn - ) + std::mem::size_of::>(), + on_load_asset_lock_operation_id_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_utxo_verdicts_fn + on_persist_transaction_accounting_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_utxo_verdicts_fn - ) + std::mem::size_of::>(), + on_persist_transaction_accounting_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_identity_balance_block_time_fn + on_load_core_history_for_accounting_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_identity_balance_block_time_fn - ) + std::mem::size_of::>(), + on_load_core_history_for_accounting_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_identity_balance_block_time_fn + on_load_core_history_for_accounting_free_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_identity_balance_block_time_fn - ) + std::mem::size_of::>(), + on_load_core_history_for_accounting_free_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_dashpay_backfill_fn + on_persist_core_history_accounted_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_dashpay_backfill_fn - ) + std::mem::size_of::>(), + on_persist_core_history_accounted_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_operation_id_fn + on_persist_core_instant_lock_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_operation_id_fn - ) + std::mem::size_of::>(), + on_persist_core_instant_lock_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_core_instant_lock_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_core_instant_lock_fn) + + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_asset_lock_operation_id_fn + on_load_core_instant_lock_free_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_asset_lock_operation_id_fn - ) + std::mem::size_of::>(), + on_load_core_instant_lock_free_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn) + + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -10326,6 +11675,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, PersistenceCapabilities::DASHPAY_PAYMENTS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_HISTORY_RESTORE, + PersistenceCapabilities::CORE_HISTORY_RESTORE.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() @@ -10885,6 +12238,26 @@ mod tests { assert_eq!(detail.value, ASSET_LOCK_CHANGE); } + #[test] + fn should_keep_stored_accounting_when_a_restored_lock_gains_finality() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let (mut ctx, lock, change) = + reloaded_after_unlocked_asset_lock(&rt, ChangeTo::FundingAccount); + assert!(ctx + .managed_wallet + .mark_instant_send_utxos(&lock.txid(), &InstantLock::default())); + + let records = wallet_slices(&ctx.managed_wallet, &lock.txid()); + assert!(!records.is_empty()); + assert!( + records + .iter() + .all(|record| record_accounting(record).is_none()), + "held change without spent-input ownership must not overwrite stored accounting" + ); + assert!(stored_coin_flags(records).contains(&(change, 0, false, true))); + } + /// A change coin the account no longer holds gets no detail: a store /// whose row for it is already gone would insert it as unspent. #[test] @@ -13079,6 +14452,501 @@ mod tests { ); } + /// Owned bytes plus the FFI row that borrows them; the row is valid only + /// while `bytes` lives. + struct RecordedRow { + bytes: Vec, + context: u32, + height: u32, + position: Option, + txid: [u8; 32], + } + + impl RecordedRow { + fn new(tx: &Transaction, context: u32, height: u32, position: Option) -> Self { + Self { + bytes: serialize(tx), + context, + height, + position, + txid: *tx.txid().as_byte_array(), + } + } + + fn ffi(&mut self) -> RecordedTransactionRestoreFFI { + RecordedTransactionRestoreFFI { + txid: self.txid, + tx_bytes: self.bytes.as_mut_ptr(), + tx_bytes_len: self.bytes.len(), + context: self.context, + block_height: self.height, + block_hash: [self.height as u8; 32], + block_timestamp: self.height, + block_position: self.position.unwrap_or(0), + has_block_position: self.position.is_some(), + net_amount: -7, + direction: 1, + } + } + } + + /// A seeded wallet's BIP44 account spec (with its encoded xpub) and first + /// receive address. + fn bip44_wallet_fixture() -> (Wallet, Vec, dashcore::Address) { + let wallet = Wallet::from_seed_bytes( + [0x51; 64], + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("seeded wallet"); + let xpub = wallet + .get_bip44_account(0) + .expect("a Default-created wallet has BIP44 account 0") + .account_xpub; + let xpub_bytes = + bincode::encode_to_vec(xpub, config::standard()).expect("encode account xpub"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .next_receive_address(Some(&xpub), true) + .expect("receive address"); + (wallet, xpub_bytes, address) + } + + fn pay(previous_output: dashcore::OutPoint, outputs: Vec) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: outputs, + special_transaction_payload: None, + } + } + + /// The FFI restore path end to end: the host hands back only the unspent + /// change coin plus the stored history (funding and a confirmed spend), + /// `build_wallet_start_state` decodes it, the shared load replay runs, and + /// the funding transaction is redelivered (rescan). The confirmed-spent + /// output must not come back. Without history — the pre-change host + /// shape — the same redelivery resurrects it. + #[test] + fn should_not_resurrect_confirmed_spent_output_after_ffi_restore() { + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + use platform_wallet::manager::history_replay::replay_recorded_history; + + let (wallet, xpub_bytes, address) = bip44_wallet_fixture(); + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let spec = build_account_spec_ffi(&bip44, &xpub_bytes); + let funding = pay( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([0x61; 32]), 0), + [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + ); + let spent = dashcore::OutPoint::new(funding.txid(), 0); + let spending = pay( + spent, + vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + ); + let script = address.script_pubkey().to_bytes(); + let change = UtxoRestoreEntryFFI { + type_tag: AccountTypeTagFFI::Standard as u8, + standard_tag: StandardAccountTypeTagFFI::Bip44 as u8, + account_index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + prev_txid: *funding.txid().as_byte_array(), + vout: 1, + value_duffs: 20_000, + script_pubkey: script.as_ptr(), + script_pubkey_len: script.len(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + parent_context: TX_CONTEXT_RAW_IN_BLOCK, + parent_block_height: 100, + }; + // Stored out of chain order on purpose: the replay orders them. + let mut rows = [ + RecordedRow::new(&spending, TX_CONTEXT_RAW_IN_BLOCK, 101, Some(0)), + RecordedRow::new(&funding, TX_CONTEXT_RAW_IN_BLOCK, 100, Some(0)), + ]; + let history: Vec<_> = rows.iter_mut().map(RecordedRow::ffi).collect(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + let mut raw_bytes = [serialize(&funding), serialize(&spending)]; + let unresolved: Vec<_> = raw_bytes + .iter_mut() + .enumerate() + .map(|(i, bytes)| UnresolvedAssetLockTxRecordFFI { + account_index: 0, + tx_bytes: bytes.as_mut_ptr(), + tx_bytes_len: bytes.len(), + context_raw: TX_CONTEXT_RAW_IN_BLOCK, + block_height: 100 + i as u32, + block_hash: [100 + i as u8; 32], + block_timestamp: 100 + i as u64, + first_seen: 100 + i as u64, + }) + .collect(); + let redeliver = |recorded: &[RecordedTransactionRestoreFFI], overlap: bool| { + let entry = WalletRestoreEntryFFI { + wallet_id: wallet.wallet_id, + accounts: &spec, + accounts_count: 1, + utxos: &change, + utxos_count: 1, + unresolved_asset_lock_tx_records: unresolved.as_ptr(), + unresolved_asset_lock_tx_records_count: if overlap { unresolved.len() } else { 0 }, + recorded_transactions: recorded.as_ptr(), + recorded_transactions_count: recorded.len(), + ..Default::default() + }; + let (state, _) = build_wallet_start_state(&entry).expect("wallet must restore"); + let (mut wallet, mut info) = (state.wallet, state.wallet_info); + runtime.block_on(async { + replay_recorded_history(&mut info, &mut wallet, state.recorded_history) + .await + .unwrap(); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(BlockInfo::new( + 100, + dashcore::BlockHash::from_byte_array([100; 32]), + 100, + )), + &mut wallet, + true, + true, + ) + .await; + }); + info + }; + + for overlap in [false, true] { + let guarded = redeliver(&history, overlap); + assert!( + guarded + .accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&spent)), + "a redelivered funding must not re-credit a confirmed-spent output" + ); + assert_eq!(guarded.balance.total(), 20_000); + } + let unguarded = redeliver(&[], false); + assert!( + unguarded + .accounts + .all_funding_accounts() + .into_iter() + .any(|account| account.utxos.contains_key(&spent)), + "control: without history the redelivery resurrects the output" + ); + } + + /// Each stored row decodes into its own context, block position and + /// stored accounting; rows that do not hash to their txid or carry an + /// unknown context drop out instead of failing the load. + #[test] + fn should_decode_recorded_transactions_and_drop_bad_rows() { + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionContext; + + let tx = |marker: u8| { + pay( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([marker; 32]), 0), + vec![TxOut { + value: u64::from(marker), + script_pubkey: ScriptBuf::new(), + }], + ) + }; + let mut rows = [ + RecordedRow::new(&tx(1), TX_CONTEXT_RAW_MEMPOOL, 0, None), + RecordedRow::new(&tx(2), TX_CONTEXT_RAW_INSTANT_SEND, 0, None), + RecordedRow::new(&tx(3), TX_CONTEXT_RAW_IN_BLOCK, 50, Some(4)), + RecordedRow::new(&tx(4), TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK, 60, None), + RecordedRow::new(&tx(5), 9, 0, None), + RecordedRow::new(&tx(6), TX_CONTEXT_RAW_MEMPOOL, 0, None), + ]; + // Row 6 claims another row's txid: its bytes do not belong to it. + rows[5].txid = rows[0].txid; + let mut ffi: Vec<_> = rows.iter_mut().map(RecordedRow::ffi).collect(); + ffi[3].direction = 42; + let entry = WalletRestoreEntryFFI { + recorded_transactions: ffi.as_ptr(), + recorded_transactions_count: ffi.len(), + ..Default::default() + }; + + let history = decode_recorded_transactions(&entry); + + let txids: Vec<_> = history.transactions.iter().map(|t| t.txid).collect(); + assert_eq!( + txids, + vec![tx(1).txid(), tx(2).txid(), tx(3).txid(), tx(4).txid()] + ); + let contexts: Vec<_> = history.transactions.iter().map(|t| &t.context).collect(); + assert!(matches!(contexts[0], TransactionContext::Mempool)); + assert!( + matches!(contexts[1], TransactionContext::Mempool), + "the provisional context cannot grant finality without the required proof" + ); + assert!(matches!( + contexts[2], + TransactionContext::InBlock(info) if info.height() == 50 && info.position() == Some(4) + )); + assert!(matches!( + contexts[3], + TransactionContext::InChainLockedBlock(info) if info.height() == 60 && info.position().is_none() + )); + assert_eq!(history.transactions[0].stored_net_amount, Some(-7)); + assert_eq!( + history.transactions[0].stored_direction, + Some(TransactionDirection::Outgoing) + ); + assert_eq!(history.transactions[3].stored_direction, None); + assert!(history.instant_locks.is_empty()); + assert_eq!(history.required_instant_locks, [tx(2).txid()].into()); + } + + #[test] + fn should_round_trip_raw_instant_lock_sidecars_and_free_failed_reads() { + use platform_wallet::changeset::CoreChangeSet; + #[derive(Default)] + struct Host { + bytes: Vec, + frees: usize, + fail_load: bool, + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + bytes: *const u8, + len: usize, + ) -> i32 { + let host = &mut *ctx.cast::(); + host.bytes = slice::from_raw_parts(bytes, len).to_vec(); + 0 + } + unsafe extern "C" fn load( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + bytes: *mut *const u8, + len: *mut usize, + ) -> i32 { + let host = &mut *ctx.cast::(); + let copy = host.bytes.clone().into_boxed_slice(); + *len = copy.len(); + *bytes = Box::into_raw(copy).cast::(); + if host.fail_load { + 1 + } else { + 0 + } + } + unsafe extern "C" fn free(ctx: *mut c_void, bytes: *const u8, len: usize) { + let host = &mut *ctx.cast::(); + host.frees += 1; + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + bytes.cast_mut(), + len, + ))); + } + let ctx = TestWalletContext::new_random(); + let transaction = pay( + dashcore::OutPoint::null(), + vec![TxOut { + value: 1_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }], + ); + let txid = transaction.txid(); + // Transport bytes are deliberately unverified; loading them cannot mint evidence. + let lock = dashcore::InstantLock { + txid, + ..Default::default() + }; + let mut host = Host::default(); + let mut persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&mut host as *mut Host).cast(), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_core_instant_lock: Some(persist), + load_core_instant_lock: Some(load), + free_core_instant_lock: Some(free), + ..Default::default() + }, + ); + persister + .store( + ctx.wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + instant_locks_for_non_final_records: [(txid, lock.clone())].into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(host.bytes, dashcore::consensus::serialize(&lock)); + let mut row = RecordedRow::new(&transaction, TX_CONTEXT_RAW_INSTANT_SEND, 0, None); + let ffi = row.ffi(); + let entry = WalletRestoreEntryFFI { + recorded_transactions: &ffi, + recorded_transactions_count: 1, + ..Default::default() + }; + let mut history = decode_recorded_transactions(&entry); + persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .unwrap(); + assert_eq!(history.instant_locks[&txid], lock); + assert_eq!(host.frees, 1); + host.fail_load = true; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 2, + "a callback error must still release its allocation" + ); + host.fail_load = false; + host.bytes = vec![1, 2, 3]; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 3, + "decode failure must still release the host buffer" + ); + persister.free_core_instant_lock_callback = None; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 3, + "an incomplete callback pair must fail before allocation" + ); + } + + #[tokio::test] + async fn should_fail_restore_instead_of_downgrading_a_stored_instant_send_observation() { + use platform_wallet::manager::history_replay::{ + replay_recorded_history, HistoryReplayError, + }; + let ctx = TestWalletContext::new_random(); + let transaction = pay( + dashcore::OutPoint::null(), + vec![TxOut { + value: 1_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }], + ); + let mut row = RecordedRow::new(&transaction, TX_CONTEXT_RAW_INSTANT_SEND, 0, None); + let ffi = row.ffi(); + let entry = WalletRestoreEntryFFI { + recorded_transactions: &ffi, + recorded_transactions_count: 1, + ..Default::default() + }; + let history = decode_recorded_transactions(&entry); + let mut wallet = ctx.wallet; + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + assert!( + matches!(replay_recorded_history(&mut info, &mut wallet, history).await, + Err(HistoryReplayError::MissingFinalityEvidence { txids }) if txids.contains(&transaction.txid())) + ); + } + + #[test] + fn should_reject_non_exact_transaction_bytes_on_restore() { + let tx = synthetic_minimal_tx(); + let exact = serialize(&tx); + for bytes in [ + [exact.clone(), vec![0]].concat(), + [exact.clone(), exact.clone()].concat(), + exact[..exact.len() - 1].to_vec(), + ] { + let mut row = RecordedRow::new(&tx, TX_CONTEXT_RAW_MEMPOOL, 0, None); + row.bytes = bytes.clone(); + let recorded = row.ffi(); + let outgoing = UnconfirmedOutgoingTxRecordFFI { + txid: *tx.txid().as_byte_array(), + tx_bytes: bytes.as_ptr().cast_mut(), + tx_bytes_len: bytes.len(), + first_seen: 0, + }; + let entry = WalletRestoreEntryFFI { + recorded_transactions: &recorded, + recorded_transactions_count: 1, + unconfirmed_outgoing_tx_records: &outgoing, + unconfirmed_outgoing_tx_records_count: 1, + ..Default::default() + }; + assert!(decode_recorded_transactions(&entry).is_empty()); + assert!(decode_unconfirmed_outgoing(&entry).is_empty()); + } + } + + #[test] + fn should_decode_no_history_from_a_null_array() { + let entry = WalletRestoreEntryFFI::default(); + assert!(decode_recorded_transactions(&entry).is_empty()); + } + + /// History restore rides the wallet-list load: a host declaring the bit + /// attests it only with the load callback pair wired. + #[test] + fn core_history_restore_requires_the_wallet_list_load_callbacks() { + let declared = PersistenceCapabilities::CORE_HISTORY_RESTORE; + assert!( + !declared_persister(PersistenceCallbacks::default(), declared) + .persistence_capabilities() + .contains(declared) + ); + let cb = PersistenceCallbacks { + on_load_wallet_list_fn: Some(noop_load_wallets), + on_load_wallet_list_free_fn: Some(noop_free_wallets), + ..Default::default() + }; + assert!(declared_persister(cb, declared) + .persistence_capabilities() + .contains(declared)); + } + /// A cold restore must hand every persisted owned DPNS label to the /// in-memory identity, so a later capped fetch only adds to it and no /// snapshot emitted before the next complete fetch drops an owned pick diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs new file mode 100644 index 00000000000..5a50bfbda37 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -0,0 +1,1121 @@ +//! Synchronous host transaction adapter for wallet-owned history replay. + +use std::collections::BTreeMap; +use std::os::raw::c_void; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use dashcore::{OutPoint, ScriptBuf, TxOut, Txid}; +use parking_lot::Mutex; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{PersistenceError, RecordedHistory}; +use platform_wallet::manager::history_replay::{ + replay_wallet_history_blocking, HistoryReplayError, HistoryReplayLoad, ReplayClaimEffect, + StoredReplayClaim, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use super::FFIPersister; +use crate::core_wallet_types::OutPointFFI; +use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; + +/// Scope-only callback. The host must invoke it once synchronously and never retain it. +pub type ReplayOperationFn = unsafe extern "C" fn(operation_context: *mut c_void) -> i32; +/// Run one wallet's replay inside a host storage transaction; nonzero means rollback. +pub type ReplayWalletFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + operation: ReplayOperationFn, + operation_context: *mut c_void, +) -> i32; +/// Load owned coin material and durable claims from the current wallet snapshot. +pub type LoadReplayClaimsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const ReplayClaimRestoreFFI, + out_count: *mut usize, +) -> i32; +/// Release the complete host-owned array and its nested script buffers. +pub type FreeReplayClaimsFn = + unsafe extern "C" fn(context: *mut c_void, rows: *const ReplayClaimRestoreFFI, count: usize); +/// Apply exactly the owner-computed effects inside the current wallet transaction. +pub type ApplyReplayEffectsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + effects: *const HistoryReplayEffectsFFI, +) -> i32; + +/// Coin material and optional hold; repeated outpoints may carry distinct claims. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ReplayClaimRestoreFFI { + pub txid: [u8; 32], + pub vout: u32, + pub has_output: bool, + pub value: u64, + pub script: *const u8, + pub script_len: usize, + pub is_held: bool, + pub has_claimant: bool, + pub claimant: [u8; 32], + pub has_mined_height: bool, + pub mined_height: u32, +} + +/// One Rust-owned claim verdict. The host must not infer its own release policy. +#[repr(C)] +pub struct ReplayClaimEffectFFI { + pub txid: [u8; 32], + pub vout: u32, + /// 0 removes the output/placeholder, 1 releases it, 2 holds it. + pub action: u8, + pub has_claimant: bool, + pub claimant: [u8; 32], + pub has_mined_height: bool, + pub mined_height: u32, +} + +/// Borrowed only for ApplyReplayEffectsFn; all removals concern the supplied wallet. +#[repr(C)] +pub struct HistoryReplayEffectsFFI { + pub removed_records: *const [u8; 32], + pub removed_records_count: usize, + pub removed_evidence: *const [u8; 32], + pub removed_evidence_count: usize, + pub claims: *const ReplayClaimEffectFFI, + pub claims_count: usize, + pub removed_asset_locks: *const OutPointFFI, + pub removed_asset_locks_count: usize, + pub payments: *const DashpayPaymentPersistEntryFFI, + pub payments_count: usize, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct ReplayCallbacks { + pub transaction: Option, + pub load_claims: Option, + pub free_claims: Option, + pub apply: Option, +} + +#[derive(Debug, thiserror::Error)] +enum ReplayAdapterError { + #[error("wallet history replay requires all atomic transaction, claim load/free and effect callbacks")] + IncompleteCallbacks, + #[error("host must invoke the wallet replay operation exactly once before its transaction callback returns")] + InvocationProtocol, + #[error("host wallet replay transaction failed (code {0})")] + HostFailure(i32), + #[error("host wallet replay transaction failed (code {code}): {operation}")] + HostAndOperationFailure { + code: i32, + #[source] + operation: PersistenceError, + }, + #[error("wallet replay operation panicked; host must roll back the transaction")] + Panicked, + #[error( + "wallet replay claim callback returned invalid buffers or conflicting output material" + )] + InvalidClaims, + #[error("wallet replay claim callback failed (code {0})")] + ClaimRead(i32), + #[error("wallet replay effect callback failed (code {0})")] + EffectWrite(i32), + #[error("wallet disappeared during atomic history replay")] + MissingWallet, +} + +impl ReplayCallbacks { + fn any(self) -> bool { + self.transaction.is_some() + || self.load_claims.is_some() + || self.free_claims.is_some() + || self.apply.is_some() + } + pub(super) fn complete(self) -> bool { + self.transaction.is_some() + && self.load_claims.is_some() + && self.free_claims.is_some() + && self.apply.is_some() + } +} + +struct ReplayInvocation<'a> { + persister: &'a FFIPersister, + wallet_id: WalletId, + evidence: &'a ReplayEvidence, + calls: AtomicUsize, + result: Mutex>>, +} + +unsafe extern "C" fn invoke_replay(context: *mut c_void) -> i32 { + // SAFETY: the host contract permits this borrowed context only during its callback. + let invocation = &*context.cast::>(); + let previous = invocation.calls.fetch_add(1, Ordering::SeqCst); + if previous != 0 { + return 1; + } + let result = catch_unwind(AssertUnwindSafe(|| { + invocation + .persister + .replay_in_scope(invocation.wallet_id, invocation.evidence) + })) + .unwrap_or_else(|_| Err(PersistenceError::backend(ReplayAdapterError::Panicked))); + let failed = result.is_err(); + *invocation.result.lock() = Some(result); + i32::from(failed) +} + +impl FFIPersister { + pub(super) fn load_wallet_replay_scope( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let round = self.round_lock.lock(); + if round.in_round { + return Err(PersistenceError::backend( + "cannot replay an unclosed persistence round", + )); + } + if !self.replay_callbacks.any() { + return Ok(HistoryReplayLoad { + state: self.load_snapshot_unlocked(Some(wallet_id))?, + outcomes: BTreeMap::new(), + }); + } + if !self.replay_callbacks.complete() { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + } + let Some(transaction) = self.replay_callbacks.transaction else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let invocation = ReplayInvocation { + persister: self, + wallet_id, + evidence, + calls: AtomicUsize::new(0), + result: Mutex::new(None), + }; + // Synchronous host -> Rust -> private unlocked reads; no recursive round mutex. + let code = unsafe { + transaction( + self.callbacks.context, + wallet_id.as_ptr(), + invoke_replay, + (&invocation as *const ReplayInvocation<'_>) + .cast_mut() + .cast(), + ) + }; + let result = invocation.result.into_inner(); + if invocation.calls.load(Ordering::SeqCst) != 1 || result.is_none() { + return Err(PersistenceError::backend( + ReplayAdapterError::InvocationProtocol, + )); + } + match (code, result) { + (0, Some(result)) => result, + (code, Some(Err(operation))) => Err(PersistenceError::backend( + ReplayAdapterError::HostAndOperationFailure { code, operation }, + )), + (code, _) => Err(PersistenceError::backend(ReplayAdapterError::HostFailure( + code, + ))), + } + } + + fn replay_in_scope( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let mut state = self.load_snapshot_unlocked(Some(wallet_id))?; + let Some(wallet) = state.wallets.get_mut(&wallet_id) else { + return Ok(HistoryReplayLoad { + state, + outcomes: BTreeMap::new(), + }); + }; + let mut claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; + let outcome = + replay_wallet_history_blocking(wallet, evidence).map_err(PersistenceError::backend)?; + let records = wallet.recorded_history.transactions.clone(); + let effects = outcome + .storage_effects(&records, &claims) + .map_err(PersistenceError::backend)?; + let has_effects = !effects.removed_records.is_empty() + || !effects.removed_evidence.is_empty() + || !effects.claims.is_empty(); + if (!outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty()) + && !has_effects + { + return Err(PersistenceError::backend( + HistoryReplayError::UnsettledAfterReplay, + )); + } + if has_effects { + let Some(apply) = self.replay_callbacks.apply else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let removed_records: Vec<_> = effects + .removed_records + .iter() + .map(|id| id.to_byte_array()) + .collect(); + let removed_evidence: Vec<_> = effects + .removed_evidence + .iter() + .map(|id| id.to_byte_array()) + .collect(); + let claim_effects: Vec<_> = effects + .claims + .iter() + .map(|(outpoint, effect)| { + let (action, claimant, height) = match effect { + ReplayClaimEffect::Remove => (0, None, None), + ReplayClaimEffect::Release => (1, None, None), + ReplayClaimEffect::Hold { + claimant, + mined_height, + } => (2, *claimant, *mined_height), + }; + ReplayClaimEffectFFI { + txid: outpoint.txid.to_byte_array(), + vout: outpoint.vout, + action, + has_claimant: claimant.is_some(), + claimant: claimant.map(|id| id.to_byte_array()).unwrap_or_default(), + has_mined_height: height.is_some(), + mined_height: height.unwrap_or_default(), + } + }) + .collect(); + let operations = effects.operation_effects(wallet); + let asset_locks: Vec<_> = operations + .asset_locks + .removed + .iter() + .map(OutPointFFI::from) + .collect(); + let (payments, _payment_strings) = build_payment_persist_entries(&operations.payments); + let ffi = HistoryReplayEffectsFFI { + removed_records: slice_pointer(&removed_records), + removed_records_count: removed_records.len(), + removed_evidence: slice_pointer(&removed_evidence), + removed_evidence_count: removed_evidence.len(), + claims: slice_pointer(&claim_effects), + claims_count: claim_effects.len(), + removed_asset_locks: slice_pointer(&asset_locks), + removed_asset_locks_count: asset_locks.len(), + payments: slice_pointer(&payments), + payments_count: payments.len(), + }; + let code = unsafe { apply(self.callbacks.context, wallet_id.as_ptr(), &ffi) }; + if code != 0 { + return Err(PersistenceError::backend(ReplayAdapterError::EffectWrite( + code, + ))); + } + state = self.load_snapshot_unlocked(Some(wallet_id))?; + let wallet = state + .wallets + .get_mut(&wallet_id) + .ok_or_else(|| PersistenceError::backend(ReplayAdapterError::MissingWallet))?; + claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; + let remaining = replay_wallet_history_blocking(wallet, evidence) + .map_err(PersistenceError::backend)?; + if !remaining.sweeps.is_empty() + || !remaining.authorization.rejected.is_empty() + || !remaining.withheld_asset_lock_inputs.is_empty() + { + return Err(PersistenceError::backend( + HistoryReplayError::UnsettledAfterReplay, + )); + } + wallet.unconfirmed_outgoing_txs = + outcome.resendable(std::mem::take(&mut wallet.unconfirmed_outgoing_txs)); + } + if let Some(wallet) = state.wallets.get_mut(&wallet_id) { + wallet.wallet_info.restore_spent_outpoints( + &claims + .iter() + .map(|claim| (claim.outpoint, claim.claimant)) + .collect::>(), + ); + } + Ok(HistoryReplayLoad { + state, + outcomes: [(wallet_id, outcome)].into(), + }) + } + + fn load_replay_claims( + &self, + wallet_id: WalletId, + history: &mut RecordedHistory, + ) -> Result, PersistenceError> { + let (Some(load), Some(free)) = ( + self.replay_callbacks.load_claims, + self.replay_callbacks.free_claims, + ) else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let mut rows = std::ptr::null(); + let mut count = 0; + let code = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut rows, + &mut count, + ) + }; + let _guard = ClaimRowsGuard { + context: self.callbacks.context, + free, + rows, + count, + }; + if code != 0 { + return Err(PersistenceError::backend(ReplayAdapterError::ClaimRead( + code, + ))); + } + if count + > dashcore::consensus::encode::MAX_VEC_SIZE + / std::mem::size_of::() + || (rows.is_null() && count != 0) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + let rows = if count == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(rows, count) } + }; + let mut claims = Vec::new(); + for row in rows { + let outpoint = OutPoint::new(Txid::from_byte_array(row.txid), row.vout); + if row.has_output { + if row.script_len > dashcore::consensus::encode::MAX_VEC_SIZE + || (row.script.is_null() && row.script_len != 0) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + let script = if row.script_len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(row.script, row.script_len) }.to_vec() + }; + let output = TxOut { + value: row.value, + script_pubkey: ScriptBuf::from_bytes(script), + }; + if history + .owned_prevouts + .insert(outpoint, output.clone()) + .is_some_and(|old| old != output) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + } + if row.is_held { + claims.push(StoredReplayClaim { + outpoint, + claimant: row + .has_claimant + .then(|| Txid::from_byte_array(row.claimant)), + mined_height: row.has_mined_height.then_some(row.mined_height), + }); + } + } + Ok(claims) + } +} + +fn slice_pointer(values: &[T]) -> *const T { + if values.is_empty() { + std::ptr::null() + } else { + values.as_ptr() + } +} + +struct ClaimRowsGuard { + context: *mut c_void, + free: FreeReplayClaimsFn, + rows: *const ReplayClaimRestoreFFI, + count: usize, +} +impl Drop for ClaimRowsGuard { + fn drop(&mut self) { + unsafe { (self.free)(self.context, self.rows, self.count) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset_lock_persistence::AssetLockEntryFFI; + use crate::persistence::{FFIPersister, PersistenceCallbacks, PersistenceExtensionCallbacks}; + use crate::types::FFINetwork; + use crate::wallet_restore_types::RecordedTransactionRestoreFFI; + use crate::wallet_restore_types::{UtxoRestoreEntryFFI, WalletRestoreEntryFFI}; + use dashcore::Network; + use dashcore::{Transaction, TxIn}; + use platform_wallet::changeset::recorded_history::ReplayEvidence; + use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; + use std::cell::{Cell, RefCell}; + + type AppliedClaim = (OutPoint, u8, Option, Option); + + #[derive(Default)] + struct Host { + scopes: Cell, + inside: Cell, + scoped_reads: Cell, + mode: Cell, + fail_read: Cell, + transaction: RefCell>, + replacement: RefCell>, + claim_frees: Cell, + claim_mode: Cell, + effects: Cell, + additional: RefCell>, + account_xpub: Vec, + applied_claims: RefCell>, + terminal_lock: Option, + coin: Option<(OutPoint, TxOut)>, + coin_held: Cell, + } + + unsafe extern "C" fn scope( + ctx: *mut c_void, + _: *const u8, + operation: ReplayOperationFn, + operation_ctx: *mut c_void, + ) -> i32 { + let host = &*ctx.cast::(); + host.scopes.set(host.scopes.get() + 1); + host.inside.set(true); + let saved = host.additional.borrow().clone(); + let saved_coin = host.coin_held.get(); + let saved_claims = host.applied_claims.borrow().clone(); + if let Some(replacement) = host.replacement.borrow_mut().take() { + *host.transaction.borrow_mut() = Some(replacement); + } + let first = if host.mode.get() == 5 { + let address = operation_ctx as usize; + std::thread::scope(|scope| { + scope + .spawn(move || operation(address as *mut c_void)) + .join() + .unwrap() + }) + } else if host.mode.get() == 1 { + 0 + } else { + operation(operation_ctx) + }; + if host.mode.get() == 2 { + let _ = operation(operation_ctx); + } + host.inside.set(false); + if first != 0 || host.mode.get() == 4 { + *host.additional.borrow_mut() = saved; + host.coin_held.set(saved_coin); + *host.applied_claims.borrow_mut() = saved_claims; + } + match host.mode.get() { + 1..=3 => 0, + 4 => 71, + _ => first, + } + } + + unsafe extern "C" fn load( + ctx: *mut c_void, + rows: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &*ctx.cast::(); + if host.inside.get() { + host.scoped_reads.set(host.scoped_reads.get() + 1); + } + if host.fail_read.get() { + return 73; + } + let transactions: Vec<_> = host + .transaction + .borrow() + .iter() + .cloned() + .map(|tx| (tx, 1)) + .chain(host.additional.borrow().iter().cloned()) + .collect(); + let history: Vec<_> = transactions + .iter() + .map(|(transaction, context)| { + let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); + let len = bytes.len(); + RecordedTransactionRestoreFFI { + txid: transaction.txid().to_byte_array(), + tx_bytes: Box::into_raw(bytes).cast(), + tx_bytes_len: len, + context: *context, + block_height: if *context >= 2 { 42 } else { 0 }, + block_hash: [1; 32], + block_timestamp: 0, + block_position: 0, + has_block_position: false, + net_amount: 0, + direction: 0, + } + }) + .collect(); + let history_len = history.len(); + let history = Box::into_raw(history.into_boxed_slice()).cast(); + let account = (!host.account_xpub.is_empty()).then(|| { + let kind = key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }; + Box::into_raw(Box::new(super::super::build_account_spec_ffi( + &kind, + &host.account_xpub, + ))) + }); + let coin = + host.coin + .as_ref() + .filter(|_| !host.coin_held.get()) + .map(|(outpoint, output)| { + let spec = &*account.unwrap(); + Box::into_raw(Box::new(UtxoRestoreEntryFFI { + type_tag: spec.type_tag, + standard_tag: spec.standard_tag, + account_index: spec.index, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + prev_txid: outpoint.txid.to_byte_array(), + vout: outpoint.vout, + value_duffs: output.value, + script_pubkey: output.script_pubkey.as_bytes().as_ptr(), + script_pubkey_len: output.script_pubkey.len(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + parent_context: 2, + parent_block_height: 100, + })) + }); + let terminal = host.terminal_lock.as_ref().map(|transaction| { + let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); + let mut out_point = [0; 36]; + out_point[..32].copy_from_slice(transaction.txid().as_byte_array()); + out_point[32..].copy_from_slice(&1_u32.to_le_bytes()); + Box::into_raw(Box::new(AssetLockEntryFFI { + out_point, + transaction_bytes_len: bytes.len(), + transaction_bytes: Box::into_raw(bytes).cast(), + account_index: 0, + funding_type: 0, + identity_index: 0, + amount_duffs: 50_000, + status: 4, + proof_bytes: std::ptr::null(), + proof_bytes_len: 0, + })) + }); + *rows = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [41; 32], + accounts: account.unwrap_or(std::ptr::null_mut()), + accounts_count: usize::from(account.is_some()), + network: FFINetwork::Testnet, + recorded_transactions: history, + recorded_transactions_count: history_len, + utxos: coin.unwrap_or(std::ptr::null_mut()), + utxos_count: usize::from(coin.is_some()), + tracked_asset_locks: terminal.unwrap_or(std::ptr::null_mut()), + tracked_asset_locks_count: usize::from(terminal.is_some()), + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free(_: *mut c_void, rows: *const WalletRestoreEntryFFI, _: usize) { + if !rows.is_null() { + let entry = Box::from_raw(rows.cast_mut()); + if !entry.accounts.is_null() { + drop(Box::from_raw(entry.accounts.cast_mut())); + } + if !entry.utxos.is_null() { + drop(Box::from_raw(entry.utxos.cast_mut())); + } + if !entry.tracked_asset_locks.is_null() { + let lock = Box::from_raw(entry.tracked_asset_locks.cast_mut()); + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + lock.transaction_bytes.cast_mut(), + lock.transaction_bytes_len, + ))); + } + if !entry.recorded_transactions.is_null() { + let records = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.recorded_transactions.cast_mut(), + entry.recorded_transactions_count, + )); + for record in &records { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + record.tx_bytes, + record.tx_bytes_len, + ))); + } + } + } + } + unsafe extern "C" fn claims( + ctx: *mut c_void, + _: *const u8, + rows: *mut *const ReplayClaimRestoreFFI, + count: *mut usize, + ) -> i32 { + *rows = std::ptr::null(); + let host = &*ctx.cast::(); + *count = usize::from(host.claim_mode.get() == 2); + if host.claim_mode.get() == 1 { + 79 + } else { + 0 + } + } + unsafe extern "C" fn free_claims(ctx: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) { + let host = &*ctx.cast::(); + host.claim_frees.set(host.claim_frees.get() + 1); + } + unsafe extern "C" fn apply( + ctx: *mut c_void, + _: *const u8, + effects: *const HistoryReplayEffectsFFI, + ) -> i32 { + let host = &*ctx.cast::(); + assert!(host.inside.get()); + host.effects.set(host.effects.get() + 1); + let effects = &*effects; + if effects.removed_records_count != 0 { + let removed = + std::slice::from_raw_parts(effects.removed_records, effects.removed_records_count); + host.additional + .borrow_mut() + .retain(|(tx, _)| !removed.contains(&tx.txid().to_byte_array())); + } + if effects.claims_count != 0 { + let claims = std::slice::from_raw_parts(effects.claims, effects.claims_count); + *host.applied_claims.borrow_mut() = claims + .iter() + .map(|claim| { + ( + OutPoint::new(Txid::from_byte_array(claim.txid), claim.vout), + claim.action, + claim + .has_claimant + .then(|| Txid::from_byte_array(claim.claimant)), + claim.has_mined_height.then_some(claim.mined_height), + ) + }) + .collect(); + } + if host.mode.get() != 7 && host.coin.is_some() && effects.claims_count > 0 { + host.coin_held.set(true); + } + if host.mode.get() == 6 { + 81 + } else { + 0 + } + } + + fn host_with_funds_account() -> Host { + let wallet = key_wallet::Wallet::from_seed_bytes( + [0x51; 64], + dashcore::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .unwrap(); + Host { + account_xpub: bincode::encode_to_vec( + wallet.get_bip44_account(0).unwrap().account_xpub, + bincode::config::standard(), + ) + .unwrap(), + ..Default::default() + } + } + + #[test] + fn should_apply_exact_owner_claims_and_reread_inside_the_host_transaction() { + let winner = transaction(1); + let loser = transaction(2); + let host = host_with_funds_account(); + *host.additional.borrow_mut() = vec![(winner.clone(), 3), (loser, 0)]; + let loaded = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!(loaded.state.wallets.len(), 1); + assert_eq!(host.effects.get(), 1); + assert_eq!(*host.additional.borrow(), vec![(winner.clone(), 3)]); + assert_eq!( + *host.applied_claims.borrow(), + vec![( + winner.input[0].previous_output, + 2, + Some(winner.txid()), + Some(42) + )] + ); + assert!(host.scoped_reads.get() >= 2); + } + + #[test] + fn should_rollback_host_row_deletion_when_effect_application_fails() { + let original = vec![(transaction(1), 3), (transaction(2), 0)]; + let host = host_with_funds_account(); + *host.additional.borrow_mut() = original.clone(); + host.mode.set(6); + let error = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap_err(); + assert!(format!("{error:?}").contains("81"), "{error:?}"); + assert_eq!(host.effects.get(), 1); + assert_eq!(*host.additional.borrow(), original); + } + + #[test] + fn should_apply_terminal_input_hold_atomically_and_reject_a_noop_host() { + use dashcore::transaction::{ + special_transaction::asset_lock::AssetLockPayload, TransactionPayload, + }; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + for mode in [0, 6, 7] { + let wallet = key_wallet::Wallet::from_seed_bytes( + [0x51; 64], + dashcore::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let mut lock = transaction(90); + lock.version = 3; + lock.special_transaction_payload = Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new( + [49_000, 50_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + ), + )); + let spent = lock.input[0].previous_output; + let mut host = host_with_funds_account(); + host.coin = Some(( + spent, + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + )); + host.terminal_lock = Some(lock.clone()); + host.mode.set(mode); + let result = persister(&host).load_with_replay(&ReplayEvidence::default()); + if mode == 0 { + let loaded = result.unwrap(); + assert_eq!( + loaded.state.wallets[&[41; 32]].wallet_info.balance.total(), + 0 + ); + assert!(host.coin_held.get()); + assert_eq!( + *host.applied_claims.borrow(), + vec![(spent, 2, Some(lock.txid()), None)] + ); + let again = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!( + again.state.wallets[&[41; 32]].wallet_info.balance.total(), + 0 + ); + } else { + assert!( + result.is_err(), + "a failed or unapplied hold must not publish a wallet" + ); + assert!( + !host.coin_held.get(), + "transaction rollback preserves the original host state" + ); + assert!(host.applied_claims.borrow().is_empty()); + } + } + } + + fn persister(host: &Host) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (host as *const Host).cast_mut().cast(), + on_load_wallet_list_fn: Some(load), + on_load_wallet_list_free_fn: Some(free), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + replay_wallet: Some(scope), + load_replay_claims: Some(claims), + free_replay_claims: Some(free_claims), + apply_replay_effects: Some(apply), + ..Default::default() + }, + ) + } + + fn transaction(marker: u32) -> Transaction { + Transaction { + version: 1, + lock_time: marker, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([7; 32]), 0), + ..Default::default() + }], + output: vec![], + special_transaction_payload: None, + } + } + + async fn checked_evidence(transaction: &Transaction) -> ReplayEvidence { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + struct Storage(std::path::PathBuf); + impl Drop for Storage { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + static NEXT: AtomicUsize = AtomicUsize::new(0); + let storage = Storage(std::env::temp_dir().join(format!( + "ffi-replay-proof-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ))); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&storage.0).unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(dashcore::Network::Testnet) + .without_filters() + .with_storage_path(&storage.0), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + } + } + + #[tokio::test] + async fn should_allow_explicit_replay_retry_only_with_checked_matching_evidence() { + let host = Host::default(); + let transaction = transaction(2); + *host.transaction.borrow_mut() = Some(transaction.clone()); + let persister = persister(&host); + assert!(persister + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + let evidence = checked_evidence(&transaction).await; + let restored = persister + .load_wallet_with_replay([41; 32], &evidence) + .unwrap(); + assert!(restored.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.effects.get(), 0); + assert_eq!(host.scopes.get(), 2); + } + + #[tokio::test] + async fn should_rebind_prefetched_evidence_to_the_fresh_host_transaction() { + let host = Host::default(); + let old = transaction(3); + *host.transaction.borrow_mut() = Some(old.clone()); + let persister = persister(&host); + let _discovery = persister.load_read_snapshot().unwrap(); + let evidence = checked_evidence(&old).await; + let fresh = transaction(4); + let fresh_id = fresh.txid(); + *host.replacement.borrow_mut() = Some(fresh); + let error = persister + .load_wallet_with_replay([41; 32], &evidence) + .unwrap_err(); + assert!(error.to_string().contains(&fresh_id.to_string())); + assert_eq!(host.effects.get(), 0); + } + + #[test] + fn should_fail_missing_required_proof_before_any_host_effects() { + let host = Host::default(); + let transaction = transaction(1); + let txid = transaction.txid(); + *host.transaction.borrow_mut() = Some(transaction); + let error = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap_err(); + assert!(error.to_string().contains(&txid.to_string())); + assert_eq!(host.effects.get(), 0); + assert_eq!(host.claim_frees.get(), 1); + assert!(host.transaction.borrow().is_some()); + } + + #[test] + fn should_free_claim_buffers_on_callback_and_decode_failure() { + for mode in [1, 2] { + let host = Host::default(); + host.claim_mode.set(mode); + assert!(persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + assert_eq!(host.claim_frees.get(), 1); + assert_eq!(host.effects.get(), 0); + } + } + + #[test] + fn should_require_complete_atomic_callbacks_for_wallet_restore_capability() { + let host = Host::default(); + let mut persister = persister(&host); + persister.declared_capabilities = PersistenceCapabilities::WALLET_RESTORE; + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::WALLET_RESTORE)); + for callbacks in [ + ReplayCallbacks { + transaction: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + load_claims: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + free_claims: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + apply: None, + ..persister.replay_callbacks + }, + ReplayCallbacks::default(), + ] { + persister.replay_callbacks = callbacks; + assert!(!persister + .persistence_capabilities() + .contains(PersistenceCapabilities::WALLET_RESTORE)); + } + } + + #[test] + fn should_read_wallet_inside_exactly_one_atomic_replay_scope() { + let host = Host::default(); + let loaded = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap(); + assert!(loaded.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.scopes.get(), 1); + assert!(host.scoped_reads.get() > 0); + assert!(!host.inside.get()); + } + + #[test] + fn should_accept_a_synchronous_replay_thunk_on_the_hosts_serial_thread() { + let host = Host::default(); + host.mode.set(5); + let loaded = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap(); + assert!(loaded.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.scopes.get(), 1); + assert_eq!(host.scoped_reads.get(), 1); + } + + #[test] + fn should_reject_missing_or_repeated_atomic_replay_thunk() { + for mode in [1, 2] { + let host = Host::default(); + host.mode.set(mode); + assert!( + persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err(), + "mode={mode}" + ); + } + } + + #[test] + fn should_preserve_rust_failure_when_host_reports_replay_success() { + let host = Host::default(); + host.mode.set(3); + host.fail_read.set(true); + let error = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap_err(); + assert!(error.to_string().contains("73")); + assert_eq!(host.scopes.get(), 1); + } + + #[test] + fn should_reject_host_failure_after_successful_replay() { + let host = Host::default(); + host.mode.set(4); + assert!(persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index e5cfdfa0dab..815d32ca6ec 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -615,6 +615,42 @@ pub struct UnconfirmedOutgoingTxRecordFFI { pub first_seen: u64, } +/// One stored transaction of the wallet, handed back at load so the shared +/// history replay can rebuild the spend guards of confirmed AND unconfirmed +/// spends. +/// +/// Without it only the persisted unspent UTXO set comes back, so a funding +/// transaction redelivered after restart (rescan, gap-limit rediscovery) +/// re-credits an output a confirmed spend already consumed. +#[repr(C)] +pub struct RecordedTransactionRestoreFFI { + /// Wire-order txid of the stored row. The load path decodes `tx_bytes` + /// and drops the record unless it hashes to this: the replay applies the + /// transaction through the ordinary state-update path, so foreign bytes + /// would move accounting for unrelated inputs and outputs. + pub txid: [u8; 32], + /// Consensus-encoded transaction body. Swift-owned for the callback + /// window; freed by `LoadWalletListFreeFn`. + pub tx_bytes: *mut u8, + pub tx_bytes_len: usize, + /// Stored context: `0` mempool, `1` InstantSend, `2` in a block, `3` in a + /// chain-locked block. `1` requires SPV-verified lock evidence before replay; + /// any other value drops the record. + pub context: u32, + /// Block fields, read only for contexts `2` and `3`. + pub block_height: u32, + pub block_hash: [u8; 32], + pub block_timestamp: u32, + /// In-block position, meaningful only when `has_block_position`. + pub block_position: u32, + pub has_block_position: bool, + /// The wallet-level net amount the host stores for this row. + pub net_amount: i64, + /// The stored direction: `0` incoming, `1` outgoing, `2` internal, + /// `3` CoinJoin. Any other value reads as "unknown". + pub direction: u32, +} + /// Per-wallet entry returned by `on_load_wallet_list_fn`. /// /// `accounts` points to a contiguous array of length `accounts_count`. @@ -746,6 +782,16 @@ pub struct WalletRestoreEntryFFI { /// so no existing offset moves. pub dashpay_backfill_covered: *const crate::core_wallet_types::DashPayBackfillCoveredContactFFI, pub dashpay_backfill_covered_count: usize, + /// Every stored transaction of this wallet, in any order — see + /// [`RecordedTransactionRestoreFFI`]. `null` / `0` means "no history + /// supplied" and keeps the pre-history load behaviour. Each entry's + /// `tx_bytes` buffer is Swift-owned and freed by `LoadWalletListFreeFn`. + /// + /// Appended at the end for the same reason as the field above; the + /// struct carries no size field, so host and library must still be + /// built together. + pub recorded_transactions: *const RecordedTransactionRestoreFFI, + pub recorded_transactions_count: usize, } /// Every field named explicitly so that adding a field to this ABI struct @@ -791,6 +837,8 @@ impl Default for WalletRestoreEntryFFI { dashpay_backfill_rewound_from: 0, dashpay_backfill_covered: std::ptr::null(), dashpay_backfill_covered_count: 0, + recorded_transactions: std::ptr::null(), + recorded_transactions_count: 0, } } } diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index a3f47e5fb45..26a94c1a91b 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -161,6 +161,8 @@ apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], opti windows-native-keyring-store = { version = "=1.0.0", optional = true } [dev-dependencies] +async-trait = "=0.1.89" +dash-spv = { workspace = true, features = ["test-utils"] } # Safe SQLite fault injection for migration rollback tests. rusqlite = { version = "0.38", features = ["functions"] } # `test-utils` reaches `provider_key_test_wallet`, shared with diff --git a/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs b/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs new file mode 100644 index 00000000000..9335a6adc00 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs @@ -0,0 +1,8 @@ +//! Access paths for the typed, proof-preserving Core history rescan. + +pub fn migration() -> String { + "CREATE INDEX idx_core_utxos_funding ON core_utxos(wallet_id, substr(outpoint, 2, 32)); + CREATE INDEX idx_core_utxos_claimant ON core_utxos(wallet_id, spent_in_txid) + WHERE spent_in_txid IS NOT NULL;" + .to_owned() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 029476658ed..ff4b67f8647 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; +use key_wallet::transaction_checking::spend_authorization::SpendAuthorizationError; use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; use crate::sqlite::util::safe_cast::SafeCastTarget; @@ -38,6 +39,9 @@ pub enum AutoBackupOperation { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum WalletStorageError { + /// Persisted authorization uses rules this wallet cannot verify. + #[error("cannot verify pending spend authorization")] + SpendAuthorization(#[from] SpendAuthorizationError), /// File-system I/O error reaching the database or backup files. #[error("io error")] Io(#[from] std::io::Error), @@ -422,6 +426,16 @@ pub enum WalletStorageError { blob_height: Option, }, + /// Aliases of one canonical transaction disagree about their confirmed block. + #[error( + "history aliases for {txid} in wallet {} have conflicting contexts", + hex::encode(wallet_id) + )] + HistoryAliasContextConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + /// An incoming transaction record reuses a stored txid with a body whose /// txid-committed content differs (witness-only differences are not a /// conflict); neither copy is trusted to replace the other. @@ -790,6 +804,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::HistoryAliasContextConflict { .. } | Self::TransactionBodyConflict { .. } | Self::UnknownWalletNetwork { .. } | Self::NetAmountOverflow { .. } @@ -806,7 +821,8 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::DatabasePathIsSymlink { .. } => false, + | Self::DatabasePathIsSymlink { .. } + | Self::SpendAuthorization(_) => false, } } @@ -840,9 +856,9 @@ impl WalletStorageError { Self::IdentityKeyWalletMismatch { .. } => PersistenceErrorKind::Constraint, // History invariants checked in Rust on the write path: the incoming // record contradicts stored history, so the data is wrong, not the engine. - Self::TransactionBodyConflict { .. } | Self::NetAmountOverflow { .. } => { - PersistenceErrorKind::Constraint - } + Self::HistoryAliasContextConflict { .. } + | Self::TransactionBodyConflict { .. } + | Self::NetAmountOverflow { .. } => PersistenceErrorKind::Constraint, // Refinery surfaces FK / constraint problems through rusqlite; // if that path leaks through here the typed variant lives in // `Self::Migration`, which we leave as `Fatal` since a @@ -916,7 +932,8 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, + | Self::DatabasePathIsSymlink { .. } + | Self::SpendAuthorization(_) => PersistenceErrorKind::Fatal, } } @@ -984,6 +1001,7 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::HistoryAliasContextConflict { .. } => "history_alias_context_conflict", Self::TransactionBodyConflict { .. } => "transaction_body_conflict", Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", Self::NetAmountOverflow { .. } => "net_amount_overflow", @@ -1007,6 +1025,7 @@ impl WalletStorageError { Self::EmptyUtxoScript { .. } => "empty_utxo_script", Self::EmptyPoolAddressScript { .. } => "empty_pool_address_script", Self::DatabasePathIsSymlink { .. } => "database_path_is_symlink", + Self::SpendAuthorization(_) => "spend_authorization", } } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs new file mode 100644 index 00000000000..9382fed8e7b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs @@ -0,0 +1,253 @@ +//! SQL adapter for wallet-owned replay effects. + +use crate::sqlite::util::safe_cast::{i64_to_u32, i64_to_u64}; +use dashcore::{OutPoint, ScriptBuf, TxOut, Txid}; +use platform_wallet::manager::history_replay::{ + HistoryReplayEffects, ReplayClaimEffect, StoredReplayClaim, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Connection, Transaction}; +use std::collections::BTreeMap; + +use crate::sqlite::schema::blob; +use crate::WalletStorageError; + +/// Decode durable claim provenance from the same snapshot as wallet history. +pub(crate) fn load_spent_claims( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + // SQLite evaluates every result column when the row is stepped, but a + // CASE lazily: a payload past its gate is never loaded, and the length + // beside it reports the typed error below. + let mut stmt = conn.prepare_cached( + "SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, \ + length(spent_in_txid), \ + CASE WHEN length(spent_in_txid) = 32 THEN spent_in_txid END, winner_mined_height \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 1", + )?; + let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); + let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; + let mut outpoints = Vec::new(); + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let bytes: Vec = row.get(1)?; + let claimant = if let Some(length) = row.get::<_, Option>(2)? { + blob::check_fixed_width(length, 32, "core_utxos.spent_in_txid")?; + let raw: Vec = row.get(3)?; + Some(Txid::from_slice(&raw)?) + } else { + None + }; + outpoints.push(StoredReplayClaim { + outpoint: blob::decode_outpoint(&bytes)?, + claimant, + mined_height: row + .get::<_, Option>(4)? + .map(|height| i64_to_u32("core_utxos.winner_mined_height", height)) + .transpose()?, + }); + } + Ok(outpoints) +} + +/// Stored coin material is validated against wallet address ownership by the owner. +pub(crate) fn load_prevouts( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut coins = BTreeMap::new(); + let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); + let mut stmt = conn.prepare_cached("SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, value, length(script), CASE WHEN length(script) <= ?2 THEN script END FROM core_utxos WHERE wallet_id = ?1 AND is_sweep_placeholder = 0")?; + let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + blob::check_size(row.get(3)?)?; + let outpoint: Vec = row.get(1)?; + let script: Vec = row.get(4)?; + coins.insert( + blob::decode_outpoint(&outpoint)?, + TxOut { + value: i64_to_u64("core_utxos.value", row.get(2)?)?, + script_pubkey: ScriptBuf::from_bytes(script), + }, + ); + } + Ok(coins) +} + +/// Apply storage-independent effects without selecting claimants in SQL. +pub(crate) fn apply_effects( + tx: &Transaction<'_>, + wallet_id: &WalletId, + effects: &HistoryReplayEffects, +) -> Result<(), WalletStorageError> { + for txid in &effects.removed_records { + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + } + for txid in &effects.removed_evidence { + let key = params![wallet_id.as_slice(), txid.as_byte_array().as_slice()]; + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + key, + )?; + // The encoded outpoint's transaction ID occupies bytes 2..=33. + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", + key, + )?; + } + // Row deletion can clear claimant links; exact owner effects restore them last. + for (outpoint, effect) in &effects.claims { + let key = blob::encode_outpoint(outpoint)?; + match effect { + ReplayClaimEffect::Remove => { + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), key], + )?; + } + ReplayClaimEffect::Release => { + tx.execute("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 1", params![wallet_id.as_slice(), key])?; + tx.execute("UPDATE core_utxos SET spent = 0, spent_in_txid = NULL, winner_mined_height = NULL WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet_id.as_slice(), key])?; + } + ReplayClaimEffect::Hold { + claimant, + mined_height, + } => { + tx.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, is_sweep_placeholder, spent, spent_in_txid, winner_mined_height) VALUES (?1, ?2, 0, X'', 1, 1, ?3, ?4) ON CONFLICT(wallet_id, outpoint) DO UPDATE SET spent = 1, spent_in_txid = excluded.spent_in_txid, winner_mined_height = excluded.winner_mined_height", + params![wallet_id.as_slice(), key, claimant.map(|id| id.to_byte_array().to_vec()), mined_height.map(i64::from)])?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migrations; + use platform_wallet::manager::history_replay::{HistoryReplayEffects, ReplayClaimEffect}; + + fn database() -> Connection { + let mut connection = Connection::open_in_memory().unwrap(); + migrations::run(&mut connection).unwrap(); + for id in [[1u8; 32], [2u8; 32]] { + connection.execute("INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![id.as_slice()]).unwrap(); + } + connection + } + + fn point(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), 0) + } + + fn seed_coin(connection: &Connection, wallet: &[u8; 32], point: OutPoint) { + connection.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, spent_in_txid, winner_mined_height) VALUES (?1, ?2, 100, X'', 1, ?3, 99)", + params![wallet.as_slice(), blob::encode_outpoint(&point).unwrap(), point.txid.as_byte_array().as_slice()]).unwrap(); + } + + #[test] + fn should_apply_exact_owner_claims_without_inheriting_a_removed_stamp() { + let mut connection = database(); + let held = point(3); + let released = point(4); + let missing = point(5); + for id in [[1u8; 32], [2u8; 32]] { + seed_coin(&connection, &id, held); + seed_coin(&connection, &id, released); + } + let effects = HistoryReplayEffects { + claims: [ + ( + held, + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + }, + ), + (released, ReplayClaimEffect::Release), + ( + missing, + ReplayClaimEffect::Hold { + claimant: Some(point(6).txid), + mined_height: Some(101), + }, + ), + ] + .into(), + ..Default::default() + }; + let tx = connection.transaction().unwrap(); + apply_effects(&tx, &[1; 32], &effects).unwrap(); + tx.commit().unwrap(); + let row = |wallet: [u8; 32], + point: OutPoint| + -> (bool, Option>, Option, bool) { + connection.query_row("SELECT spent, spent_in_txid, winner_mined_height, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet.as_slice(), blob::encode_outpoint(&point).unwrap()], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))).unwrap() + }; + assert_eq!(row([1; 32], held), (true, None, None, false)); + assert_eq!(row([1; 32], released), (false, None, None, false)); + assert_eq!( + row([1; 32], missing), + ( + true, + Some(point(6).txid.to_byte_array().to_vec()), + Some(101), + true + ) + ); + assert_eq!( + row([2; 32], held), + ( + true, + Some(held.txid.to_byte_array().to_vec()), + Some(99), + false + ) + ); + } + + #[test] + fn should_roll_back_removals_when_a_later_claim_write_fails() { + let mut connection = database(); + let record = point(7).txid; + let held = point(8); + seed_coin(&connection, &[1; 32], held); + connection.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized) VALUES (?1, ?2, 1, 0)", params![[1u8; 32].as_slice(), record.as_byte_array().as_slice()]).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_replay_claim BEFORE UPDATE ON core_utxos WHEN NOT EXISTS (SELECT 1 FROM core_transactions) BEGIN SELECT RAISE(ABORT, 'replay claim fault'); END;").unwrap(); + { + let tx = connection.transaction().unwrap(); + let effects = HistoryReplayEffects { + removed_records: [record].into(), + claims: [( + held, + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + }, + )] + .into(), + ..Default::default() + }; + let error = apply_effects(&tx, &[1; 32], &effects).unwrap_err(); + assert!( + matches!(&error, WalletStorageError::Sqlite(cause) if cause.to_string().contains("replay claim fault")), + "{error:?}" + ); + } + assert_eq!( + connection + .query_row( + "SELECT count(*) FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![[1u8; 32].as_slice(), record.as_byte_array().as_slice()], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs index 025b15007ef..f37ae520a5f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs @@ -329,6 +329,12 @@ impl LoadCtx { } } + /// Recheck an already-counted snapshot under the same policy without + /// adding its inconsistencies to the load's original tally again. + pub(crate) fn for_recheck(&self) -> Self { + Self::new(self.policy) + } + /// Context that aborts the load on any inconsistency. A production /// load takes its policy from the config; this is the shorthand for a /// caller driving a reader directly. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index a753eafe1c5..448f05d24d6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -12,6 +12,7 @@ use refinery_core::error::WrapMigrationError; mod legacy_v008; mod v020; +mod v021; // Generates a `migrations` module with `runner()`; path is relative to // the crate root. @@ -53,6 +54,7 @@ fn run_with_runner( registration_sql: hook_sql(8), pool_sql: hook_sql(11), accounting_sql: hook_sql(20), + rescan_sql: hook_sql(21), policy, }; // Grouped reports never claim that rolled-back migrations were applied. @@ -69,6 +71,7 @@ struct MigrationTransaction<'conn> { registration_sql: String, pool_sql: String, accounting_sql: String, + rescan_sql: String, policy: LoadPolicy, } @@ -88,6 +91,8 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { legacy_v008::convert_pools(&self.tx)?; } else if query == self.accounting_sql { v020::backfill_accounting(&self.tx, self.policy)?; + } else if query == self.rescan_sql { + v021::rescan(&self.tx, self.policy)?; } count += 1; } @@ -439,6 +444,7 @@ pub fn embedded_migrations_sql() -> Vec { pub(crate) fn rewind_v020(conn: &rusqlite::Connection) { conn.execute_batch( "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DROP INDEX IF EXISTS idx_core_utxos_funding; DROP INDEX IF EXISTS idx_core_utxos_claimant; \ DELETE FROM refinery_schema_history WHERE version >= 20;", ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs index 7673576fe99..3b816a3ac28 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs @@ -199,7 +199,12 @@ mod tests { let mut conn = wallet_db(&wallet); conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 200, 1, ?3)", params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), bytes]).unwrap(); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Strict, + ) + .unwrap(); let remaining: (i64, i64) = conn.query_row("SELECT (SELECT count(*) FROM core_transactions), (SELECT count(*) FROM core_transaction_inputs)", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); assert_eq!(remaining.0, 1, "{name}: mined history must remain"); assert_eq!(remaining.1, record.transaction.input.len() as i64); @@ -292,7 +297,12 @@ mod tests { let mut conn = wallet_db(&wallet); conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, NULL, 0, ?3)", params![wallet.as_slice(), txid, bytes]).unwrap(); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Strict, + ) + .unwrap(); let indexed: (Vec, Vec) = conn .query_row( "SELECT txid, outpoint FROM core_transaction_inputs", @@ -329,8 +339,12 @@ mod tests { .expect("Recovery skips oversized bodies"); } rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Recovery) - .expect("Recovery completes V020"); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Recovery, + ) + .expect("Recovery completes V020"); let count: i64 = conn .query_row("SELECT count(*) FROM core_transaction_inputs", [], |r| { r.get(0) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs new file mode 100644 index 00000000000..377256f51ce --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs @@ -0,0 +1,161 @@ +//! Rewind coverage without discarding any wallet's unresolved InstantSend evidence. + +use dashcore::Txid; +use key_wallet::transaction_checking::TransactionContext; +use rusqlite::{params, Row, Transaction}; + +use crate::sqlite::config::LoadPolicy; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{asset_locks, blob, core_state, wallets}; +use crate::sqlite::util::safe_cast::{i64_to_u32, u64_to_i64}; + +pub(super) fn rescan(tx: &Transaction<'_>, policy: LoadPolicy) -> Result<(), WalletStorageError> { + rewind_sync_heights(tx, policy)?; + tx.execute_batch( + "CREATE TEMP TABLE core_rescan_preserved_wallets (wallet_id BLOB PRIMARY KEY)", + )?; + for wallet_id in wallets::list_ids(tx)? { + let has_raw: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM core_instant_locks WHERE wallet_id=?1) + OR EXISTS(SELECT 1 FROM asset_locks WHERE wallet_id=?1 AND status='is_locked')", + [wallet_id.as_slice()], + |row| row.get(0), + )?; + // These bytes preserve the entire conflict graph; they do not grant finality. + if has_raw + || has_record_observation(tx, &wallet_id, policy)? + || has_asset_observation(tx, &wallet_id, policy)? + { + tx.execute( + "INSERT INTO core_rescan_preserved_wallets(wallet_id) VALUES (?1)", + [wallet_id.as_slice()], + )?; + } + } + tx.execute_batch( + "DELETE FROM core_utxos WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) AND NOT ( + EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_utxos.wallet_id + AND t.txid = substr(core_utxos.outpoint, 2, 32) AND t.height IS NULL) + OR (spent = 1 AND winner_mined_height IS NULL AND NOT EXISTS ( + SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_utxos.wallet_id + AND t.txid = core_utxos.spent_in_txid AND t.height IS NOT NULL)) + ); + DELETE FROM core_instant_locks WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND NOT EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_instant_locks.wallet_id + AND t.txid = core_instant_locks.txid AND t.height IS NULL); + DELETE FROM core_transaction_inputs WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_transaction_inputs.wallet_id + AND t.txid = core_transaction_inputs.txid AND t.height IS NOT NULL); + DELETE FROM core_transactions WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND height IS NOT NULL AND NOT EXISTS (SELECT 1 FROM core_utxos u + WHERE u.wallet_id = core_transactions.wallet_id AND substr(u.outpoint, 2, 32) = core_transactions.txid); + UPDATE core_transactions SET block_hash = NULL, block_time = NULL, finalized = 0, record_blob = NULL + WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) AND height IS NOT NULL; + DROP TABLE core_rescan_preserved_wallets;", + )?; + Ok(()) +} + +fn has_record_observation( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, +) -> Result { + scan_observations(tx, wallet_id, policy, + "SELECT CASE WHEN typeof(record_blob)='blob' THEN length(record_blob) END, + CASE WHEN typeof(record_blob)='blob' AND length(record_blob)<=?2 THEN record_blob END, + length(txid), CASE WHEN length(txid)=32 THEN txid END, height + FROM core_transactions WHERE wallet_id=?1 AND record_blob IS NOT NULL", + |bytes, row| { + let record = core_state::decode_transaction_record(bytes)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid = Txid::from_slice(&row.get::<_, Vec>(3)?)?; + let height = row.get::<_, Option>(4)? + .map(|height| i64_to_u32("core_transactions.height", height)).transpose()?; + core_state::ensure_transaction_record_matches_columns(&txid, height, &record)?; + Ok(matches!(record.context, TransactionContext::InstantSend(_))) + }) +} + +fn has_asset_observation( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, +) -> Result { + scan_observations(tx, wallet_id, policy, + "SELECT CASE WHEN typeof(lifecycle_blob)='blob' THEN length(lifecycle_blob) END, + CASE WHEN typeof(lifecycle_blob)='blob' AND length(lifecycle_blob)<=?2 THEN lifecycle_blob END + FROM asset_locks WHERE wallet_id=?1", + |bytes, _| asset_locks::has_instant_observation(bytes)) +} + +fn scan_observations( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, + query: &str, + decode: impl Fn(&[u8], &Row<'_>) -> Result, +) -> Result { + let mut stmt = tx.prepare(query)?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + u64_to_i64("record_blob.limit", blob::BLOB_SIZE_LIMIT_BYTES as u64)? + ])?; + while let Some(row) = rows.next()? { + let length = row.get::<_, Option>(0)?; + let decoded = match length { + Some(length) => blob::check_size(length).and_then(|()| { + let bytes: Vec = row.get(1)?; + decode(&bytes, row) + }), + None => Err(WalletStorageError::blob_decode( + "rescan evidence must be a BLOB", + )), + }; + match decoded { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(error) if policy == LoadPolicy::Recovery && !error.is_transient() => { + tracing::warn!(wallet_id = %hex::encode(wallet_id), %error, + "V021 preserved wallet history because finality evidence is unreadable"); + return Ok(true); + } + Err(error) => return Err(error), + } + } + Ok(false) +} + +/// Validate every birth height before V021 can discard mined history. +fn rewind_sync_heights(tx: &Transaction<'_>, policy: LoadPolicy) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare("SELECT rowid, birth_height FROM wallets")?; + let mut rows = stmt.query([])?; + let mut rewinds = Vec::new(); + while let Some(row) = rows.next()? { + let rowid: i64 = row.get(0)?; + let stored_birth = row.get::<_, i64>(1).map_err(WalletStorageError::from); + let (rewind, full_rescan) = match stored_birth + .and_then(|birth| i64_to_u32("wallets.birth_height", birth)) + { + Ok(birth) => (birth.saturating_sub(1), false), + Err(error) => match policy { + LoadPolicy::Strict => return Err(error), + LoadPolicy::Recovery => { + tracing::warn!(rowid, error_kind = error.error_kind_str(), %error, + "V021 rewound the sync watermark to zero for an invalid birth height; wallet metadata is unchanged"); + (0, true) + } + }, + }; + rewinds.push((rowid, rewind, full_rescan)); + } + for (rowid, rewind, full_rescan) in rewinds { + tx.execute( + "UPDATE core_sync_state SET synced_height = CASE WHEN ?3 THEN 0 + ELSE MIN(COALESCE(synced_height, ?2), ?2) END + WHERE wallet_id = (SELECT wallet_id FROM wallets WHERE rowid = ?1)", + params![rowid, rewind, full_rescan], + )?; + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs index 6ed1f6a26ee..034c1c69f17 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod buffer; pub mod config; pub(crate) mod conn; pub mod error; +mod history_replay; #[cfg(feature = "kv")] pub mod kv; pub mod persister; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 0a20a39d8ab..a74f74e6ccd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,10 +7,14 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use dpp::prelude::Identifier; +use platform_wallet::changeset::recorded_history::ReplayEvidence; use platform_wallet::changeset::{ ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use platform_wallet::manager::history_replay::{ + replay_wallet_history_blocking, HistoryReplay, HistoryReplayError, HistoryReplayLoad, +}; use platform_wallet::wallet::identity::ManagedIdentity; use platform_wallet::wallet::platform_wallet::WalletId; @@ -23,9 +27,9 @@ use crate::sqlite::rehydrate::{ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, }; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; -use crate::sqlite::schema; use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure}; use crate::sqlite::util::safe_cast; +use crate::sqlite::{history_replay, schema}; /// Persisted-but-not-rehydrated areas, surfaced in the structured /// `tracing::info!` summary on every `load()`. @@ -1313,6 +1317,8 @@ impl PlatformWalletPersistence for SqlitePersister { // full restore remains lossy. Shielded viewing keys are native when // compiled in; notes, nullifiers, and sync state use ShieldedStore. // WALLET_DRAIN folds in WALLET_RESTORE, so it stays unattested too. + // TODO(operation-tombstones): replay cleanup can erase operation UUIDs; + // ASSET_LOCK_OPERATION_IDS requires durable evidence distinguishing that from never sent. let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -1320,10 +1326,11 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) - .union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - .union(PersistenceCapabilities::DASHPAY_PAYMENTS); + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) + // `load` replays every stored record itself, inside its transaction. + .union(PersistenceCapabilities::CORE_HISTORY_RESTORE); #[cfg(feature = "shielded")] { capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) @@ -1517,12 +1524,145 @@ impl PlatformWalletPersistence for SqlitePersister { /// # } /// ``` fn load(&self) -> Result { + self.load_with_replay(&ReplayEvidence::default()) + .map(|loaded| loaded.state) + } + + fn replay_lock_candidates(&self) -> Result, PersistenceError> { + let conn = self.conn().map_err(PersistenceError::from)?; + let ctx = LoadCtx::new(self.config.load_policy); + let mut candidates = Vec::new(); + for wallet_id in schema::wallets::list_ids(&conn).map_err(PersistenceError::from)? { + let state = load_wallet_snapshot(&conn, wallet_id, &ctx)?; + candidates.extend( + platform_wallet::changeset::recorded_history::replay_lock_candidates(&state), + ); + } + Ok(candidates) + } + + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let conn = self.conn().map_err(PersistenceError::from)?; + let ctx = LoadCtx::new(self.config.load_policy); + if schema::wallets::fetch(&conn, &wallet_id) + .map_err(PersistenceError::from)? + .is_none() + { + return Ok(HistoryReplayLoad::default()); + } + let addresses = schema::platform_addrs::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let (wallet, outcome) = + load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy, evidence)?; + let mut loaded = HistoryReplayLoad::default(); + loaded.state.wallets.insert(wallet_id, wallet); + loaded.state.platform_addresses.insert(wallet_id, addresses); + loaded.outcomes.insert(wallet_id, outcome); + self.replace_load_degradation(ctx.degradation()); + Ok(loaded) + } + + fn load_with_replay( + &self, + evidence: &ReplayEvidence, + ) -> Result { + self.load_client_snapshot(Some(evidence)) + } + + fn load_read_snapshot(&self) -> Result { + self.load_client_snapshot(None).map(|loaded| loaded.state) + } + + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + let mut conn = self.conn().map_err(PersistenceError::from)?; + // Tally deliberately dropped — see `last_load_degradation`. The + // policy still decides whether drift is fatal, and `tolerate` still + // logs it. + let ctx = LoadCtx::new(self.config.load_policy); + // Alias evidence and its active canonical record must belong to one + // SQLite snapshot, including when another connection writes the file. + let tx = conn.transaction().map_err(WalletStorageError::from)?; + let record = schema::core_history_aliases::get_record(&tx, &wallet_id, txid, &ctx)?; + tx.commit().map_err(WalletStorageError::from)?; + Ok(record) + } + + /// Served from the `dpns_name_states` table this persister already + /// writes (`DPNS_NAME_STATES` is attested above), so the marketplace + /// sync pass can recover a departed name's `document_id` after a + /// restart instead of orphaning the row. + /// + /// Reads the *committed* table. In [`FlushMode::Manual`] a row that + /// is still sitting in the write buffer is not visible yet and this + /// returns `Ok(None)` — the caller degrades to the pre-fix behaviour + /// for that one name, never to a wrong id. Not a concern for the + /// departure path: it is looking for a row written by an earlier + /// session, and the default [`FlushMode::Immediate`] commits on every + /// `store`. + fn get_dpns_name_state( + &self, + wallet_id: WalletId, + wallet_identity_id: &dpp::prelude::Identifier, + normalized_label: &str, + ) -> Result, PersistenceError> { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::dpns_name_states::get_by_identity_and_label( + &conn, + &wallet_id, + wallet_identity_id, + normalized_label, + ) + .map_err(PersistenceError::from) + } +} + +/// Count of top-level changeset slots carrying data, for the +/// `restored_field_count` / `dropped_field_count` / `pending_field_count` +/// tracing fields. Computed +/// from the public fields so no storage-only helper leaks into the +/// `rs-platform-wallet` API. +fn populated_field_count(cs: &PendingWrites) -> usize { + // Single source of truth with the version-domain mapping: each populated + // field is exactly one touched domain. + cs.segments + .iter() + .map(|segment| schema::versions::touched_domains(segment).len()) + .sum() +} + +/// Total rows sitting in the tables `load()` has no reader for. +/// +/// Informational, not a degradation: the data is intact and a future +/// reader can pick it up — it simply is not in this `ClientStartState`. +/// One statement, not one per table: `tc_p4_012` holds `load()`'s +/// wallet-count-independent statement count to a small constant. +/// Rehydrate one wallet, or fail without touching any other. +/// +/// Extracted from `load()`'s loop so the loop has somewhere to put a +/// boundary: every `?` in here ends this wallet, not the file. +impl SqlitePersister { + fn load_client_snapshot( + &self, + evidence: Option<&ReplayEvidence>, + ) -> Result { let conn = self.conn().map_err(PersistenceError::from)?; let ctx = LoadCtx::new(self.config.load_policy); // Cleared up front so a failed load never leaves the previous load's // snapshot behind, masquerading as this one's verdict. self.replace_load_degradation(LoadDegradation::default()); let mut state = ClientStartState::default(); + let mut outcomes = BTreeMap::new(); #[cfg(feature = "shielded")] { @@ -1568,9 +1708,28 @@ impl PlatformWalletPersistence for SqlitePersister { if unreadable.contains(&wallet_id) { continue; } - match load_one_wallet(&conn, wallet_id, &ctx) { - Ok(wallet_state) => { + let restored = match evidence { + Some(evidence) => { + load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy, evidence) + } + None => { + let tx = rusqlite::Transaction::new_unchecked( + &conn, + rusqlite::TransactionBehavior::Deferred, + ) + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + let result = load_wallet_snapshot(&tx, wallet_id, &ctx); + tx.rollback() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + result.map(|state| (state, HistoryReplay::default())) + } + }; + match restored { + Ok((wallet_state, outcome)) => { state.wallets.insert(wallet_id, wallet_state); + outcomes.insert(wallet_id, outcome); } // The isolation boundary. One wallet's failure is recorded // against that wallet and the walk continues; under Strict @@ -1578,6 +1737,11 @@ impl PlatformWalletPersistence for SqlitePersister { // rather than the boundary's own wrapper, so a caller // matching on a specific cause still sees it. Err(original) => { + if matches!(&original, PersistenceError::Backend { source, .. } + if matches!(source.downcast_ref::(), Some(HistoryReplayError::MissingFinalityEvidence { .. }))) + { + return Err(original); + } let cause = wallet_storage_kind(&original); record_wallet_degradation(&ctx, wallet_id, cause, &original) .map_err(|_| original)?; @@ -1608,92 +1772,84 @@ impl PlatformWalletPersistence for SqlitePersister { "load() summary" ); self.replace_load_degradation(degradation); - Ok(state) - } - - fn get_core_tx_record( - &self, - wallet_id: WalletId, - txid: &dashcore::Txid, - ) -> Result< - Option, - PersistenceError, - > { - let conn = self.conn().map_err(PersistenceError::from)?; - // Tally deliberately dropped — see `last_load_degradation`. The - // policy still decides whether drift is fatal, and `tolerate` still - // logs it. - let ctx = LoadCtx::new(self.config.load_policy); - schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) - .map_err(PersistenceError::from) - } - - /// Served from the `dpns_name_states` table this persister already - /// writes (`DPNS_NAME_STATES` is attested above), so the marketplace - /// sync pass can recover a departed name's `document_id` after a - /// restart instead of orphaning the row. - /// - /// Reads the *committed* table. In [`FlushMode::Manual`] a row that - /// is still sitting in the write buffer is not visible yet and this - /// returns `Ok(None)` — the caller degrades to the pre-fix behaviour - /// for that one name, never to a wrong id. Not a concern for the - /// departure path: it is looking for a row written by an earlier - /// session, and the default [`FlushMode::Immediate`] commits on every - /// `store`. - fn get_dpns_name_state( - &self, - wallet_id: WalletId, - wallet_identity_id: &dpp::prelude::Identifier, - normalized_label: &str, - ) -> Result, PersistenceError> { - let conn = self.conn().map_err(PersistenceError::from)?; - schema::dpns_name_states::get_by_identity_and_label( - &conn, - &wallet_id, - wallet_identity_id, - normalized_label, - ) - .map_err(PersistenceError::from) + Ok(HistoryReplayLoad { state, outcomes }) } } -/// Count of top-level changeset slots carrying data, for the -/// `restored_field_count` / `dropped_field_count` / `pending_field_count` -/// tracing fields. Computed -/// from the public fields so no storage-only helper leaks into the -/// `rs-platform-wallet` API. -fn populated_field_count(cs: &PendingWrites) -> usize { - // Single source of truth with the version-domain mapping: each populated - // field is exactly one touched domain. - cs.segments - .iter() - .map(|segment| schema::versions::touched_domains(segment).len()) - .sum() +fn replay_failure(error: HistoryReplayError) -> PersistenceError { + match error { + HistoryReplayError::SpendAuthorization(error) => { + WalletStorageError::SpendAuthorization(error).into() + } + error => PersistenceError::backend(error), + } } -/// Total rows sitting in the tables `load()` has no reader for. -/// -/// Informational, not a degradation: the data is intact and a future -/// reader can pick it up — it simply is not in this `ClientStartState`. -/// One statement, not one per table: `tc_p4_012` holds `load()`'s -/// wallet-count-independent statement count to a small constant. -/// Rehydrate one wallet, or fail without touching any other. -/// -/// Extracted from `load()`'s loop so the loop has somewhere to put a -/// boundary: every `?` in here ends this wallet, not the file. fn load_one_wallet( conn: &Connection, wallet_id: WalletId, ctx: &LoadCtx, -) -> Result { - let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Deferred) + policy: LoadPolicy, + evidence: &ReplayEvidence, +) -> Result< + ( + platform_wallet::changeset::ClientWalletStartState, + HistoryReplay, + ), + PersistenceError, +> { + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) .map_err(WalletStorageError::from) .map_err(PersistenceError::from)?; - let state = load_wallet_snapshot(&tx, wallet_id, ctx)?; - tx.commit() - .map_err(WalletStorageError::from) - .map_err(PersistenceError::from)?; - Ok(state) + let mut state = load_wallet_snapshot(&tx, wallet_id, ctx)?; + let mut outcome = + replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; + let records = state.recorded_history.transactions.clone(); + let mut claims = + history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; + let effects = outcome + .storage_effects(&records, &claims) + .map_err(replay_failure)?; + if !effects.removed_records.is_empty() + || !effects.removed_evidence.is_empty() + || !effects.claims.is_empty() + { + let operations = effects.operation_effects(&state); + history_replay::apply_effects(&tx, &wallet_id, &effects).map_err(PersistenceError::from)?; + schema::asset_locks::apply(&tx, &wallet_id, &operations.asset_locks) + .map_err(PersistenceError::from)?; + schema::dashpay::apply(&tx, &wallet_id, None, Some(&operations.payments)) + .map_err(PersistenceError::from)?; + state = load_wallet_snapshot(&tx, wallet_id, ctx)?; + let remaining = + replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; + state.unconfirmed_outgoing_txs = outcome.resendable(state.unconfirmed_outgoing_txs); + if !remaining.sweeps.is_empty() + || !remaining.authorization.rejected.is_empty() + || !remaining.withheld_asset_lock_inputs.is_empty() + { + return Err(PersistenceError::backend( + HistoryReplayError::UnsettledAfterReplay, + )); + } + claims = + history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; + } + state.wallet_info.restore_spent_outpoints( + &claims + .iter() + .map(|claim| (claim.outpoint, claim.claimant)) + .collect::>(), + ); + outcome.read_only = policy == LoadPolicy::Recovery; + if outcome.read_only { + tx.rollback() + } else { + tx.commit() + } + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + Ok((state, outcome)) } fn load_wallet_snapshot( @@ -1719,9 +1875,26 @@ fn load_wallet_snapshot( let account_manifest = schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; - let (core_state, utxo_accounts) = - schema::core_state::load_state(conn, &wallet_id, network, ctx) - .map_err(PersistenceError::from)?; + let schema::core_state::CoreStateSnapshot { + changes: mut core_state, + utxo_accounts, + record_keys, + } = schema::core_state::load_snapshot(conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; + // Replay effects address physical rows; history projection may deduplicate aliases. + let recorded_transactions = core_state + .records + .iter() + .cloned() + .zip(record_keys) + .map(|(record, key)| { + let mut stored = platform_wallet::changeset::StoredTransaction::from(record); + stored.txid = key; + stored + }) + .collect(); + schema::core_history_aliases::project_records(conn, &wallet_id, &mut core_state.records, ctx) + .map_err(PersistenceError::from)?; // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader // already carrying its own public keys + contact state (matching // the FFI persister), so signing works immediately post-load @@ -1866,6 +2039,18 @@ fn load_wallet_snapshot( )) })?; } + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + for record in core_state + .records + .iter() + .filter(|record| record.account_type == owner) + { + account + .transactions_mut() + .insert(record.txid, record.clone()); + } + } Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, @@ -1876,6 +2061,13 @@ fn load_wallet_snapshot( // replay inert here, which is the behaviour this path had before the // field existed. unconfirmed_outgoing_txs: Vec::new(), + recorded_history: platform_wallet::changeset::RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: history_replay::load_prevouts(conn, &wallet_id) + .map_err(PersistenceError::from)?, + transactions: recorded_transactions, + instant_locks: core_state.instant_locks_for_non_final_records, + }, // Not persisted by this backend yet: the backfill record rides the // FFI hosts' wallet row only. Empty keeps the rescan sweep on its // pre-record behaviour here (a rewind per launch, never lossy). diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 46af1bc4a5e..c8c7ffac917 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -300,12 +300,6 @@ pub fn apply_persisted_core_state( wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); } - // INTENTIONAL(tx-record-rehydration-gap): `core` also carries transaction - // records, but they cannot be replayed here — injecting one needs the raw - // `dashcore::Transaction`, and this crate persists only the abstracted - // `TransactionRecord` blob. History re-warms on the next scan and is not a - // balance input. - // Restore the UTXO set, routing each unspent outpoint to its true owning // funds account via `utxo_accounts` (matched on the same account identity // the writer keyed the pool row on). Two miss cases share the first-account @@ -405,14 +399,6 @@ pub fn apply_persisted_core_state( } } - // Replay persisted InstantSend locks AFTER the UTXO restore: this marks the - // UTXOs it finds, so running it earlier would record the txid and mark - // nothing. Without it, instant-locked funds come back as merely confirmed - // and stay that way until the next sync re-learns the lock. - for (txid, lock) in &core.instant_locks_for_non_final_records { - wallet_info.mark_instant_send_utxos(txid, lock); - } - // Recompute per-account + wallet balance from the restored set. // After this, a non-zero persisted balance is non-zero here — a // silent zero would be a hard FAIL of the rehydration contract. @@ -3045,15 +3031,10 @@ mod tests { ); } - /// A restored UTXO whose transaction carried an InstantSend lock must come - /// back instant-locked, not wait for the next sync to re-learn it. The - /// persisted `core_instant_locks` rows arrive in - /// `CoreChangeSet::instant_locks_for_non_final_records`, and replaying them - /// has to happen AFTER the UTXO restore — `mark_instant_send_utxos` marks - /// the UTXOs it can find, so calling it first would insert the txid and - /// mark nothing. + /// Raw restoration preserves the coin but cannot authenticate saved lock bytes. + /// Verified positive upgrades are exercised through the shared replay owner. #[test] - fn rehydration_restores_instant_send_locks_onto_restored_utxos() { + fn should_not_restore_instant_send_finality_from_unverified_lock_rows() { use dashcore::blockdata::transaction::txout::TxOut; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::{OutPoint, Txid}; @@ -3100,8 +3081,7 @@ mod tests { let txid = Txid::from([0x5Au8; 32]); let outpoint = OutPoint { txid, vout: 0 }; - // `is_instantlocked: false` is the persisted shape: the flag is not a - // stored column, it is re-derived from the `core_instant_locks` row. + // The persisted coin starts without authenticated InstantSend evidence. let utxo = Utxo { outpoint, txout: TxOut { @@ -3147,8 +3127,8 @@ mod tests { .unwrap(); assert!( - wallet_info.instant_send_locks().contains(&txid), - "the persisted InstantSend lock must be replayed onto the wallet" + !wallet_info.instant_send_locks().contains(&txid), + "raw persisted bytes cannot establish InstantSend finality" ); let restored = wallet_info .accounts @@ -3157,8 +3137,8 @@ mod tests { .and_then(|a| a.utxos.get(&outpoint)) .expect("the restored UTXO must be present on the BIP44 account"); assert!( - restored.is_instantlocked, - "the restored UTXO must carry instant-locked status, not wait for the next sync" + !restored.is_instantlocked, + "the shared replay needs matching verified evidence before upgrading the coin" ); } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index ced3c0e2ec6..c124ca4ec3f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -125,6 +125,14 @@ impl AssetLockEntryWire { } } +/// Detect an observation for migration preservation without accepting its finality. +pub(crate) fn has_instant_observation(bytes: &[u8]) -> Result { + let wire: AssetLockEntryWire = blob::decode(bytes)?; + let entry = wire.into_entry()?; + Ok(entry.status == AssetLockStatus::InstantSendLocked + || matches!(entry.proof, Some(AssetLockProof::Instant(_)))) +} + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index d3279b72e33..c6e648eeec0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -12,7 +12,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use super::accounts::DASHPAY_EXTERNAL_LABEL; -use super::{blob, core_state, wallets}; +use super::{blob, core_history_aliases, core_state, wallets}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::LoadCtx; use crate::sqlite::util::safe_cast::i64_to_u64; @@ -23,10 +23,20 @@ pub(super) fn preserve_known_details( wallet_id: &WalletId, incoming: &TransactionRecord, ) -> Result { - let mut merged = incoming.clone(); + let merged = incoming.clone(); let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; + merge_known_details(wallet_id, &previous, incoming) +} + +/// Merge accounting evidence without assigning identity or finality policy. +pub(super) fn merge_known_details( + wallet_id: &WalletId, + previous: &TransactionRecord, + incoming: &TransactionRecord, +) -> Result { + let mut merged = incoming.clone(); // Compare only txid-committed content: a peer may attach BIP144 witnesses // to a known txid, and that must neither conflict nor replace the stored body. if previous.transaction.txid() != incoming.transaction.txid() { @@ -35,10 +45,11 @@ pub(super) fn preserve_known_details( txid: incoming.txid, }); } - merged.transaction = previous.transaction; + merged.transaction = previous.transaction.clone(); let mut inputs: BTreeMap<_, _> = previous .input_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.input_details { @@ -46,7 +57,8 @@ pub(super) fn preserve_known_details( } let mut outputs: BTreeMap<_, _> = previous .output_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.output_details { @@ -97,20 +109,62 @@ pub(crate) fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + apply_records(tx, wallet_id, cs, false) +} + +/// Project live producer spends; V020 accounting backfill never calls this path. +pub(super) fn apply_live( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + apply_records(tx, wallet_id, cs, true) +} + +fn apply_records( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, + attribute_spends: bool, ) -> Result<(), WalletStorageError> { let mut affected: HashSet = cs.records.iter().map(|r| r.txid).collect(); let mut consumers = tx.prepare_cached( "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", )?; - for utxo in cs.new_utxos.iter().chain(&cs.spent_utxos) { - affected.insert(utxo.outpoint.txid); - let mut rows = consumers.query(params![ - wallet_id.as_slice(), - blob::encode_outpoint(&utxo.outpoint)? - ])?; - while let Some(row) = rows.next()? { - let bytes: Vec = row.get(0)?; - affected.insert(Txid::from_slice(&bytes)?); + let mut funding: Vec<_> = cs + .new_utxos + .iter() + .chain(&cs.spent_utxos) + .map(|utxo| utxo.outpoint) + .collect(); + // Adoption can connect already-stored coins to consumers without a new + // coin delivery. Ordinary records keep their existing scheduling policy. + for record in cs + .records + .iter() + .filter(|record| core_history_aliases::is_unlock(record)) + { + funding.extend( + record + .transaction + .output + .iter() + .enumerate() + .map(|(index, _)| OutPoint::new(record.txid, index as u32)), + ); + } + for changed in funding { + for outpoint in core_history_aliases::outpoints(tx, wallet_id, &changed)? { + affected.insert(outpoint.txid); + let mut rows = consumers.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(&outpoint)? + ])?; + while let Some(row) = rows.next()? { + let bytes: Vec = row.get(0)?; + affected.insert(Txid::from_slice(&bytes)?); + } } } if affected.is_empty() { @@ -118,7 +172,7 @@ pub(crate) fn apply( } let network = network(tx, wallet_id)?; for txid in affected { - repair_record(tx, wallet_id, &txid, network)?; + repair_record(tx, wallet_id, &txid, network, attribute_spends)?; } Ok(()) } @@ -143,6 +197,46 @@ fn owned_output( wallet_id: &WalletId, outpoint: &OutPoint, network: dashcore::Network, +) -> Result, WalletStorageError> { + // Exact physical evidence keeps its existing semantics. Only a missing + // exact coin can use a validated active history family's equivalent key. + if let Some(output) = physical_owned_output(tx, wallet_id, outpoint, network)? { + return Ok(Some(output)); + } + let equivalents = core_history_aliases::outpoints(tx, wallet_id, outpoint)?; + for alternative in equivalents.into_iter().filter(|other| other != outpoint) { + if let Some((value, address)) = physical_owned_output(tx, wallet_id, &alternative, network)? + { + let record = core_history_aliases::get_record( + tx, + wallet_id, + &outpoint.txid, + &LoadCtx::strict(), + )? + .ok_or_else(|| { + WalletStorageError::blob_decode( + "active history alias disappeared during accounting", + ) + })?; + let output = record.transaction.output.get(outpoint.vout as usize); + if !output.is_some_and(|output| { + output.value == value && output.script_pubkey == address.script_pubkey() + }) { + return Err(WalletStorageError::blob_decode( + "alias accounting output disagrees with canonical transaction body", + )); + } + return Ok(Some((value, address))); + } + } + Ok(None) +} + +fn physical_owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, ) -> Result, WalletStorageError> { let encoded = blob::encode_outpoint(outpoint)?; let key = params![wallet_id.as_slice(), encoded]; @@ -195,6 +289,7 @@ fn repair_record( wallet_id: &WalletId, txid: &Txid, network: dashcore::Network, + attribute_spends: bool, ) -> Result<(), WalletStorageError> { let Some(mut record) = prior_record(tx, wallet_id, txid)? else { return Ok(()); @@ -218,6 +313,21 @@ fn repair_record( address, }, ); + // Only the live projection attributes confirmed spends; accounting + // backfill stays read-only with respect to coins and reservations. + if attribute_spends && !matches!(record.context, TransactionContext::Mempool) { + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } } } let mut outputs = BTreeMap::new(); @@ -264,23 +374,9 @@ fn repair_record( } record.input_details = inputs.into_values().collect(); record.output_details = outputs.into_values().collect(); - // The live projection computes the same accounting, so repair never - // flips a row it just wrote; only the overflow policy differs. - let (net, direction) = wallet_accounting(&record); - record.net_amount = match i64::try_from(net) { - Ok(net) => net, - // Nothing authenticates a mempool transaction: this is peer-supplied data, - // not corruption. Keep its stored accounting until representable evidence arrives. - Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(()), - Err(_) => { - return Err(WalletStorageError::NetAmountOverflow { - wallet_id: *wallet_id, - txid: *txid, - value: net, - }) - } - }; - record.direction = direction; + if !recompute_accounting(wallet_id, &mut record)? { + return Ok(()); + } let repaired = blob::encode(&record)?; if repaired != original { // Append-only: the first pre-repair blob is kept verbatim and never @@ -303,6 +399,35 @@ fn repair_record( Ok(()) } +/// Apply the existing history accounting policy to a merged snapshot. Returns +/// false when absent or unrepresentable mempool evidence cannot replace it. +pub(super) fn recompute_accounting( + wallet_id: &WalletId, + record: &mut TransactionRecord, +) -> Result { + if record.input_details.is_empty() && record.output_details.is_empty() { + return Ok(false); + } + // The live projection computes the same accounting, so repair never + // flips a row it just wrote; only the overflow policy differs. + let (net, direction) = wallet_accounting(record); + record.net_amount = match i64::try_from(net) { + Ok(net) => net, + // Nothing authenticates a mempool transaction: this is peer-supplied data, + // not corruption. Keep its stored accounting until representable evidence arrives. + Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(false), + Err(_) => { + return Err(WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: record.txid, + value: net, + }) + } + }; + record.direction = direction; + Ok(true) +} + #[cfg(test)] mod tests { use dashcore::TxOut; @@ -487,8 +612,14 @@ mod tests { .unwrap(); let tx = conn.transaction().unwrap(); - let err = - repair_record(&tx, &WALLET_ID, &stored.txid, dashcore::Network::Testnet).unwrap_err(); + let err = repair_record( + &tx, + &WALLET_ID, + &stored.txid, + dashcore::Network::Testnet, + false, + ) + .unwrap_err(); assert!( matches!( @@ -502,10 +633,10 @@ mod tests { ); } - /// Shared with the Swift SDK's `TransactionAccountingTests` direction - /// table; pins the rule repair takes from `platform_wallet`. + /// Direction case table; pins the rule repair takes from `platform_wallet`, + /// the same one the live projection and the host-history backfill use. #[test] - fn should_classify_repaired_direction_like_the_swift_sdk() { + fn should_classify_repaired_direction_like_the_live_projection() { use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; use TransactionType::{AssetLock, Standard}; // (type, spends ours, has owned output, has external output, expected) @@ -701,7 +832,14 @@ mod tests { let mut conn = wallet_db("testnet"); store(&conn, live); let tx = conn.transaction().unwrap(); - repair_record(&tx, &WALLET_ID, &live.txid, dashcore::Network::Testnet).unwrap(); + repair_record( + &tx, + &WALLET_ID, + &live.txid, + dashcore::Network::Testnet, + false, + ) + .unwrap(); let repaired = prior_record(&tx, &WALLET_ID, &live.txid).unwrap().unwrap(); assert_eq!( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs new file mode 100644 index 00000000000..d11e3103933 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs @@ -0,0 +1,369 @@ +//! Historical AssetUnlock identities, confined to history projection and accounting. +//! +//! The original-record archive proves identity only: it never supplies ownership, +//! context or a resurrected record. An active canonical record is required. No +//! financial outpoint, claimant, proof or confirmation column is rewritten here. + +use std::collections::BTreeSet; + +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{OutPoint, Txid}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use platform_wallet::changeset::most_advanced_transaction_context; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use super::{blob, core_history, core_state}; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::LoadCtx; + +pub(super) fn is_unlock(record: &TransactionRecord) -> bool { + (record.transaction.version as i16) >= 3 + && matches!(&record.transaction.special_transaction_payload, + Some(TransactionPayload::AssetUnlockPayloadType(payload)) if payload.base.version >= 2) +} + +fn validate(key: &Txid, payload: &[u8]) -> Result { + let record = core_state::decode_transaction_record(payload)?; + if record.txid != *key { + return Err(WalletStorageError::blob_decode( + "historical identity evidence disagrees with its physical key", + )); + } + Ok(record) +} + +fn archived( + conn: &Connection, + wallet: &WalletId, + key: &Txid, +) -> Result, WalletStorageError> { + let len: Option = conn.query_row( + "SELECT length(record_blob) FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + ).optional()?; + let Some(len) = len else { return Ok(None) }; + blob::check_size(len)?; + let bytes: Vec = conn.query_row( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + )?; + validate(key, &bytes).map(Some) +} + +struct Family { + record: TransactionRecord, + aliases: BTreeSet, + physical_aliases: Vec, +} + +impl Family { + /// Exceptional-path wallet scan: no schema change or unauthenticated alias + /// table. Current blobs supply metadata; archived blobs supply only IDs. + fn load( + conn: &Connection, + wallet: &WalletId, + canonical: &TransactionRecord, + ctx: &LoadCtx, + ) -> Result { + let canonical_id = canonical.transaction.txid(); + let mut family = Self { + record: canonical.clone(), + aliases: BTreeSet::from([canonical_id]), + physical_aliases: Vec::new(), + }; + // Query lengths first; no blob is materialized before its size gate. + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid, length(record_blob) FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + blob::check_size(row.get(2)?)?; + if key == canonical_id { + continue; + } + // The physical reader validates the body, key and context columns; + // public alias projection must never recursively call itself here. + let Some(record) = core_state::get_tx_record(conn, wallet, &key, ctx)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.merge(wallet, &record)?; + family.aliases.insert(key); + family.physical_aliases.push(key); + } + } + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid FROM core_transaction_record_originals WHERE wallet_id = ?1 ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transaction_record_originals.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + let Some(record) = archived(conn, wallet, &key)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.aliases.insert(key); + } + } + if !family.physical_aliases.is_empty() { + // A canonical row already present before adoption is also active + // evidence. With no legacy sibling, ordinary upsert policy applies. + if let Some(previous) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? { + family.merge(wallet, &previous)?; + } + core_history::recompute_accounting(wallet, &mut family.record)?; + } + Ok(family) + } + + fn merge( + &mut self, + wallet: &WalletId, + previous: &TransactionRecord, + ) -> Result<(), WalletStorageError> { + if let (Some(old), Some(new)) = (previous.block_info(), self.record.block_info()) { + if old.height() != new.height() + || old.block_hash() != new.block_hash() + || old.timestamp() != new.timestamp() + || matches!((old.position(), new.position()), (Some(a), Some(b)) if a != b) + { + return Err(WalletStorageError::HistoryAliasContextConflict { + wallet_id: *wallet, + txid: self.record.txid, + }); + } + } + let context = + most_advanced_transaction_context(&self.record.context, &previous.context).clone(); + let body = self.record.transaction.clone(); + self.record = core_history::merge_known_details(wallet, previous, &self.record)?; + self.record.transaction = body; + self.record.context = context; + if self.record.fee.is_none() { + self.record.fee = previous.fee; + } + if self.record.label.is_empty() { + self.record.label.clone_from(&previous.label); + } + Ok(()) + } +} + +/// Adopt only validated current writes. Legacy-only reads remain physically keyed. +pub(super) fn adopt( + tx: &Transaction<'_>, + wallet: &WalletId, + incoming: &TransactionRecord, +) -> Result { + let canonical = core_history::preserve_known_details(tx, wallet, incoming)?; + if !is_unlock(&canonical) { + return Ok(canonical); + } + let family = Family::load(tx, wallet, &canonical, &LoadCtx::strict())?; + for alias in &family.physical_aliases { + // Append before clearing. Existing archive entries were validated above; + // the first original stays byte-for-byte immutable. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) SELECT wallet_id, txid, record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + // Keep the physical anchor: DELETE would clear durable claimant links + // through V014's trigger. Height/proof/finality metadata is untouched. + tx.execute( + "UPDATE core_transactions SET record_blob = NULL WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + } + Ok(family.record) +} + +/// Public history lookup. An archive entry alone can never resurrect history. +pub(crate) fn get_record( + conn: &Connection, + wallet: &WalletId, + key: &Txid, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let physical = core_state::get_tx_record(conn, wallet, key, ctx)?; + let evidence = match &physical { + Some(record) => Some(record.clone()), + None => archived(conn, wallet, key)?, + }; + let Some(evidence) = evidence else { + return Ok(physical); + }; + if !is_unlock(&evidence) { + return Ok(physical); + } + let canonical_id = evidence.transaction.txid(); + let Some(canonical) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? else { + return Ok(physical); + }; + Family::load(conn, wallet, &canonical, ctx).map(|family| Some(family.record)) +} + +/// Deduplicate only adopted families; backend readers keep physical row identities. +pub(crate) fn project_records( + conn: &Connection, + wallet: &WalletId, + records: &mut Vec, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + let recheck = ctx.for_recheck(); + let canonical: Vec<_> = records + .iter() + .filter(|record| is_unlock(record) && record.txid == record.transaction.txid()) + .cloned() + .collect(); + for record in canonical { + let family = Family::load(conn, wallet, &record, &recheck)?; + records.retain(|record| { + record.txid == family.record.txid || !family.aliases.contains(&record.txid) + }); + if let Some(slot) = records + .iter_mut() + .find(|record| record.txid == family.record.txid) + { + *slot = family.record; + } + } + Ok(()) +} + +/// Equivalent accounting lookup keys, never rewritten financial outpoints. +pub(super) fn outpoints( + conn: &Connection, + wallet: &WalletId, + outpoint: &OutPoint, +) -> Result, WalletStorageError> { + let Some(record) = get_record(conn, wallet, &outpoint.txid, &LoadCtx::strict())? else { + return Ok(vec![*outpoint]); + }; + if !is_unlock(&record) || record.txid != record.transaction.txid() { + return Ok(vec![*outpoint]); + } + let family = Family::load(conn, wallet, &record, &LoadCtx::strict())?; + Ok(std::iter::once(*outpoint) + .chain( + family + .aliases + .into_iter() + .filter(|id| *id != outpoint.txid) + .map(|id| OutPoint::new(id, outpoint.vout)), + ) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migrations; + use crate::LoadPolicy; + + #[test] + fn should_project_existing_alias_duplicates_without_mutating_physical_history() { + let wallet = [0x65; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet.as_slice()], + ) + .unwrap(); + let legacy: TransactionRecord = blob::decode( + &hex::decode( + include_str!("../../../tests/fixtures/v019_asset_unlock_v2.txt") + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(2) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let mut canonical = legacy.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + canonical.net_amount = 0; + for record in [&legacy, &canonical] { + conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(record).unwrap()]).unwrap(); + } + let (mut raw, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!( + raw.records.len(), + 2, + "backend readers keep exact physical identities" + ); + project_records(&conn, &wallet, &mut raw.records, &LoadCtx::strict()).unwrap(); + assert_eq!(raw.records.len(), 1); + assert_eq!(raw.records[0].txid, canonical.txid); + assert_eq!(raw.records[0].output_details.len(), 1); + assert_eq!(raw.records[0].net_amount, 150_000); + for id in [legacy.txid, canonical.txid] { + let projected = get_record(&conn, &wallet, &id, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!( + blob::encode(&projected).unwrap(), + blob::encode(&raw.records[0]).unwrap() + ); + } + let (physical, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(physical.records.len(), 2); + assert_eq!( + core_state::get_tx_record(&conn, &wallet, &canonical.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .output_details + .len(), + 0 + ); + assert_eq!( + conn.query_row::( + "SELECT count(*) FROM core_transaction_record_originals", + [], + |row| row.get(0) + ) + .unwrap(), + 0 + ); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE txid = ?1", + params![legacy.txid.as_byte_array().as_slice()], + ) + .unwrap(); + let recovery = LoadCtx::recovery(); + let (mut raw, _) = + core_state::load_state(&conn, &wallet, dashcore::Network::Testnet, &recovery).unwrap(); + let before = recovery.degradation().total; + assert_eq!(before, 1); + project_records(&conn, &wallet, &mut raw.records, &recovery).unwrap(); + assert_eq!( + recovery.degradation().total, + before, + "projection must not count a previously reported physical-row drift again" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index b3141190632..38cd76b6c53 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -8,17 +8,20 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; +use platform_wallet::changeset::changeset::UtxoCreditVerdict; use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::manager::history_replay::held_sweep_claimant; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob::impl_persistable_blob; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; -use crate::sqlite::schema::{blob, core_history}; +use crate::sqlite::schema::{blob, core_history, core_history_aliases}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -95,7 +98,7 @@ pub fn apply( )?; for incoming in &cs.records { ensure_current_transaction_identity(incoming)?; - let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; + let record = core_history_aliases::adopt(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); @@ -148,7 +151,26 @@ pub fn apply( refresh the record itself to update its confirmation height" ); } - execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, false)?; + let spent = match cs.utxo_credit_verdicts.get(&utxo.outpoint) { + Some(UtxoCreditVerdict::ObservedSpent { .. } | UtxoCreditVerdict::Doomed) => true, + Some(UtxoCreditVerdict::Uncredited) => { + let prior_spent: Option = tx + .prepare_cached( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + )? + .query_row( + params![wallet_id.as_slice(), blob::encode_outpoint(&utxo.outpoint)?], + |row| row.get(0), + ) + .optional()?; + let Some(prior_spent) = prior_spent else { + continue; + }; + prior_spent + } + None => false, + }; + execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, spent, None)?; } } if !cs.spent_utxos.is_empty() { @@ -165,20 +187,36 @@ pub fn apply( "SELECT 1 FROM core_utxos \ WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", )?; + // A reservation created here names the record that made it, so a + // conflict removing that record can release the coin. Only the unspent + // -> spent transition is attributed: an existing claim, known or not, stands. + let mut spenders: HashMap = HashMap::new(); + for record in &cs.records { + for input in &record.transaction.input { + spenders.entry(input.previous_output).or_insert(record.txid); + } + } let mut mark_spent_stmt = tx.prepare_cached( - "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 0 THEN ?3 ELSE spent_in_txid END \ + WHERE wallet_id = ?1 AND outpoint = ?2", )?; let mut upsert_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; for utxo in &cs.spent_utxos { let op = blob::encode_outpoint(&utxo.outpoint)?; + let spender = spenders.get(&utxo.outpoint); let materialised: bool = materialised_stmt .query_row(params![wallet_id.as_slice(), &op[..]], |_| Ok(true)) .optional()? .unwrap_or(false); if materialised { - mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + mark_spent_stmt.execute(params![ + wallet_id.as_slice(), + &op[..], + spender.map(AsRef::<[u8]>::as_ref) + ])?; } else { - execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true, spender)?; } } } @@ -231,7 +269,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - core_history::apply(tx, wallet_id, cs)?; + core_history::apply_live(tx, wallet_id, cs)?; return Ok(()); } @@ -395,7 +433,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - core_history::apply(tx, wallet_id, cs)?; + core_history::apply_live(tx, wallet_id, cs)?; Ok(()) } @@ -450,12 +488,15 @@ fn surviving_stored_input_claims( wallet_id: &WalletId, swept_txids: &HashSet, ) -> Result, WalletStorageError> { - let mut stmt = - tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL")?; + let mut stmt = tx.prepare_cached( + "SELECT length(txid), txid, length(record_blob), record_blob \ + FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL", + )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut claims: HashSet = HashSet::new(); while let Some(row) = rows.next()? { - let txid_bytes: Vec = row.get(0)?; + blob::check_size(row.get(0)?)?; + let txid_bytes: Vec = row.get(1)?; let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { return Err(WalletStorageError::blob_decode( "core_transactions.txid must be exactly 32 bytes", @@ -465,7 +506,8 @@ fn surviving_stored_input_claims( if swept_txids.contains(&key_txid) { continue; } - let blob_bytes: Vec = row.get(1)?; + blob::check_size(row.get(2)?)?; + let blob_bytes: Vec = row.get(3)?; let record: TransactionRecord = blob::decode(&blob_bytes)?; if record.txid != key_txid { return Err(WalletStorageError::blob_decode( @@ -660,12 +702,12 @@ fn apply_sweep( // query even if nothing had marked it spent yet (upstream sweeps only // unconfirmed records, whose spends this schema does not mark). // `spent_in_txid` moves with `spent`: a released input clears back to - // NULL (nobody's claim), a held one is attributed to `superseded_by` so + // NULL (nobody's claim), a held one is attributed to its surviving spender so // the claim outlives this row's own deletion below. // A held, never-materialised claim (`is_sweep_placeholder = 1`) is re-stamped - // with the NEW winner's mined height when this sweep has one — the - // claim now belongs to that winner, and its height is what the - // collector compares against the finality boundary. An IS-locked + // with the winner's height only when that winner spends the input. + // A surviving unmined spender's hold must outlive that winner's finality. + // An IS-locked // winner (`?5` NULL) re-points the claim but keeps the existing stamp: // the earlier block-context observation stands, exactly as upstream's // `observed_spent_outpoints` entry is never retracted by an @@ -698,6 +740,9 @@ fn apply_sweep( winner_mined_height) \ VALUES (?1, ?2, 0, X'', 1, 1, ?3, ?4)", )?; + let mut spenders_stmt = tx.prepare_cached( + "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", + )?; for input in &loser.transaction.input { let outpoint = input.previous_output; // An input funded by a transaction this same changeset also sweeps @@ -733,17 +778,33 @@ fn apply_sweep( } let key = blob::encode_outpoint(&outpoint)?; let freed = released.contains(&outpoint); - let spent_in_txid: Option<&[u8]> = if freed { + let claimant = if freed { None } else { - Some(AsRef::<[u8]>::as_ref(superseded_by)) + let spenders = spenders_stmt + .query_map(params![wallet_id.as_slice(), &key], |row| { + row.get::<_, Vec>(0) + })? + .map(|row| Ok(Txid::from_slice(&row?)?)) + .collect::, WalletStorageError>>()?; + Some(held_sweep_claimant( + *superseded_by, + spenders + .into_iter() + .filter(|spender| !swept_txids.contains(spender)), + )) }; + let spent_in_txid = claimant.as_ref().map(AsRef::<[u8]>::as_ref); + // A winner's height cannot retire a hold belonging to another spender. + let stamp = winner_mined_height + .filter(|_| claimant.as_ref() == Some(superseded_by)) + .map(i64::from); let affected = spend_stmt.execute(params![ wallet_id.as_slice(), &key[..], !freed, spent_in_txid, - winner_mined_height.map(i64::from) + stamp ])?; if affected == 0 && !freed { // A held input with no row gets a placeholder in EVERY sweep @@ -756,8 +817,8 @@ fn apply_sweep( tombstone_stmt.execute(params![ wallet_id.as_slice(), &key[..], - AsRef::<[u8]>::as_ref(superseded_by), - winner_mined_height.map(i64::from) + spent_in_txid, + stamp ])?; } } @@ -766,8 +827,8 @@ fn apply_sweep( } const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, spent) \ - VALUES (?1, ?2, ?3, ?4, ?5) \ + (wallet_id, outpoint, value, script, spent, spent_in_txid) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ value = excluded.value, \ script = excluded.script, \ @@ -781,6 +842,8 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ ELSE NULL END"; /// Upsert one `core_utxos` row; `spent` marks spent-only synthetic rows. +/// `spender` is the claimant of a freshly inserted row only: an existing row +/// keeps the claim it already has. /// /// # Errors /// @@ -793,6 +856,7 @@ fn execute_upsert_utxo( wallet_id: &WalletId, utxo: &Utxo, spent: bool, + spender: Option<&Txid>, ) -> Result<(), WalletStorageError> { if utxo.txout.script_pubkey.as_bytes().is_empty() { return Err(WalletStorageError::EmptyUtxoScript { @@ -806,6 +870,7 @@ fn execute_upsert_utxo( crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), spent, + spender.map(AsRef::<[u8]>::as_ref), ])?; Ok(()) } @@ -919,7 +984,26 @@ pub fn load_state( ), WalletStorageError, > { + let snapshot = load_snapshot(conn, wallet_id, network, ctx)?; + Ok((snapshot.changes, snapshot.utxo_accounts)) +} + +/// Physical keys stay paired with accepted records, including tolerated Recovery drift. +pub(crate) struct CoreStateSnapshot { + pub changes: CoreChangeSet, + pub utxo_accounts: HashMap, + pub record_keys: Vec, +} + +/// Read projection and replay row identities together without decoding records twice. +pub(crate) fn load_snapshot( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, + ctx: &LoadCtx, +) -> Result { let mut cs = CoreChangeSet::default(); + let mut record_keys = Vec::new(); let mut utxo_accounts: HashMap = HashMap::new(); let mut transaction_heights: HashMap> = HashMap::new(); @@ -943,7 +1027,7 @@ pub fn load_state( if let Some(record_blob_len) = row.get::<_, Option>(3)? { blob::check_size(record_blob_len)?; let payload: Vec = row.get(4)?; - let record = blob::decode::(&payload)?; + let record = decode_transaction_record(&payload)?; effective_txid = record.txid; effective_height = record.block_info().map(|block_info| block_info.height()); if let Err(mismatch) = @@ -954,6 +1038,7 @@ pub fn load_state( ctx.tolerate(LoadSite::CoreTransactionColumnDrift, mismatch)?; } cs.records.push(record); + record_keys.push(txid); transaction_heights.insert(effective_txid, effective_height); blob_backed_transaction_heights.insert(effective_txid); } else if !blob_backed_transaction_heights.contains(&effective_txid) { @@ -987,6 +1072,10 @@ pub fn load_state( let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height = transaction_heights.get(&outpoint.txid).copied().flatten(); let script = dashcore::ScriptBuf::from_bytes(script_bytes); + // Contact-only historical outputs must not enter the spendable fallback. + if core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + continue; + } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { utxo_accounts.insert(outpoint, owner); } @@ -1071,7 +1160,11 @@ pub fn load_state( } } - Ok((cs, utxo_accounts)) + Ok(CoreStateSnapshot { + changes: cs, + utxo_accounts, + record_keys, + }) } /// Every address that has ever held a `core_utxos` row for this wallet — @@ -1311,7 +1404,7 @@ fn ensure_current_transaction_identity( } /// Diagnose typed txid/height columns that disagree with the authoritative blob. -fn ensure_transaction_record_matches_columns( +pub(crate) fn ensure_transaction_record_matches_columns( typed_txid: &dashcore::Txid, typed_height: Option, record: &TransactionRecord, @@ -1387,6 +1480,7 @@ mod tests { TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use platform_wallet::changeset::changeset::SweepBatch; // Encoded by TransactionRecord::new at rust-dashcore d631aaca3632cc713143476e363c9a9aca038d2e, // using grovedb-bincode 2.1.0 serde standard config. Public mempool records, not finality proofs. @@ -1716,7 +1810,10 @@ mod tests { let before = snapshot(&conn); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); let repaired = stored_record(&conn, &wallet_id, &spending.txid); assert_eq!(repaired.net_amount, -150_000); @@ -1879,6 +1976,11 @@ mod tests { assert_eq!(repaired.direction, TransactionDirection::Internal); assert_eq!(repaired.input_details.len(), 1); assert_eq!(repaired.block_info().unwrap().height(), 101); + assert_eq!( + spend_claim(&tx, &wallet_id, &funding.outpoint), + (true, Some(spending.txid.as_byte_array().to_vec())), + "late funding retains the confirmed spender's identity" + ); let original = original_blob(&tx, &wallet_id, &spending.txid); assert_eq!( blob::decode::(&original) @@ -1961,7 +2063,7 @@ mod tests { migrations::run_for_open(&mut conn, LoadPolicy::Recovery) .expect("Recovery must complete V020 past an undecodable kept record"); - assert_eq!(schema_version(&conn), 20); + assert_eq!(schema_version(&conn), migrations::max_supported_version()); } #[test] @@ -2437,6 +2539,102 @@ mod tests { assert!(!loaded.is_confirmed); } + #[test] + fn should_bulk_load_historical_asset_unlock_without_rekeying_under_both_policies() { + let wallet_id = [0x78; 32]; + let conn = wallet_db(&wallet_id); + let rows = historical_asset_unlock_rows(); + assert_ne!(rows[0].0, rows[0].2.transaction.txid()); + for (txid, bytes, _) in &rows { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice(), bytes], + ) + .unwrap(); + } + for ctx in [LoadCtx::strict(), LoadCtx::recovery()] { + let (state, _) = + load_state(&conn, &wallet_id, dashcore::Network::Testnet, &ctx).unwrap(); + assert_eq!(state.records.len(), rows.len()); + for (txid, bytes, original) in &rows { + let loaded = state.records.iter().find(|r| r.txid == *txid).unwrap(); + assert_eq!(loaded.transaction, original.transaction); + assert_eq!(blob::encode(loaded).unwrap(), *bytes); + let stored: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stored, *bytes, + "bulk read preserves historical identity and bytes" + ); + } + } + } + + fn assert_bulk_load_rejects_body_identity_mismatch(ctx: &LoadCtx) { + let wallet_id = [0x72; 32]; + let conn = wallet_db(&wallet_id); + let record = transaction_record(TransactionContext::Mempool); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, record_blob, finalized) \ + VALUES (?1, ?2, ?3, 0)", + params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode(&record).unwrap() + ], + ) + .unwrap(); + let (loaded, _) = load_state(&conn, &wallet_id, dashcore::Network::Testnet, ctx).unwrap(); + assert_eq!(loaded.records[0].txid, record.txid); + + for change_body in [true, false] { + let mut tampered = record.clone(); + if change_body { + tampered.transaction.lock_time += 1; + } else { + tampered.txid = Txid::from_byte_array([0x73; 32]); + } + let payload = blob::encode(&tampered).unwrap(); + conn.execute( + "UPDATE core_transactions SET txid = ?1, record_blob = ?2 WHERE wallet_id = ?3", + params![ + tampered.txid.as_byte_array().as_slice(), + &payload, + wallet_id.as_slice() + ], + ) + .unwrap(); + assert!(matches!( + load_state(&conn, &wallet_id, dashcore::Network::Testnet, ctx), + Err(WalletStorageError::BlobDecode { .. }) + )); + let stored: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, payload, "a failed read must not rewrite history"); + } + } + + #[test] + fn should_reject_bulk_history_body_identity_mismatch_in_strict_mode() { + assert_bulk_load_rejects_body_identity_mismatch(&LoadCtx::strict()); + } + + #[test] + fn should_reject_bulk_history_body_identity_mismatch_in_recovery_mode() { + assert_bulk_load_rejects_body_identity_mismatch(&LoadCtx::recovery()); + } + #[test] fn load_state_tolerates_transaction_blob_txid_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); @@ -2874,4 +3072,448 @@ mod tests { corrupt.extend_from_slice(&[0xFFu8; 4]); assert_eq!(chain_lock_height(&corrupt), None); } + + #[test] + fn should_apply_credit_verdicts_to_the_stored_spent_flag() { + use UtxoCreditVerdict::{Doomed, ObservedSpent, Uncredited}; + let wallet_id = [0xAEu8; 32]; + let mut conn = wallet_db(&wallet_id); + let tx = conn.transaction().unwrap(); + let stored_spent = |outpoint: &OutPoint| -> Option { + tx.query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + // (case, row before the round, verdict, stored spent flag after it) + let cases = [ + ( + "observed spent", + "none", + ObservedSpent { height: 101 }, + Some(true), + ), + ("doomed", "none", Doomed, Some(true)), + ("uncredited never materializes", "none", Uncredited, None), + ( + "uncredited cannot invent a spend", + "unspent", + Uncredited, + Some(false), + ), + ( + "uncredited cannot clear a spend", + "spent", + Uncredited, + Some(true), + ), + ( + "uncredited replay keeps a doomed spend", + "doomed", + Uncredited, + Some(true), + ), + ]; + for (marker, (case, prior, verdict, expected)) in (1..).zip(cases) { + let utxo = sample_utxo(Txid::from_byte_array([marker; 32]), 100, true); + let unspent = round(vec![], vec![utxo.clone()]); + let spent = CoreChangeSet { + spent_utxos: vec![utxo.clone()], + ..Default::default() + }; + let prior_rounds = match prior { + "none" => vec![], + "unspent" => vec![unspent], + "spent" => vec![unspent, spent], + _ => vec![CoreChangeSet { + utxo_credit_verdicts: [(utxo.outpoint, Doomed)].into(), + ..unspent + }], + }; + for prior_round in &prior_rounds { + apply(&tx, &wallet_id, prior_round).unwrap(); + } + let mut verdict_round = round(vec![], vec![utxo.clone()]); + verdict_round.utxo_credit_verdicts = [(utxo.outpoint, verdict)].into(); + apply(&tx, &wallet_id, &verdict_round).unwrap(); + assert_eq!(stored_spent(&utxo.outpoint), expected, "{case}"); + } + } + + #[test] + fn should_keep_contact_only_rows_but_never_load_them_as_spendable() { + let wallet_id = [0xB1u8; 32]; + let mut conn = wallet_db(&wallet_id); + let contact = sample_utxo(Txid::from_byte_array([0x51; 32]), 100, true); + let mut own = sample_utxo(Txid::from_byte_array([0x52; 32]), 100, true); + own.address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x24u8; 20])), + ); + own.txout.script_pubkey = own.address.script_pubkey(); + insert_contact_script(&conn, &wallet_id, &contact.txout.script_pubkey); + let tx = conn.transaction().unwrap(); + let outpoints = [contact.outpoint, own.outpoint]; + apply(&tx, &wallet_id, &round(vec![], vec![contact, own])).unwrap(); + tx.commit().unwrap(); + for outpoint in &outpoints { + assert!( + !spend_claim(&conn, &wallet_id, outpoint).0, + "stored rows stay unspent, never destroyed" + ); + } + let (cs, owners) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!( + loaded, + vec![outpoints[1]], + "a contact's coin is not ours to spend" + ); + assert!(!owners.contains_key(&outpoints[0])); + } + + fn mempool_spend(coins: &[&Utxo]) -> TransactionRecord { + let coins: Vec<_> = coins.iter().map(|coin| coin.outpoint).collect(); + spend(&coins, TransactionContext::Mempool) + } + + fn spend_claim( + conn: &Connection, + wallet_id: &[u8; 32], + outpoint: &OutPoint, + ) -> (bool, Option>) { + conn.query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + } + + #[test] + fn should_record_spender_for_new_mempool_reservation() { + let wallet_id = [0x61; 32]; + let mut conn = wallet_db(&wallet_id); + let stored = sample_utxo(Txid::from_byte_array([0x62; 32]), 10, true); + // No funding row yet: the spend synthesises its own spent row. + let unseen = sample_utxo(Txid::from_byte_array([0x63; 32]), 10, true); + let spender = mempool_spend(&[&stored, &unseen]); + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &round(vec![], vec![stored.clone()])).unwrap(); + + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spender.clone()], + spent_utxos: vec![stored.clone(), unseen.clone()], + ..Default::default() + }, + ) + .unwrap(); + + let claimant = Some(AsRef::<[u8]>::as_ref(&spender.txid).to_vec()); + for coin in [&stored, &unseen] { + assert_eq!( + spend_claim(&tx, &wallet_id, &coin.outpoint), + (true, claimant.clone()), + "a new mempool reservation must name the transaction that made it" + ); + } + } + + #[test] + fn should_keep_existing_claims_when_mempool_spend_is_redelivered() { + let wallet_id = [0x64; 32]; + let mut conn = wallet_db(&wallet_id); + let unknown = sample_utxo(Txid::from_byte_array([0x65; 32]), 10, true); + let foreign = sample_utxo(Txid::from_byte_array([0x66; 32]), 10, true); + let held = sample_utxo(Txid::from_byte_array([0x67; 32]), 10, true); + let other_spender = [0x68u8; 32]; + let tx = conn.transaction().unwrap(); + let unspent = round(vec![], vec![unknown.clone(), foreign.clone()]); + apply(&tx, &wallet_id, &unspent).unwrap(); + tx.execute( + "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1", + params![&wallet_id[..]], + ) + .unwrap(); + tx.execute( + "UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&foreign.outpoint).unwrap(), + &other_spender[..] + ], + ) + .unwrap(); + // A sweep hold for an input whose funding this store never saw. + tx.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, is_sweep_placeholder, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', 1, 1, ?3)", + params![ + &wallet_id[..], + blob::encode_outpoint(&held.outpoint).unwrap(), + &other_spender[..] + ], + ) + .unwrap(); + let spender = mempool_spend(&[&unknown, &foreign, &held]); + + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spender], + spent_utxos: vec![unknown.clone(), foreign.clone(), held.clone()], + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + spend_claim(&tx, &wallet_id, &unknown.outpoint), + (true, None), + "an unknown durable claim must not be adopted by a later mempool spender" + ); + for coin in [&foreign, &held] { + assert_eq!( + spend_claim(&tx, &wallet_id, &coin.outpoint), + (true, Some(other_spender.to_vec())), + "another spender's claim must stand" + ); + } + } + + #[test] + fn should_reject_oversize_spent_claim_before_reading_it() { + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + const WALLET: [u8; 32] = [0x5F; 32]; + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let oversize = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + let outpoint = + blob::encode_outpoint(&OutPoint::new(Txid::from_byte_array([0x42; 32]), 0)).unwrap(); + for (stored_outpoint, claimant) in [ + (&oversize[..], &[0x43u8; 32][..]), + (&outpoint[..], &oversize[..]), + ] { + let conn = wallet_db(&WALLET); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'00', 1, ?3)", + params![&WALLET[..], stored_outpoint, claimant], + ) + .unwrap(); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + + let err = crate::sqlite::history_replay::load_spent_claims(&conn, &WALLET).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + } + + #[test] + fn should_reject_oversize_stored_claimant_before_reading_it() { + use rusqlite::types::Value; + + let oversize = blob::BLOB_SIZE_LIMIT_BYTES + 1; + let key = || Value::Blob(vec![0x11; 32]); + let cases = [ + ( + "txid", + Value::Blob(vec![0x11; oversize]), + Value::Blob(vec![0]), + ), + ("record", key(), Value::Blob(vec![0; oversize])), + // A blob reader cannot read text at all, so only a gate that + // runs before the read can report this one by its size. + ( + "record stored as text", + key(), + Value::Text("a".repeat(oversize)), + ), + ]; + let mut ungated = Vec::new(); + for (column, txid, record) in cases { + let wallet_id = [0x4Cu8; 32]; + let mut conn = wallet_db(&wallet_id); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 1, ?3)", + params![&wallet_id[..], txid, record], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let error = surviving_stored_input_claims(&tx, &wallet_id, &HashSet::new()) + .expect_err("an oversize stored claimant must fail the scan"); + + let gated = matches!( + error, + WalletStorageError::BlobTooLarge { len_bytes, .. } if len_bytes == oversize + ); + if !gated { + ungated.push(format!("{column}: {error:?}")); + } + } + assert_eq!(ungated, Vec::::new()); + } + + fn indexed_txid(tag: u8, index: u32) -> Txid { + let mut bytes = [tag; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + Txid::from_byte_array(bytes) + } + + #[test] + fn should_migrate_to_v021_in_work_linear_in_kept_coins_and_dropped_history() { + let ops = |n: u32| { + let wallet_id = [0xB8u8; 32]; + let mut conn = wallet_db(&wallet_id); + for index in 0..n { + let (receipt, mined) = (indexed_txid(1, index), indexed_txid(2, index)); + for (txid, height) in [(receipt, None), (mined, Some(200))] { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized) \ + VALUES (?1, ?2, ?3, 0)", + params![&wallet_id[..], txid.as_byte_array().as_slice(), height], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 1, X'51', 0)", + params![ + &wallet_id[..], + blob::encode_outpoint(&OutPoint::new(receipt, 0)).unwrap() + ], + ) + .unwrap(); + } + rewind_v020(&conn); + let ops = count_vm_ops(&conn); + migrations::run(&mut conn).unwrap(); + let kept: i64 = conn + .query_row("SELECT COUNT(*) FROM core_transactions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(kept, i64::from(n), "only the unmined receipts stay"); + ops.load(std::sync::atomic::Ordering::Relaxed) + }; + let (small, large) = (ops(200), ops(800)); + assert!( + large < 8 * small, + "V021 work must grow linearly, not with kept coins times dropped records: \ + {small} VM ops for 200 of each, {large} for 800" + ); + } + + #[test] + fn should_hand_a_withheld_input_to_its_kept_survivor_after_migrating_to_v021() { + for materialized in [true, false] { + let wallet_id = [0xB7u8; 32]; + let mut conn = wallet_db(&wallet_id); + let coin = |marker| sample_utxo(Txid::from_byte_array([marker; 32]), 150, true); + let (first, second, shared) = (coin(0x71), coin(0x72), coin(0x73)); + // The shared coin is claimed by the first record listing it: `loser`. + let loser = spend( + &[first.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let survivor = spend( + &[second.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let coins = if materialized { + vec![first.clone(), second.clone(), shared.clone()] + } else { + vec![] + }; + let pending = CoreChangeSet { + spent_utxos: coins.clone(), + ..round(vec![loser.clone(), survivor.clone()], coins) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &pending).unwrap(); + tx.commit().unwrap(); + let claimant = |txid: &Txid| (true, Some(txid.as_byte_array().to_vec())); + if materialized { + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&loser.txid) + ); + } + rewind_v020(&conn); + migrations::run(&mut conn).unwrap(); + + let block = BlockInfo::new(160, BlockHash::all_zeros(), 1); + let winner = spend(&[first.outpoint], TransactionContext::InBlock(block)); + let settled = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser.txid], + superseded_by: winner.txid, + winner_mined_height: Some(160), + released_outpoints: vec![], + }], + ..round(vec![winner], vec![]) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &settled).unwrap(); + tx.commit().unwrap(); + + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&survivor.txid), + "the kept survivor, not the winner, holds the withheld input" + ); + let stamp: Option = conn + .query_row( + "SELECT winner_mined_height FROM core_utxos WHERE wallet_id=?1 AND outpoint=?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&shared.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stamp, None, + "an unmined survivor cannot inherit the winner's height" + ); + } + } + fn count_vm_ops(conn: &Connection) -> std::sync::Arc { + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + let count = Arc::new(AtomicU64::new(0)); + let counter = Arc::clone(&count); + conn.progress_handler( + 1, + Some(move || { + counter.fetch_add(1, Ordering::Relaxed); + false + }), + ) + .unwrap(); + count + } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index ad4bdbb387b..8f03fe15847 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -11,6 +11,7 @@ pub mod asset_locks; pub mod blob; pub mod contacts; pub(crate) mod core_history; +pub(crate) mod core_history_aliases; pub mod core_pool; pub mod core_state; pub mod dashpay; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs index 3d8e777e2d7..6b4b2f17459 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -197,9 +197,7 @@ fn account_state_from_rows( /// network-scoped sync watermark plus the per-account committed state /// reconstructed from registrations + address rows. /// -/// `load()` uses the grouped [`load_all`] path; this per-wallet form is -/// retained for this crate's integration tests. -#[cfg(any(test, feature = "__test-helpers"))] +/// The scoped replay loader uses this form to leave other wallets untouched. pub fn load_state( conn: &Connection, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt new file mode 100644 index 00000000000..ab1eb86ec91 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt @@ -0,0 +1,2 @@ +asset_unlock_v2 d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928 03000001fcf04902001976a9143a573b102166c333339a24746760b144c3e2fcb988ac01080247fbe803c82031313131313131313131313131313131313131313131313131313131313131316042424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424220d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928000000000700000100000122795264765945327a47534873687751376a6765555034553850396a5a4c7136337a63fcf0490200fce09304000000 +asset_unlock_v2_spend 19cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f 02000120d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928006b483045022100f71f93ce5c42c6b3f4e4ae5078ac40dd28922ff38fb452e1e0cde866c0042f3402201a0d18b9d059b1686ae4259ed3fb82edae66fcbf0f351df4749321654c791db0012102e4d24944ed947bc34ed14ea4b2e063aa27ebd8da658577a263f55a631c7f656afcffffffff0001fc0846020000002019cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f0000000000010100fcf049020022795264765945327a47534873687751376a6765555034553850396a5a4c7136337a6300fcdf9304000000 diff --git a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs index d9d62a4f6d5..87ccf6d4a32 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs @@ -4,7 +4,14 @@ #![allow(dead_code)] +use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; +use dash_spv::{ClientConfig, DashSpvClient}; +use dashcore::{InstantLock, Network, Transaction}; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::key_wallet_manager::test_utils::MockWallet; use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::wallet::platform_wallet::WalletId; @@ -257,3 +264,182 @@ pub fn store_and_flush( persister.store(wallet_id, cs).expect("store"); persister.flush(wallet_id).expect("flush"); } + +/// Names a table case in the output of an assertion that fails under it. +pub struct Case(pub String); + +impl Drop for Case { + fn drop(&mut self) { + if std::thread::panicking() { + eprintln!("failed case: {}", self.0); + } + } +} + +/// A wallet's first round: testnet metadata, its account manifest, and `core`. +pub fn registration( + wallet: &key_wallet::Wallet, + core: Option, +) -> platform_wallet::changeset::PlatformWalletChangeSet { + use platform_wallet::changeset::{AccountRegistrationEntry, WalletMetadataEntry}; + let accounts = wallet.accounts.all_accounts().into_iter(); + platform_wallet::changeset::PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: dashcore::Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 0, + }), + account_registrations: accounts + .map(|account| AccountRegistrationEntry { + account_type: account.account_type, + account_xpub: account.account_xpub, + }) + .collect(), + core, + ..Default::default() + } +} + +/// A ChainLock for `height` over a block hash of `[hash; 32]`, unsigned. +pub fn chain_lock(height: u32, hash: u8) -> dashcore::ephemerealdata::chain_lock::ChainLock { + dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: height, + block_hash: dashcore::BlockHash::from_byte_array([hash; 32]), + signature: dashcore::bls_sig_utils::BLSSignature::from([0; 96]), + } +} + +/// A shared wallet manager, as the live event adapter holds it. +pub type Manager = std::sync::Arc< + tokio::sync::RwLock< + platform_wallet::key_wallet_manager::WalletManager< + platform_wallet::wallet::platform_wallet::PlatformWalletInfo, + >, + >, +>; + +/// Process `tx` alone in a block at `height` for `wallet_id`, as SPV would. +pub async fn mine(manager: &Manager, wallet_id: [u8; 32], tx: dashcore::Transaction, height: u32) { + use dashcore::block::{Header, Version}; + use dashcore::{Block, BlockHash, CompactTarget, TxMerkleNode}; + use platform_wallet::key_wallet_manager::WalletInterface; + let block = Block { + header: Header { + version: Version::default(), + prev_blockhash: BlockHash::from_byte_array([height as u8; 32]), + merkle_root: TxMerkleNode::all_zeros(), + time: height, + bits: CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata: vec![tx], + }; + manager + .write() + .await + .process_block_for_wallets( + &block, + block.block_hash(), + height, + &std::collections::BTreeSet::from([wallet_id]), + ) + .await; +} + +/// Store every event `events` holds through the real wallet event adapter. +pub async fn persist_events( + manager: &Manager, + persister: &std::sync::Arc, + events: &mut tokio::sync::mpsc::UnboundedReceiver< + platform_wallet::key_wallet_manager::WalletEvent, + >, +) { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + // Close one finite batch so completion proves every real manager event was stored. + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + while let Ok(event) = events.try_recv() { + sender.send(event).unwrap(); + } + drop(sender); + let fault = Arc::new(AtomicBool::new(false)); + platform_wallet::changeset::spawn_wallet_event_adapter( + Arc::clone(manager), + Arc::downgrade(persister), + receiver, + Arc::clone(&fault), + tokio_util::sync::CancellationToken::new(), + ) + .await + .unwrap(); + assert!(!fault.load(Ordering::Relaxed)); +} + +/// Sign the owned inputs in a fixture; peer-forgery regressions deliberately omit this. +pub fn authorize( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + tx: dashcore::Transaction, +) -> dashcore::Transaction { + authorize_with_mode(wallet, info, address, tx, 1) +} + +pub fn authorize_with_mode( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + mut tx: dashcore::Transaction, + mode: u8, +) -> dashcore::Transaction { + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let path = info + .accounts + .all_funding_accounts() + .into_iter() + .find_map(|account| account.address_derivation_path(address)) + .unwrap(); + let key = wallet.derive_private_key(&path).unwrap(); + for index in 0..tx.input.len() { + let hash = SighashCache::new(&tx) + .legacy_signature_hash(index, &address.script_pubkey(), u32::from(mode)) + .unwrap(); + let mut sig = key.sign(&hash.to_byte_array()).with_der(|der| der.to_vec()); + sig.push(mode); + tx.input[index].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(sig).unwrap()) + .push_slice(PushBytesBuf::try_from(key.public_key().to_compressed().to_vec()).unwrap()) + .into_script(); + } + tx +} + +/// Produce evidence through the public verifier; no synthetic token constructor. +pub async fn checked_instant_lock(transaction: &Transaction) -> (InstantLock, ReplayEvidence) { + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); + let directory = secure_tempdir().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ( + fixture.instant_lock().clone(), + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + }, + ) +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 0f0a6c6f051..482ffaea515 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -44,6 +44,7 @@ mod sqlite_error_classification; mod sqlite_fk_changeset_ordering; mod sqlite_foreign_db_rejection; mod sqlite_foreign_keys; +mod sqlite_history_aliases; mod sqlite_identity_hard_delete; mod sqlite_identity_index_concurrency; mod sqlite_identity_index_uniqueness; @@ -85,3 +86,14 @@ mod sqlite_v008_migration; mod sqlite_version_bump; mod sqlite_wallet_db_identity; mod tracked_masternodes_roundtrip; + +mod sqlite_spent_rehydration; + +mod sqlite_clean_restart; + +mod sqlite_core_rescan; + +mod sqlite_replay_migration; +mod sqlite_shared_history_replay; + +mod sqlite_unverified_lock_producer; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_clean_restart.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_clean_restart.rs new file mode 100644 index 00000000000..4e6ad1daf5b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_clean_restart.rs @@ -0,0 +1,200 @@ +#![cfg(feature = "sqlite")] + +use crate::common; + +use std::sync::Arc; + +use common::{mine, persist_events, registration, Case}; +use dashcore::{ + bls_sig_utils::BLSSignature, ephemerealdata::chain_lock::ChainLock, BlockHash, Network, + OutPoint, ScriptBuf, Transaction, TxOut, +}; +use key_wallet::wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::{ + coin_selection::{CoinSelector, SelectionStrategy}, + fee::FeeRate, + }, + ManagedWalletInfo, +}; +use platform_wallet::{ + changeset::PlatformWalletPersistence, + key_wallet_manager::{WalletInterface, WalletManager}, + test_support::core_fixtures::{block, check, outside, pay, spend_of}, + wallet::platform_wallet::PlatformWalletInfo, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use tokio::sync::RwLock; + +fn assert_coins(info: &ManagedWalletInfo, funding: &Transaction, phase: &str) { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!( + !coins.contains_key(&OutPoint::new(funding.txid(), 0)), + "{phase}: spent funding output must not return" + ); + assert!(coins.contains_key(&OutPoint::new(funding.txid(), 1))); + assert_eq!(info.balance.total(), 20_000, "{phase}: spendable balance"); + assert!( + CoinSelector::new(SelectionStrategy::LargestFirst) + .select_coins(coins.values(), 50_000, FeeRate::default(), 300) + .is_err(), + "{phase}: spent output must not fund a new payment" + ); +} + +async fn clean_restart(recordless_winner: bool, finalized: bool, pending: bool) { + let (persister, _dir, path) = common::fresh_persister(); + assert!(persister.load().unwrap().wallets.is_empty()); + let persister = Arc::new(persister); + let mut manager = WalletManager::::new(Network::Testnet); + let mut events = manager.take_persistence_receiver().unwrap(); + let wallet_id = manager.create_wallet_from_mnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + 0, WalletAccountCreationOptions::Default, + ).unwrap(); + let xpub = manager + .get_wallet(&wallet_id) + .unwrap() + .accounts + .standard_bip44_accounts[&0] + .account_xpub; + let address = manager + .get_wallet_info_mut(&wallet_id) + .unwrap() + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + let wallet = manager.get_wallet(&wallet_id).unwrap(); + persister + .store(wallet_id, registration(wallet, None)) + .unwrap(); + let funding = spend_of( + &[outside(13)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let spent = OutPoint::new(funding.txid(), 0); + let burn = TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }; + let spending = common::authorize( + wallet, + &manager.get_wallet_info(&wallet_id).unwrap().core_wallet, + &address, + spend_of(&[spent], vec![burn]), + ); + let manager = Arc::new(RwLock::new(manager)); + if recordless_winner { + // A wallet-paying mempool spend loses to a mined payment to somebody else. + let loser = spend_of(&[spent], vec![pay(98_000, &address)]); + assert!( + manager + .write() + .await + .process_mempool_transaction(&loser, None) + .await + .is_relevant + ); + persist_events(&manager, &persister, &mut events).await; + } else { + mine(&manager, wallet_id, funding.clone(), 100).await; + persist_events(&manager, &persister, &mut events).await; + } + if pending { + assert!( + manager + .write() + .await + .process_mempool_transaction(&spending, None) + .await + .is_relevant + ); + } else { + mine(&manager, wallet_id, spending.clone(), 200).await; + } + persist_events(&manager, &persister, &mut events).await; + if finalized { + let mut guard = manager.write().await; + guard.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + if !recordless_winner { + guard.update_wallet_synced_height(&wallet_id, 300); + } + drop(guard); + persist_events(&manager, &persister, &mut events).await; + } + if recordless_winner { + let (records, claims): (i64, i64) = persister + .lock_conn_for_test() + .query_row( + "SELECT (SELECT COUNT(*) FROM core_transactions WHERE record_blob IS NOT NULL), \ + (SELECT COUNT(*) FROM core_utxos WHERE spent = 1)", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!( + (records, claims), + (0, 1), + "the live event path must produce the recordless state, \ + and the sweep must persist the winner's input claim" + ); + } + let (mut uninterrupted_wallet, mut uninterrupted_info) = { + let guard = manager.read().await; + ( + guard.get_wallet(&wallet_id).unwrap().clone(), + guard + .get_wallet_info(&wallet_id) + .unwrap() + .core_wallet + .clone(), + ) + }; + drop(events); + drop(manager); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let mut restored = reopened.load().unwrap().wallets.remove(&wallet_id).unwrap(); + check( + &mut uninterrupted_info, + &mut uninterrupted_wallet, + &funding, + block(100), + ) + .await; + assert_coins(&uninterrupted_info, &funding, "uninterrupted wallet"); + check( + &mut restored.wallet_info, + &mut restored.wallet, + &funding, + block(100), + ) + .await; + assert_coins(&restored.wallet_info, &funding, "restored wallet"); +} + +/// A spend, or a recordless winner, persisted through the real wallet event +/// adapter survives a clean restart: funding redelivery returns the spent +/// coin neither to the uninterrupted wallet nor to the restored one. +#[tokio::test] +async fn should_preserve_spend_after_clean_event_persistence_and_restart() { + // (case, recordless winner, finalized, pending spend) + for (label, recordless_winner, finalized, pending) in [ + ("mined spend", false, false, false), + ("pending spend", false, false, true), + ("finalized spend", false, true, false), + ("recordless winner", true, false, false), + ("finalized recordless winner", true, true, false), + ] { + let _case = Case(label.into()); + clean_restart(recordless_winner, finalized, pending).await; + } +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs new file mode 100644 index 00000000000..646324bb766 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs @@ -0,0 +1,631 @@ +#![cfg(feature = "sqlite")] + +//! A wallet whose mined Core history was wiped (what V021 does to an older store) +//! rebuilds it by rescanning from its birth height. These tests drive that +//! rescan through the real manager, event adapter, store and load replay. + +use crate::common; + +use std::collections::BTreeMap; +use std::sync::Arc; + +use common::{chain_lock, mine, persist_events, registration, Manager}; +use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{Address, Network, OutPoint, ScriptBuf, Transaction, TxOut}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::transaction_checking::{TransactionContext, TransactionType}; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::ManagedWalletInfo; +use key_wallet::Wallet; +use platform_wallet::changeset::{ + AssetLockChangeSet, AssetLockEntry, ClientStartState, CoreChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet::key_wallet_manager::{WalletEvent, WalletInterface, WalletManager}; +use platform_wallet::test_support::core_fixtures::{ + foreign_address, outside, pay, spend_of, wallet_with_receive_address, +}; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use platform_wallet_storage::sqlite::schema::blob; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::RwLock; + +/// Blocks the rescan finds, in chain order: a funding payment, a spend of its +/// first output (the original bug's shape: the spend's input details depend on +/// the funding being known), and an asset lock burning its second output with +/// no change. +struct Chain { + funding: Transaction, + spend: Transaction, + lock: Transaction, +} + +impl Chain { + fn new(address: &Address) -> Self { + let funding = spend_of( + &[outside(21)], + vec![pay(100_000, address), pay(20_000, address)], + ); + let spend = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(60_000, &foreign_address(9)), pay(39_000, address)], + ); + let burn = TxOut { + value: 19_000, + script_pubkey: ScriptBuf::new_op_return(&[]), + }; + let mut lock = spend_of(&[OutPoint::new(funding.txid(), 1)], vec![burn]); + lock.version = 3; + lock.special_transaction_payload = + Some(TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![pay(19_000, &foreign_address(8))], + })); + Self { + funding, + spend, + lock, + } + } + + fn blocks(&self) -> [(Transaction, u32); 3] { + [ + (self.funding.clone(), 100), + (self.spend.clone(), 200), + (self.lock.clone(), 300), + ] + } +} + +/// A registered wallet with no Core history, as V021 leaves it, and its first +/// receive address. +fn wiped_wallet(persister: &SqlitePersister) -> (WalletId, Address) { + let (wallet, _, address) = wallet_with_receive_address(); + persister + .store(wallet.wallet_id, registration(&wallet, None)) + .unwrap(); + (wallet.wallet_id, address) +} + +/// Load the store at `path`, run `blocks` through a manager holding the loaded +/// wallet and its tracked asset locks, persist every event, and load again. +async fn rescan( + path: &std::path::Path, + wallet_id: WalletId, + blocks: &[(Transaction, u32)], +) -> ClientStartState { + rescan_with_evidence(path, wallet_id, blocks, &Default::default()).await +} + +async fn rescan_with_evidence( + path: &std::path::Path, + wallet_id: WalletId, + blocks: &[(Transaction, u32)], + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, +) -> ClientStartState { + let persister = Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()); + let loaded = persister + .load_with_replay(evidence) + .unwrap() + .state + .wallets + .remove(&wallet_id) + .unwrap(); + assert!( + loaded.wallet_info.accounts.standard_bip44_accounts[&0] + .transactions() + .values() + .all(|record| record.context.block_info().is_none()), + "the wiped wallet has only its still-active outgoing history" + ); + let mut manager = WalletManager::::new(Network::Testnet); + let mut events = manager.take_persistence_receiver().unwrap(); + let mut info = PlatformWalletInfo::from_wallet(&loaded.wallet, 0); + info.core_wallet = loaded.wallet_info; + info.tracked_asset_locks = loaded.unused_asset_locks.into_values().flatten().collect(); + manager.insert_wallet(loaded.wallet, info).unwrap(); + let manager = Arc::new(RwLock::new(manager)); + for (tx, height) in blocks { + mine(&manager, wallet_id, tx.clone(), *height).await; + persist_events(&manager, &persister, &mut events).await; + } + drop((manager, events, persister)); + SqlitePersister::open(SqlitePersisterConfig::new(path)) + .unwrap() + .load_with_replay(evidence) + .unwrap() + .state +} + +/// Undo V020 on the store at `path`, so its next open replays V020 over the +/// history it holds, as on a store upgraded from before V020. +fn rewind_v020(path: &std::path::Path) { + rusqlite::Connection::open(path) + .unwrap() + .execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DROP INDEX idx_core_utxos_funding; DROP INDEX idx_core_utxos_claimant; \ + DELETE FROM refinery_schema_history WHERE version >= 20;", + ) + .unwrap(); +} + +/// The record the store holds for `tx`. Wallet-level accounting lives here: +/// the in-memory account record keeps upstream's account-local view (an asset +/// lock with no change reads `Outgoing` there, live or reloaded alike). +fn stored(path: &std::path::Path, wallet_id: &WalletId, tx: &Transaction) -> TransactionRecord { + let conn = rusqlite::Connection::open(path).unwrap(); + let bytes: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![wallet_id.as_slice(), tx.txid().as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap_or_else(|_| panic!("the rescan stores {}", tx.txid())); + blob::decode(&bytes).unwrap() +} + +/// R1: a rescan from birth through the pinned key-wallet stores correct +/// accounting — input details, net, direction (an asset lock with no change +/// is `Internal`) — and the store's own load replay restores the spent coins. +#[tokio::test] +async fn should_rebuild_accounting_when_a_wiped_wallet_rescans_from_birth() { + let (persister, _dir, path) = common::fresh_persister(); + let (wallet_id, address) = wiped_wallet(&persister); + drop(persister); + let chain = Chain::new(&address); + + let state = rescan(&path, wallet_id, &chain.blocks()).await; + + assert_rebuilt(&path, &wallet_id, &chain, &address, &state); + let replayed = state.wallets[&wallet_id] + .wallet_info + .accounts + .standard_bip44_accounts[&0] + .transactions(); + for tx in [&chain.funding, &chain.spend, &chain.lock] { + assert!( + replayed.contains_key(&tx.txid()), + "the load replays {}", + tx.txid() + ); + } +} + +/// R3: a wallet upgraded from V018. V021 drops its mined Core history but keeps +/// `last_processed_height` and the ChainLock, both above that history, so the +/// rescan replays every historical block below a ChainLock the loaded wallet +/// already holds: key-wallet's ChainLocked path, not the ordinary block one. +/// It stores the same accounting as R1, and another reload keeps it. The +/// loaded account holds each transaction as finalized: without +/// `keep-finalized-transactions` key-wallet drops a ChainLocked record from +/// memory, and the stored row stays the record of it. +#[tokio::test] +async fn should_rebuild_accounting_when_an_upgraded_wallet_rescans_below_its_chain_lock() { + let (persister, _dir, path) = common::fresh_persister(); + let (wallet_id, address) = wiped_wallet(&persister); + drop(persister); + let chain = Chain::new(&address); + // The history and checkpoint a synced V018 store holds. + rescan(&path, wallet_id, &chain.blocks()).await; + let checkpoint = CoreChangeSet { + last_processed_height: Some(500), + synced_height: Some(500), + last_applied_chain_lock: Some(chain_lock(400, 40)), + ..Default::default() + }; + SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .unwrap() + .store( + wallet_id, + PlatformWalletChangeSet { + core: Some(checkpoint), + ..Default::default() + }, + ) + .unwrap(); + let sync_state = || { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.query_row( + "SELECT last_processed_height, synced_height, chainlock_height FROM core_sync_state \ + WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap() + }; + rewind_v020(&path); + { + let conn = rusqlite::Connection::open(&path).unwrap(); + let rows: i64 = conn + .query_row("SELECT COUNT(*) FROM core_transactions", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(rows, 3, "the V018 store holds the history"); + } + assert_eq!(sync_state(), (Some(500), Some(500), Some(400))); + + // `rescan` opens through `SqlitePersister::open`, so V020 and V021 run first. + let state = rescan(&path, wallet_id, &chain.blocks()).await; + + assert_eq!( + sync_state(), + (Some(500), Some(0), Some(400)), + "V021 rewinds only the filter checkpoint" + ); + for tx in [&chain.funding, &chain.spend, &chain.lock] { + assert!( + matches!( + stored(&path, &wallet_id, tx).context, + TransactionContext::InChainLockedBlock(_) + ), + "the rescan finds {} below the ChainLock", + tx.txid() + ); + } + let reloaded = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .unwrap() + .load() + .unwrap(); + for state in [&state, &reloaded] { + assert_rebuilt(&path, &wallet_id, &chain, &address, state); + let account = &state.wallets[&wallet_id] + .wallet_info + .accounts + .standard_bip44_accounts[&0]; + for tx in [&chain.funding, &chain.spend, &chain.lock] { + assert!( + account.transaction_is_finalized(&tx.txid()), + "the load holds {} as finalized", + tx.txid() + ); + } + } +} + +/// What a rescan of `chain` from birth must leave: the stored accounting of +/// each transaction, the spendable coins and balance of the loaded `state`, +/// and its replay of every transaction. +fn assert_rebuilt( + path: &std::path::Path, + wallet_id: &WalletId, + chain: &Chain, + address: &Address, + state: &ClientStartState, +) { + let input = |value| (0, value, address.clone()); + // (case, transaction, input details as (index, value, address), net, direction) + let cases = [ + ( + "funding", + &chain.funding, + vec![], + 120_000, + TransactionDirection::Incoming, + ), + ( + "spend", + &chain.spend, + vec![input(100_000)], + -61_000, + TransactionDirection::Outgoing, + ), + ( + "asset lock", + &chain.lock, + vec![input(20_000)], + -20_000, + TransactionDirection::Internal, + ), + ]; + for (case, tx, inputs, net, direction) in cases { + let record = stored(path, wallet_id, tx); + let details: Vec<_> = record + .input_details + .iter() + .map(|d| (d.index, d.value, d.address.clone())) + .collect(); + assert_eq!(details, inputs, "{case}"); + assert_eq!( + (record.net_amount, record.direction), + (net, direction), + "{case}" + ); + } + assert_eq!( + stored(path, wallet_id, &chain.lock).transaction_type, + TransactionType::AssetLock + ); + let info = &state.wallets[wallet_id].wallet_info; + let coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .keys() + .copied() + .collect(); + assert_eq!( + coins, + vec![OutPoint::new(chain.spend.txid(), 1)], + "only the change is spendable" + ); + assert_eq!(info.balance.total(), 39_000); +} + +fn asset_lock_rows(path: &std::path::Path) -> Vec> { + let conn = rusqlite::Connection::open(path).unwrap(); + let mut stmt = conn + .prepare("SELECT * FROM asset_locks ORDER BY outpoint") + .unwrap(); + let width = stmt.column_count(); + stmt.query_map([], |row| (0..width).map(|i| row.get(i)).collect()) + .unwrap() + .map(Result::unwrap) + .collect() +} + +/// R2: unconsumed asset locks of a wiped wallet survive the load untouched, +/// and a rescan that finds one of their transactions stores it without +/// dropping any lock. +#[tokio::test] +async fn should_keep_unconsumed_asset_locks_when_a_wiped_wallet_loads_and_rescans() { + let (persister, _dir, path) = common::fresh_persister(); + let (wallet_id, address) = wiped_wallet(&persister); + let chain = Chain::new(&address); + let entry = |transaction: &Transaction, status| { + let out_point = OutPoint::new(transaction.txid(), 0); + let entry = AssetLockEntry { + out_point, + transaction: transaction.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 19_000, + status, + proof: None, + operation_id: None, + }; + (out_point, entry) + }; + let other = |byte| spend_of(&[outside(byte)], vec![]); + let instant = other(32); + let (lock, evidence) = common::checked_instant_lock(&instant).await; + let mut locks: BTreeMap<_, _> = [ + entry(&other(31), AssetLockStatus::Built), + entry(&chain.lock, AssetLockStatus::Broadcast), + entry(&instant, AssetLockStatus::InstantSendLocked), + entry(&other(33), AssetLockStatus::ChainLocked), + ] + .into(); + locks + .get_mut(&OutPoint::new(instant.txid(), 0)) + .unwrap() + .proof = Some(dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof::new( + lock, + instant.clone(), + 0, + ), + )); + let round = PlatformWalletChangeSet { + asset_locks: Some(AssetLockChangeSet { + asset_locks: locks.clone(), + ..Default::default() + }), + ..Default::default() + }; + persister.store(wallet_id, round).unwrap(); + drop(persister); + let rows = asset_lock_rows(&path); + assert_eq!(rows.len(), 4); + + let loaded = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .unwrap() + .load_with_replay(&evidence) + .unwrap() + .state; + let restored: BTreeMap<_, _> = loaded.wallets[&wallet_id] + .unused_asset_locks + .values() + .flatten() + .map(|(out_point, lock)| (*out_point, lock.status.clone())) + .collect(); + let expected: BTreeMap<_, _> = locks + .iter() + .map(|(op, e)| (*op, e.status.clone())) + .collect(); + assert_eq!(restored, expected, "every unconsumed lock loads as stored"); + assert_eq!( + asset_lock_rows(&path), + rows, + "the load leaves the rows untouched" + ); + + let state = rescan_with_evidence(&path, wallet_id, &chain.blocks(), &evidence).await; + + assert_eq!( + stored(&path, &wallet_id, &chain.lock).direction, + TransactionDirection::Internal + ); + let after: Vec<_> = state.wallets[&wallet_id] + .unused_asset_locks + .values() + .flat_map(|locks| locks.keys().copied()) + .collect(); + assert_eq!( + after, + locks.keys().copied().collect::>(), + "no lock is dropped" + ); + assert_eq!( + asset_lock_rows(&path), + rows, + "the rescan leaves the rows untouched" + ); +} + +/// The coins `info` can spend, in outpoint order. +fn spendable(info: &ManagedWalletInfo) -> Vec { + let mut coins: Vec<_> = info.accounts.standard_bip44_accounts[&0] + .utxos + .keys() + .copied() + .collect(); + coins.sort(); + coins +} + +/// A manager holding `wallet` with `wallet_info`, and its event receiver. +fn manager_of( + wallet: Wallet, + wallet_info: ManagedWalletInfo, +) -> (Manager, UnboundedReceiver) { + let mut manager = WalletManager::::new(Network::Testnet); + let events = manager.take_persistence_receiver().unwrap(); + let mut info = PlatformWalletInfo::from_wallet(&wallet, 0); + info.core_wallet = wallet_info; + manager.insert_wallet(wallet, info).unwrap(); + (Arc::new(RwLock::new(manager)), events) +} + +/// R4: a V019 store holding unmined spends of a confirmed funding. A block +/// rescan redelivers the funding but never the spends, so V021 keeps their +/// evidence for the load to authenticate: an InstantSend-locked spend and a +/// signed pending send keep their inputs reserved, a forged claim is +/// released. Nothing is redelivered from the mempool or InstantSend. +#[tokio::test] +async fn should_keep_unmined_spends_reserved_when_an_upgraded_wallet_rescans() { + let (persister, _dir, path) = common::fresh_persister(); + let (wallet, info, address) = wallet_with_receive_address(); + let wallet_id = wallet.wallet_id; + persister + .store(wallet_id, registration(&wallet, None)) + .unwrap(); + let funding = spend_of( + &[outside(41)], + vec![ + pay(100_000, &address), + pay(50_000, &address), + pay(30_000, &address), + ], + ); + let coin = |vout| OutPoint::new(funding.txid(), vout); + let locked = spend_of( + &[coin(0)], + vec![pay(60_000, &foreign_address(7)), pay(39_000, &address)], + ); + let signed = common::authorize( + &wallet, + &info, + &address, + spend_of(&[coin(1)], vec![pay(49_000, &foreign_address(6))]), + ); + let forged = spend_of(&[coin(2)], vec![pay(29_000, &foreign_address(5))]); + let (lock, evidence) = common::checked_instant_lock(&locked).await; + let (manager, mut events) = manager_of(wallet, info); + let persister = Arc::new(persister); + mine(&manager, wallet_id, funding.clone(), 100).await; + for (tx, lock) in [(&locked, Some(lock)), (&signed, None), (&forged, None)] { + let mut guard = manager.write().await; + assert!( + guard + .process_mempool_transaction(tx, lock) + .await + .is_relevant + ); + } + persist_events(&manager, &persister, &mut events).await; + drop((manager, events, persister)); + rewind_v020(&path); + + let persister = Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap()); + let loaded = persister + .load_with_replay(&evidence) + .unwrap() + .state + .wallets + .remove(&wallet_id) + .unwrap(); + let (manager, mut events) = manager_of(loaded.wallet, loaded.wallet_info); + // The complete block rescan: the funding is the only mined transaction. + mine(&manager, wallet_id, funding.clone(), 100).await; + persist_events(&manager, &persister, &mut events).await; + let live = spendable( + &manager + .read() + .await + .get_wallet_info(&wallet_id) + .unwrap() + .core_wallet, + ); + let reloaded = persister.load_with_replay(&evidence).unwrap().state; + + let mut expected = vec![coin(2), OutPoint::new(locked.txid(), 1)]; + expected.sort(); + assert_eq!(live, expected, "the rescanned wallet reserves both spends"); + assert_eq!( + spendable(&reloaded.wallets[&wallet_id].wallet_info), + expected, + "the reload keeps both reservations" + ); + let conn = rusqlite::Connection::open(&path).unwrap(); + let row = |tx: &Transaction| -> Option<(Option, bool)> { + conn.query_row( + "SELECT height, record_blob IS NOT NULL FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![wallet_id.as_slice(), tx.txid().as_byte_array().as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() + }; + assert_eq!( + row(&funding), + Some((Some(100), true)), + "the rescan records the height-only funding again" + ); + assert!(matches!( + stored(&path, &wallet_id, &funding).context, + TransactionContext::InBlock(_) + )); + assert_eq!(row(&locked), Some((None, true))); + assert_eq!(row(&signed), Some((None, true))); + assert_eq!(row(&forged), None, "the forged spend is gone"); + + // The kept spends mine later, and the wallet records them again. + mine(&manager, wallet_id, locked.clone(), 101).await; + mine(&manager, wallet_id, signed.clone(), 102).await; + persist_events(&manager, &persister, &mut events).await; + drop((manager, events, persister)); + let mined = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .unwrap() + .load_with_replay(&evidence) + .unwrap() + .state; + assert_eq!(row(&locked), Some((Some(101), true))); + assert_eq!(row(&signed), Some((Some(102), true))); + for (tx, value, net) in [(&locked, 100_000, -61_000), (&signed, 50_000, -50_000)] { + let record = stored(&path, &wallet_id, tx); + assert!(matches!(record.context, TransactionContext::InBlock(_))); + let details: Vec<_> = record + .input_details + .iter() + .map(|d| (d.index, d.value, d.address.clone())) + .collect(); + assert_eq!(details, vec![(0, value, address.clone())], "{}", tx.txid()); + assert_eq!( + (record.net_amount, record.direction), + (net, TransactionDirection::Outgoing), + "{}", + tx.txid() + ); + } + assert_eq!( + spendable(&mined.wallets[&wallet_id].wallet_info), + expected, + "mining the kept spends settles the same coins" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs new file mode 100644 index 00000000000..e087b8a56e8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -0,0 +1,1051 @@ +//! Canonical redelivery of a migrated historical AssetUnlock identity. +//! Both literal fixtures were emitted by d631aaca3632cc713143476e363c9a9aca038d2e. +//! The signed fixture uses the public test key [0x19; 32] and SIGHASH_ALL; +//! signature preservation is tested independently of chain/quorum validity. + +use crate::common::{ensure_wallet_meta, fresh_persister, registration, wid}; +use dashcore::sighash::SighashCache; +use dashcore::{consensus, Address, Network, OutPoint, Txid}; +use dpp::secp256k1::{ecdsa, ecdsa::Signature, Message, PublicKey}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::Utxo; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::test_support::core_fixtures::wallet_with_receive_address; +use platform_wallet::PlatformWalletManager; +use platform_wallet_storage::sqlite::schema::blob; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use rusqlite::params; +use std::sync::Arc; + +fn migrated_history() -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + migrated_fixture(include_str!("../fixtures/v019_asset_unlock_v2.txt")) +} + +fn migrated_fixture( + fixture: &str, +) -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + let (persister, temp, path) = fresh_persister(); + let wallet = wid(0x79); + ensure_wallet_meta(&persister, &wallet); + let mut records = Vec::new(); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DROP INDEX idx_core_utxos_funding; DROP INDEX idx_core_utxos_claimant; \ + DELETE FROM refinery_schema_history WHERE version >= 20;", + ) + .unwrap(); + for line in fixture.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + let bytes = hex::decode(fields[2]).unwrap(); + let mut record: TransactionRecord = blob::decode(&bytes).unwrap(); + assert_eq!( + record.txid, + Txid::from_slice(&hex::decode(fields[1]).unwrap()).unwrap() + ); + // Remove only the test copy's accounting evidence: raw consumer bytes stay literal. + if !record.transaction.input.is_empty() { + record.input_details.clear(); + record.net_amount = 0; + } + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(&record).unwrap()], + ).unwrap(); + records.push(record); + } + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, 19); + } + drop(persister); + let migrated = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = migrated.lock_conn_for_test(); + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + version, + platform_wallet_storage::sqlite::migrations::max_supported_version() + ); + } + let consumer = records.pop().unwrap(); + let funding = records.pop().unwrap(); + assert_ne!(funding.txid, funding.transaction.txid()); + assert_eq!( + consumer.transaction.input[0].previous_output, + OutPoint::new(funding.txid, 0) + ); + (migrated, temp, funding, consumer) +} + +fn canonical_redelivery(persister: &SqlitePersister, funding: &TransactionRecord, with_coin: bool) { + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + let new_utxos = if with_coin { + let output = canonical.transaction.output[0].clone(); + vec![Utxo { + outpoint: OutPoint::new(canonical.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }] + } else { + vec![] + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + new_utxos, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); +} + +fn assert_consumer_bytes_and_raw_index_unchanged( + persister: &SqlitePersister, + original: &TransactionRecord, +) { + let loaded = persister + .get_core_tx_record(wid(0x79), &original.txid) + .unwrap() + .unwrap(); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&original.transaction) + ); + let conn = persister.lock_conn_for_test(); + let indexed: Vec = conn + .query_row( + "SELECT outpoint FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + original.txid.as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode_outpoint(&indexed).unwrap(), + original.transaction.input[0].previous_output + ); +} + +#[test] +fn should_keep_one_history_record_after_migration_and_canonical_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let conn = persister.lock_conn_for_test(); + let count: i64 = conn.query_row( + "SELECT count(*) FROM core_transactions WHERE wallet_id = ?1 AND txid IN (?2, ?3) AND record_blob IS NOT NULL", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice(), funding.transaction.txid().as_byte_array().as_slice()], + |row| row.get(0), + ).unwrap(); + assert_eq!( + count, 1, + "one body must not become two history records through a hash-policy upgrade" + ); +} + +#[test] +fn should_repair_a_legacy_raw_prevout_when_canonical_funding_arrives() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, true); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.net_amount, -150_000, + "canonical funding must reach the raw legacy prevout's accounting" + ); + assert_eq!(loaded.input_details.len(), 1); +} + +#[test] +fn should_merge_owned_details_when_alias_and_canonical_history_already_coexist() { + let (persister, _temp, funding, consumer) = migrated_history(); + let mut partial = funding.clone(); + partial.txid = partial.transaction.txid(); + partial.output_details.clear(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wid(0x79).as_slice(), partial.txid.as_byte_array().as_slice(), blob::encode(&partial).unwrap()], + ).unwrap(); + } + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &partial.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.output_details.len(), + 1, + "known ownership must survive alias reconciliation" + ); +} + +fn seed_record(persister: &SqlitePersister, wallet: [u8; 32], record: &TransactionRecord) { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), record.block_info().map(|b| b.height()), record.block_info().map(|b| b.block_hash().to_byte_array().to_vec()), record.block_info().map(|b| b.timestamp()), record.block_info().is_some(), blob::encode(record).unwrap()], + ).unwrap(); +} + +fn confirmed() -> key_wallet::transaction_checking::TransactionContext { + key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([42; 32]), + 420, + ), + ) +} + +#[test] +fn should_preserve_active_confirmed_alias_context_on_late_mempool_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + funding.fee = Some(1_000); + funding.label = "old funding".to_owned(); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::Mempool; + late.fee = None; + late.label.clear(); + canonical_redelivery(&persister, &late, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, funding.context); + assert_eq!(loaded.fee, funding.fee); + assert_eq!(loaded.label, funding.label); +} + +#[test] +fn should_keep_chainlocked_alias_context_when_canonical_snapshot_is_only_in_block() { + let (persister, _temp, mut funding, _) = migrated_history(); + let block = *confirmed().block_info().unwrap(); + funding.context = + key_wallet::transaction_checking::TransactionContext::InChainLockedBlock(block); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::InBlock(block); + canonical_redelivery(&persister, &late, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + funding.context + ); +} + +fn snapshot(persister: &SqlitePersister, table: &str) -> Vec> { + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY 1, 2")) + .unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() +} + +#[test] +fn should_resolve_multiple_legacy_instances_without_mutating_archived_originals() { + let (persister, temp, funding, consumer) = migrated_history(); + let mut second = funding.clone(); + if let Some( + dashcore::transaction::special_transaction::TransactionPayload::AssetUnlockPayloadType( + payload, + ), + ) = &mut second.transaction.special_transaction_payload + { + payload.quorum_sig = [0x43; 96].into(); + } + second.txid = second.transaction.instance_hash(); + assert_ne!(second.txid, funding.txid); + assert_eq!(second.transaction.txid(), funding.transaction.txid()); + seed_record(&persister, wid(0x79), &second); + canonical_redelivery(&persister, &funding, false); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let rows = snapshot(&persister, "core_transactions"); + canonical_redelivery(&persister, &second, false); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_eq!(snapshot(&persister, "core_transactions"), rows); + drop(persister); + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(temp.path().join("wallet.db"))).unwrap(); + for id in [funding.txid, second.txid, funding.transaction.txid()] { + let record = persister + .get_core_tx_record(wid(0x79), &id) + .unwrap() + .unwrap(); + assert_eq!(record.txid, funding.transaction.txid()); + assert_eq!(record.transaction, funding.transaction); + assert_eq!(record.output_details.len(), 1); + } + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_claims_and_physical_metadata_anchors_during_history_adoption() { + let (persister, _temp, funding, _) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let utxo = Utxo { + outpoint: OutPoint::new(funding.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_utxos SET spent = 1, spent_in_txid = ?1, winner_mined_height = 17", + params![funding.txid.as_byte_array().as_slice()], + ) + .unwrap(); + } + let before = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!(snapshot(&persister, "core_utxos"), before); + let conn = persister.lock_conn_for_test(); + let anchor: (Option>, Option, i64) = conn.query_row( + "SELECT record_blob, height, finalized FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).unwrap(); + assert_eq!(anchor, (None, None, 0)); +} + +#[test] +fn should_not_resurrect_history_or_metadata_from_archive_after_canonical_removal() { + let (persister, _temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.transaction.txid().as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .is_none()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .output_details + .is_empty(), + "archive proves identity, never ownership" + ); +} + +#[test] +fn should_rollback_conflicting_confirmed_alias_contexts() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + incoming.context = key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 43, + dashcore::BlockHash::from_byte_array([43; 32]), + 430, + ), + ); + let error = persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap_err(); + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("backend error expected") + }; + assert!(matches!( + source.downcast_ref::(), + Some(platform_wallet_storage::WalletStorageError::HistoryAliasContextConflict { .. }) + )); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); +} + +#[test] +fn should_reject_corrupt_alias_evidence_in_strict_and_recovery_without_writes() { + let (persister, temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + let mut tampered = funding.clone(); + tampered.transaction.output[0].value += 1; + conn.execute("UPDATE core_transaction_record_originals SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", params![blob::encode(&tampered).unwrap(), wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()]).unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} + +#[test] +fn should_keep_alias_projection_wallet_scoped_and_legacy_only_identity_unchanged() { + let (persister, _temp, funding, _) = migrated_history(); + let other = wid(0x80); + ensure_wallet_meta(&persister, &other); + seed_record(&persister, other, &funding); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(other, &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(other, &funding.transaction.txid()) + .unwrap() + .is_none()); +} + +fn assert_signed_consumer(funding: &TransactionRecord, consumer: &TransactionRecord) { + let pushes: Vec<_> = consumer.transaction.input[0] + .script_sig + .instructions() + .map(|instruction| { + instruction + .unwrap() + .push_bytes() + .unwrap() + .as_bytes() + .to_vec() + }) + .collect(); + assert_eq!(pushes.len(), 2); + assert_eq!(*pushes[0].last().unwrap(), 1, "SIGHASH_ALL"); + let signature = Signature::from_der(&pushes[0][..pushes[0].len() - 1]).unwrap(); + let public = PublicKey::from_slice(&pushes[1]).unwrap(); + let script = Address::p2pkh( + &dashcore::PublicKey::from_slice(&pushes[1]).unwrap(), + Network::Testnet, + ) + .script_pubkey(); + assert_eq!(script, funding.transaction.output[0].script_pubkey); + let hash = SighashCache::new(&consumer.transaction) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + ecdsa::verify( + &signature, + Message::from_digest(hash.to_byte_array()), + &public, + ) + .unwrap(); + let mut rekeyed = consumer.transaction.clone(); + rekeyed.input[0].previous_output.txid = funding.transaction.txid(); + let changed_hash = SighashCache::new(&rekeyed) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + assert!( + ecdsa::verify( + &signature, + Message::from_digest(changed_hash.to_byte_array()), + &public + ) + .is_err(), + "rewriting the legacy prevout would invalidate this signature" + ); +} + +#[test] +fn should_preserve_known_key_signed_consumer_through_migration_and_redelivery() { + let fixture = include_str!("../fixtures/v019_asset_unlock_v2_signed.txt"); + let originals: Vec = fixture + .lines() + .map(|line| { + blob::decode(&hex::decode(line.split_whitespace().nth(2).unwrap()).unwrap()).unwrap() + }) + .collect(); + assert_signed_consumer(&originals[0], &originals[1]); + let (persister, _temp, funding, consumer) = migrated_fixture(fixture); + canonical_redelivery(&persister, &funding, true); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_signed_consumer(&funding, &loaded); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&originals[1].transaction) + ); + assert_eq!(loaded.net_amount, -150_000); +} + +#[test] +fn should_preserve_active_canonical_context_while_adopting_existing_legacy_sibling() { + let (persister, _temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = confirmed(); + seed_record(&persister, wid(0x79), &canonical); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_accept_same_block_promotion_with_newly_known_position() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = key_wallet::transaction_checking::TransactionContext::InChainLockedBlock( + confirmed().block_info().unwrap().with_position(7), + ); + canonical_redelivery(&persister, &canonical, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_not_promote_later_canonical_context_from_archived_or_blobless_alias() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + funding.context = key_wallet::transaction_checking::TransactionContext::Mempool; + canonical_redelivery(&persister, &funding, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, key_wallet::transaction_checking::TransactionContext::Mempool, + "ordinary canonical upsert keeps its existing policy; archive/anchors never promote context"); +} + +#[test] +fn should_repair_existing_canonical_coin_consumers_when_alias_is_adopted_without_coin_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let coin = Utxo { + outpoint: OutPoint::new(funding.transaction.txid(), 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![coin], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + 0 + ); + let coins = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + -150_000 + ); + assert_eq!(snapshot(&persister, "core_utxos"), coins); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_unrepresentable_mempool_accounting_during_alias_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.transaction.output[0].value = u64::MAX; + funding.output_details[0].value = u64::MAX; + funding.txid = funding.transaction.instance_hash(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_not_recompute_alias_accounting_without_owned_details() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.output_details.clear(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_rollback_archiving_and_anchor_changes_when_canonical_upsert_fails_then_retry() { + let (persister, _temp, funding, consumer) = migrated_history(); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch("CREATE TEMP TRIGGER reject_canonical_insert BEFORE INSERT ON core_transactions BEGIN SELECT RAISE(ABORT, 'injected canonical upsert failure'); END;").unwrap(); + } + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER reject_canonical_insert;") + .unwrap(); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.transaction.txid() + ); +} + +#[test] +fn should_preserve_recovery_height_drift_policy_during_alias_projection() { + let (persister, temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + seed_record(&persister, wid(0x79), &canonical); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.txid.as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &canonical.txid) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + let loaded = recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.txid, canonical.txid); + assert_eq!(loaded.context, funding.context); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} + +struct NoopAliasEvents; +impl platform_wallet::events::EventHandler for NoopAliasEvents {} +impl platform_wallet::events::PlatformEventHandler for NoopAliasEvents {} + +/// Literal old-writer bodies on the current schema isolate replay from rescan policy. +async fn signed_history_load_and_reopen(adopted: bool) { + let (persister, _directory, path) = fresh_persister(); + let (wallet, _, _) = wallet_with_receive_address(); + let wallet_id = wallet.wallet_id; + persister + .store(wallet_id, registration(&wallet, None)) + .unwrap(); + let records: Vec = + include_str!("../fixtures/v019_asset_unlock_v2_signed.txt") + .lines() + .map(|line| { + blob::decode(&hex::decode(line.split_whitespace().nth(2).unwrap()).unwrap()) + .unwrap() + }) + .collect(); + let (funding, consumer) = (&records[0], &records[1]); + assert_signed_consumer(funding, consumer); + for record in &records { + seed_record(&persister, wallet_id, record); + } + if adopted { + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + persister + .store( + wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + } + drop(persister); + + for _ in 0..2 { + let persister = Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap()); + let loaded = persister + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + let state = &loaded.state.wallets[&wallet_id]; + let history = state.wallet_info.accounts.standard_bip44_accounts[&0].transactions(); + assert_eq!( + history + .values() + .filter(|record| record.transaction.txid() == funding.transaction.txid()) + .count(), + 1, + "one legacy funding body must remain one logical history record" + ); + let restored_consumer = history + .get(&consumer.txid) + .expect("signed consumer survives replay"); + assert_eq!( + consensus::serialize(&restored_consumer.transaction), + consensus::serialize(&consumer.transaction) + ); + assert_signed_consumer(funding, restored_consumer); + assert_eq!( + state.wallet_info.balance.total(), + 0, + "historical ownership annotations alone cannot credit this unrelated HD wallet" + ); + let physical_key = if adopted { + funding.transaction.txid() + } else { + funding.txid + }; + assert!(state + .recorded_history + .transactions + .iter() + .any(|record| record.txid == physical_key)); + { + let conn = persister.lock_conn_for_test(); + let raw: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![ + wallet_id.as_slice(), + consumer.txid.as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + let record: TransactionRecord = blob::decode(&raw).unwrap(); + assert_eq!( + consensus::serialize(&record.transaction), + consensus::serialize(&consumer.transaction) + ); + } + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::clone(&persister), + Arc::new(NoopAliasEvents), + ); + manager.load_from_persistor().await.unwrap(); + let published = manager + .get_wallet(&wallet_id) + .await + .expect("manager publishes the restored wallet"); + let guard = published.wallet_manager().read().await; + let info = guard.get_wallet_info(&wallet_id).unwrap(); + assert_eq!( + info.core_wallet.accounts.standard_bip44_accounts[&0] + .transactions() + .values() + .filter(|record| record.transaction.txid() == funding.transaction.txid()) + .count(), + 1 + ); + drop(guard); + drop(published); + assert!(manager.shutdown().await.all_clean()); + drop(manager); + drop(persister); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_load_literal_legacy_only_history_through_replay_manager_and_reopen() { + signed_history_load_and_reopen(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_load_adopted_literal_history_through_replay_manager_and_reopen() { + signed_history_load_and_reopen(true).await; +} + +#[tokio::test] +async fn should_keep_one_literal_legacy_funding_record_when_its_script_is_owned() { + use key_wallet::managed_account::address_pool::AddressInfo; + use platform_wallet::manager::history_replay::replay_recorded_history; + + let (persister, _directory, _) = fresh_persister(); + let (wallet, _, _) = wallet_with_receive_address(); + let wallet_id = wallet.wallet_id; + persister + .store(wallet_id, registration(&wallet, None)) + .unwrap(); + let records: Vec = + include_str!("../fixtures/v019_asset_unlock_v2_signed.txt") + .lines() + .map(|line| { + blob::decode(&hex::decode(line.split_whitespace().nth(2).unwrap()).unwrap()) + .unwrap() + }) + .collect(); + assert_signed_consumer(&records[0], &records[1]); + for record in &records { + seed_record(&persister, wallet_id, record); + } + let mut state = persister + .load_read_snapshot() + .unwrap() + .wallets + .remove(&wallet_id) + .unwrap(); + // The literal fixture uses a standalone known key, not this test wallet's HD seed. + // Install only its public script in the owner seam; raw history stays unchanged. + let script = records[0].transaction.output[0].script_pubkey.clone(); + let address = Address::from_script(&script, Network::Testnet).unwrap(); + { + let account = state + .wallet_info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + let mut pools = account.managed_account_type_mut().address_pools_mut(); + let pool = &mut pools[0]; + let info = AddressInfo::new_from_script_pubkey_p2pkh( + script.clone(), + 100, + pool.base_path.clone(), + Network::Testnet, + ) + .unwrap(); + pool.addresses.insert(100, info); + pool.address_index.insert(address.clone(), 100); + pool.script_pubkey_index.insert(script, 100); + // Gap maintenance must advance past the manually installed entry. + pool.highest_generated = Some(pool.highest_generated.unwrap_or(0).max(100)); + assert!(account.contains_address(&address)); + } + replay_recorded_history( + &mut state.wallet_info, + &mut state.wallet, + state.recorded_history, + ) + .await + .unwrap(); + let history = state.wallet_info.accounts.standard_bip44_accounts[&0].transactions(); + assert_eq!( + history + .values() + .filter(|record| record.transaction.txid() == records[0].transaction.txid()) + .count(), + 1, + "replay must not restore physical I beside the same funding body under C" + ); + assert_eq!( + consensus::serialize(&history[&records[1].txid].transaction), + consensus::serialize(&records[1].transaction) + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs index 95a9aa665bd..35277f2b65f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs @@ -1539,9 +1539,10 @@ fn tc_p4_012_load_query_count_bounded() { "per-wallet statement count must be constant \ (N=1 → {count_one}, N=10 → {count_ten}, delta → {delta})" ); + // Atomic replay also reads durable claims and spent prevout material. let per_wallet = delta / 9; assert!( - (1..=20).contains(&per_wallet), + (1..=21).contains(&per_wallet), "per-wallet statement count must be small + bounded, got {per_wallet}" ); // Shared (wallet-count-independent) overhead: the `list_ids` + @@ -1823,7 +1824,7 @@ fn tc_p4_020_unignore_survives_restart_table_is_authoritative() { } #[test] -fn should_load_wallet_from_one_snapshot_during_an_external_commit() { +fn should_exclude_external_writers_while_loading_one_consistent_snapshot() { use key_wallet::account::{AccountType, StandardAccountType}; use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; @@ -1852,23 +1853,36 @@ fn should_load_wallet_from_one_snapshot_during_an_external_commit() { }, ) .unwrap(); - let writer = rusqlite::Connection::open(path).unwrap(); + let writer = rusqlite::Connection::open(&path).unwrap(); + writer.busy_timeout(std::time::Duration::ZERO).unwrap(); let changed = Arc::new(AtomicBool::new(false)); let changed_hook = Arc::clone(&changed); - persister.lock_conn_for_test().authorizer(Some(move |ctx: AuthContext<'_>| { - if matches!(ctx.action, AuthAction::Read { table_name: "core_sync_state", .. }) - && !changed_hook.swap(true, Ordering::SeqCst) - { - writer.execute_batch("BEGIN IMMEDIATE; UPDATE wallets SET birth_height = 10; UPDATE core_sync_state SET synced_height = 200; COMMIT;").unwrap(); - } - Authorization::Allow - })).unwrap(); + persister + .lock_conn_for_test() + .authorizer(Some(move |ctx: AuthContext<'_>| { + if matches!( + ctx.action, + AuthAction::Read { + table_name: "core_sync_state", + .. + } + ) && !changed_hook.swap(true, Ordering::SeqCst) + { + let error = writer.execute_batch("BEGIN IMMEDIATE").unwrap_err(); + assert_eq!( + error.sqlite_error_code(), + Some(rusqlite::ErrorCode::DatabaseBusy) + ); + } + Authorization::Allow + })) + .unwrap(); let state = persister.load().unwrap(); assert!( changed.load(Ordering::SeqCst), - "external commit must occur during load" + "external writer must attempt to enter during load" ); let metadata = &state.wallets[&wallet_id].wallet_info.metadata; assert_eq!(metadata.birth_height, 0); @@ -1876,4 +1890,13 @@ fn should_load_wallet_from_one_snapshot_during_an_external_commit() { metadata.synced_height, 100, "wallet metadata and checkpoint share one snapshot" ); + persister + .lock_conn_for_test() + .authorizer(None::) -> Authorization>) + .unwrap(); + let writer = rusqlite::Connection::open(&path).unwrap(); + writer.execute_batch("BEGIN IMMEDIATE; UPDATE wallets SET birth_height = 10; UPDATE core_sync_state SET synced_height = 200; COMMIT;").unwrap(); + let next = persister.load().unwrap(); + let metadata = &next.wallets[&wallet_id].wallet_info.metadata; + assert_eq!((metadata.birth_height, metadata.synced_height), (10, 200)); } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs index b0cfc1ee715..fb6682cbd51 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs @@ -159,8 +159,8 @@ fn transaction_height_and_blob(conn: &Connection, wallet: &WalletId) -> (Option< .unwrap() } -/// Assert the post-migration store carries the full fixture data intact. -fn assert_full_data_preserved(conn: &Connection) { +/// Assert V021 rewinds mined Core data while retaining wallet and identity state. +fn assert_migrated_data(conn: &Connection) { let full = wid(FULL_WALLET); assert_eq!( schema_version(conn), @@ -186,24 +186,22 @@ fn assert_full_data_preserved(conn: &Connection) { "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", &full, ); - assert_eq!(utxos, 1, "UTXO preserved"); + assert_eq!(utxos, 0, "V021 rescans mined outputs from birth"); assert_eq!( count( conn, "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", &full ), - 1 + 0, + "V021 rescans mined history with no retained unmined claims" ); - let (height, record_blob) = transaction_height_and_blob(conn, &full); - assert_eq!(height, Some(200), "transaction height preserved by V010"); - let record: key_wallet::managed_account::transaction_record::TransactionRecord = - platform_wallet_storage::sqlite::schema::blob::decode(&record_blob) - .expect("transaction record blob preserved by V010"); - assert_eq!( - record.height(), - Some(200), - "transaction record blob retains its block context" + let (synced, birth): (i64, i64) = conn.query_row( + "SELECT s.synced_height, w.birth_height FROM core_sync_state s JOIN wallets w USING(wallet_id) WHERE wallet_id=?1", + rusqlite::params![full.as_slice()], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert!( + synced <= birth.saturating_sub(1).max(0), + "rescan must start at wallet birth" ); assert_eq!( count( @@ -257,17 +255,34 @@ fn v4_2_dev_database_opens_and_migrates_forward() { ); transaction_height_and_blob(&pre, &wid(FULL_WALLET)) }; - let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert_eq!(original_transaction.0, Some(200)); + let record: key_wallet::managed_account::transaction_record::TransactionRecord = + platform_wallet_storage::sqlite::schema::blob::decode(&original_transaction.1).unwrap(); + assert_eq!(record.height(), Some(200)); + // Pin preservation through V020 before the deliberate V021 rescan. { - let conn = p.lock_conn_for_test(); - assert_full_data_preserved(&conn); + let mut conn = Connection::open(&path).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); assert_eq!( transaction_height_and_blob(&conn, &wid(FULL_WALLET)), original_transaction, - "the rebuild must preserve the fixture transaction height and blob byte-for-byte" + "V010 rebuild and V020 accounting preserve this fixture byte-for-byte" + ); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id=?1", + &wid(FULL_WALLET) + ), + 1 ); } - // The full wallet reconstructs; converted pool and UTXO usage agree. + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert_migrated_data(&p.lock_conn_for_test()); + // The full wallet reconstructs and retained pool usage still prevents address reuse. let state = p.load().unwrap(); let full = wid(FULL_WALLET); assert!( @@ -277,7 +292,7 @@ fn v4_2_dev_database_opens_and_migrates_forward() { assert_eq!( used_set(&p, &full).len(), 1, - "migrated store falls back to the UTXO-derived used-set" + "persisted address usage survives the Core rescan" ); } @@ -329,8 +344,11 @@ fn v014_backfills_recordless_confirmed_utxo_height() { assert_eq!(schema_version(&conn), 13); } - let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); - let conn = persister.lock_conn_for_test(); + let mut conn = Connection::open(&path).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); let (height, record_blob): (Option, Option>) = conn .query_row( "SELECT height, record_blob FROM core_transactions \ @@ -359,6 +377,24 @@ fn v014_backfills_recordless_confirmed_utxo_height() { legacy_unconfirmed_rows, 0, "legacy height zero was the unconfirmed sentinel and must not be backfilled" ); + mig::run(&mut conn).unwrap(); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id=?1", + &wallet_id + ), + 0 + ); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id=?1", + &wallet_id + ), + 0, + "V021 removes the height-only record when its funding is rescanned" + ); } /// The empty wallet inside the populated store migrates without a @@ -501,7 +537,7 @@ fn backup_restorable_and_remigration_deterministic() { } let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&dest)).unwrap(); let conn = p2.lock_conn_for_test(); - assert_full_data_preserved(&conn); + assert_migrated_data(&conn); } /// The forward-version gate rejects at the newest embedded diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs index ce850eb949c..7cba67f1e4c 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs @@ -293,6 +293,11 @@ fn tc044_load_empty_is_empty() { /// existing install experiences. #[test] fn tc045_v004_widens_asset_lock_status_on_existing_db() { + use dashcore::{OutPoint, Transaction}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + use platform_wallet_storage::sqlite::schema::asset_locks::encode_entry_for_test; use rusqlite::params; let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); @@ -312,10 +317,28 @@ fn tc045_v004_widens_asset_lock_status_on_existing_db() { ) .expect("insert wallet"); let outpoint_a = [1u8; 36]; + let lifecycle = encode_entry_for_test(&AssetLockEntry { + operation_id: None, + out_point: OutPoint::null(), + transaction: Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 4, + amount_duffs: 1_000_000, + status: AssetLockStatus::ChainLocked, + proof: None, + }) + .unwrap(); conn.execute( "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ - amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'chain_locked', 0, 4, 1000000, X'01')", - params![wallet_id.as_slice(), outpoint_a.as_slice()], + amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'chain_locked', 0, 4, 1000000, ?3)", + params![wallet_id.as_slice(), outpoint_a.as_slice(), lifecycle], ) .expect("insert pre-upgrade asset lock"); @@ -482,8 +505,11 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { "expected AddressDecode from the empty script, got {err:?}" ); - // 4. Upgrade to the latest schema (applies V013's purge). - mig::run(&mut conn).expect("migrate to latest"); + // Check the purge before V021 intentionally removes recordless unspent rows. + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); // 5. The poisoned row is gone... let poisoned_rows: i64 = conn @@ -541,6 +567,17 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { unspent_rows, 1, "an unspent row is balance state and must survive any script content" ); + mig::run(&mut conn).unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + params![untouched.as_slice()], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0, + "V021 rebuilds recordless unspent outputs through rescan" + ); } /// V014 rebuilds `core_transactions` into an FK-declaring twin and backfills diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs new file mode 100644 index 00000000000..a3a89b6f140 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs @@ -0,0 +1,457 @@ +use std::collections::BTreeMap; + +use dashcore::OutPoint; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{CoreChangeSet, PlatformWalletPersistence}; +use platform_wallet::test_support::core_fixtures::{ + block, outside, pay, records_of, spend_of, stored_utxo, wallet_with_receive_address, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +use crate::common::{authorize, checked_instant_lock, fresh_persister, registration}; + +fn rewind_to_v020(path: &std::path::Path) { + rusqlite::Connection::open(path) + .unwrap() + .execute_batch( + "DROP INDEX IF EXISTS idx_core_utxos_funding; + DROP INDEX IF EXISTS idx_core_utxos_claimant; + DELETE FROM refinery_schema_history WHERE version >= 21;", + ) + .unwrap(); +} + +#[tokio::test] +async fn should_rescan_plain_wallet_without_erasing_another_wallet_mined_instant_lock() { + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(131)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + records.extend(records_of(&mut info, &mut wallet, &locked, block(101)).await); + let (raw_lock, evidence) = checked_instant_lock(&locked).await; + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&locked, 0, address)], + instant_locks_for_non_final_records: BTreeMap::from([(locked.txid(), raw_lock)]), + synced_height: Some(150), + ..Default::default() + }), + ); + changes.wallet_metadata.as_mut().unwrap().birth_height = 80; + persister.store(wallet.wallet_id, changes).unwrap(); + + let (mut plain, mut plain_info, plain_address) = wallet_with_receive_address(); + let plain_tx = spend_of(&[outside(132)], vec![pay(10_000, &plain_address)]); + let plain_records = records_of(&mut plain_info, &mut plain, &plain_tx, block(100)).await; + let mut changes = registration( + &plain, + Some(CoreChangeSet { + records: plain_records, + new_utxos: vec![stored_utxo(&plain_tx, 0, plain_address)], + synced_height: Some(150), + ..Default::default() + }), + ); + changes.wallet_metadata.as_mut().unwrap().birth_height = 60; + persister.store(plain.wallet_id, changes).unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert!( + reopened + .get_core_tx_record(plain.wallet_id, &plain_tx.txid()) + .unwrap() + .is_none(), + "the wallet without IS evidence must rebuild mined history by rescan" + ); + let conn = reopened.lock_conn_for_test(); + let height = |wallet: &[u8; 32]| { + conn.query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + [wallet.as_slice()], + |row| row.get::<_, u32>(0), + ) + .unwrap() + }; + assert_eq!(height(&plain.wallet_id), 59); + assert_eq!(height(&wallet.wallet_id), 79); + let raw: Vec = conn + .query_row( + "SELECT islock_blob FROM core_instant_locks WHERE wallet_id=?1 AND txid=?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + locked.txid().as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert!(!raw.is_empty()); + drop(conn); + assert!(reopened + .load_with_replay(&ReplayEvidence::default()) + .is_err()); + assert!(reopened + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + let mut loaded = reopened.load_with_replay(&evidence).unwrap(); + let mut state = loaded.state.wallets.remove(&wallet.wallet_id).unwrap(); + assert!(state + .wallet_info + .instant_send_locks() + .contains(&locked.txid())); + state.wallet_info.truncate_above(100).unwrap(); + assert!( + state.wallet_info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&locked.txid()), + "the authenticated IS must survive removal of its mining block after migration" + ); +} + +#[tokio::test] +async fn should_preserve_context_and_terminal_tracked_proofs_without_raw_lock_rows() { + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::TransactionContext; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + for tracked in [false, true] { + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(133)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + let (raw, evidence) = checked_instant_lock(&locked).await; + let context = if tracked { + TransactionContext::Mempool + } else { + TransactionContext::InstantSend(raw.clone()) + }; + records.extend(records_of(&mut info, &mut wallet, &locked, context).await); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + synced_height: Some(150), + ..Default::default() + }), + ); + if tracked { + let core = changes.core.as_mut().unwrap(); + core.spent_utxos.push(core.new_utxos[0].clone()); + let out_point = OutPoint::new(locked.txid(), 0); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: BTreeMap::from([( + out_point, + AssetLockEntry { + out_point, + transaction: locked.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status: AssetLockStatus::Consumed, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + raw, + locked.clone(), + 0, + ))), + operation_id: None, + }, + )]), + ..Default::default() + }); + } + persister.store(wallet.wallet_id, changes).unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert!( + reopened + .get_core_tx_record(wallet.wallet_id, &funding.txid()) + .unwrap() + .is_some(), + "tracked={tracked}" + ); + if !tracked { + assert!( + reopened + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "a stored InstantSend context still needs proof" + ); + } + let no_evidence = ReplayEvidence::default(); + let loaded = reopened + .load_with_replay(if tracked { &no_evidence } else { &evidence }) + .unwrap(); + let state = &loaded.state.wallets[&wallet.wallet_id]; + assert_eq!( + state + .wallet_info + .instant_send_locks() + .contains(&locked.txid()), + !tracked + ); + if tracked { + assert!(state + .wallet_info + .accounts + .all_funding_accounts() + .iter() + .all(|account| !account + .utxos + .contains_key(&OutPoint::new(funding.txid(), 0)))); + assert!( + state.unused_asset_locks.values().all(BTreeMap::is_empty), + "reading a consumed proof must not reactivate its lifecycle" + ); + } + } +} + +#[tokio::test] +async fn should_preserve_a_chain_locked_rival_when_rescanning_an_instant_lock_wallet() { + use key_wallet::transaction_checking::TransactionContext; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(134)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + let rival = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(98_000, &address)], + ); + let mut rival_info = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); + let TransactionContext::InBlock(block) = block(102) else { + unreachable!() + }; + records.extend( + records_of( + &mut rival_info, + &mut wallet, + &rival, + TransactionContext::InChainLockedBlock(block), + ) + .await, + ); + let (raw, _) = checked_instant_lock(&locked).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + instant_locks_for_non_final_records: [(locked.txid(), raw)].into(), + ..Default::default() + }), + ), + ) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + assert!(reopened + .get_core_tx_record(wallet.wallet_id, &rival.txid()) + .unwrap() + .is_some()); + let loaded = reopened + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert!(loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!(!loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&rival.txid())); +} + +#[tokio::test] +async fn should_preserve_unreadable_evidence_in_recovery_without_destructive_cleanup() { + use crate::common::LoadPolicy; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(135)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ), + ) + .unwrap(); + persister + .lock_conn_for_test() + .execute("UPDATE core_transactions SET record_blob=X'010203'", []) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + assert!(SqlitePersister::open(SqlitePersisterConfig::new(&path)).is_err()); + let reopened = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_load_policy(LoadPolicy::Recovery) + .with_auto_backup_dir(Some(path.with_extension("recovery-backups"))), + ) + .unwrap(); + let conn = reopened.lock_conn_for_test(); + let body: Vec = conn + .query_row("SELECT record_blob FROM core_transactions", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(body, [1, 2, 3]); + assert_eq!( + conn.query_row("SELECT count(*) FROM core_utxos", [], |row| row + .get::<_, u32>(0)) + .unwrap(), + 1 + ); +} + +#[tokio::test] +async fn should_roll_back_the_entire_rescan_on_a_storage_write_failure() { + use crate::common::LoadPolicy; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(136)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + synced_height: Some(150), + ..Default::default() + }), + ), + ) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TRIGGER fail_rescan BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END").unwrap(); + for policy in [LoadPolicy::Strict, LoadPolicy::Recovery] { + let result = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_load_policy(policy) + .with_auto_backup_dir(Some(path.with_extension(format!("{policy:?}-backups")))), + ); + let error = match result { + Ok(_) => panic!("injected write must fail"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains("injected migration failure"), + "{error:?}" + ); + let state: (u32, u32, u32, u32) = conn + .query_row( + "SELECT (SELECT max(version) FROM refinery_schema_history), + (SELECT count(*) FROM core_transactions), (SELECT count(*) FROM core_utxos), + (SELECT synced_height FROM core_sync_state)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(state, (20, 1, 1, 150)); + } +} + +#[tokio::test] +async fn should_keep_v020_accounting_archive_when_v021_rescans_the_record() { + use platform_wallet_storage::sqlite::schema::blob; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(137)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let mut legacy = records[0].clone(); + legacy.net_amount = 0; + let original = blob::encode(&legacy).unwrap(); + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ), + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + conn.execute("UPDATE core_transactions SET record_blob=?1", [&original]) + .unwrap(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; + DROP INDEX idx_core_utxos_funding; DROP INDEX idx_core_utxos_claimant; + DELETE FROM refinery_schema_history WHERE version>=20;", + ) + .unwrap(); + drop(conn); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let conn = reopened.lock_conn_for_test(); + let archived: Vec = conn + .query_row( + "SELECT record_blob FROM core_transaction_record_originals", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + archived, original, + "V021 must preserve V020's original bytes after clearing the live record" + ); + let counts: (u32, u32, u32) = conn.query_row("SELECT (SELECT count(*) FROM core_transactions), + (SELECT count(*) FROM core_transaction_inputs), + (SELECT count(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_core_utxos_funding','idx_core_utxos_claimant','idx_core_transaction_inputs_outpoint'))", + [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap(); + assert_eq!(counts, (0, 0, 3)); +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs index 863c2e4ef71..6a0b94212cc 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "fdcaec1d3a0a960adbe02405601166c32487f10b5a253f175041ae99af7c5326"; + "1b88153391ba20a55dc38170584259e562262a3c86d5ecab1caa85a09c20a5d2"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "d8fbb57cd8cbccd51c729dacd2fd3b7e72b5fe80cb38c2f603b75dc94730d4e2"; + "55689b73b7c4d1242948bda3676194879f5a17b10994aa22d7a4320c61ba8a1e"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs new file mode 100644 index 00000000000..993cf72b79a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -0,0 +1,393 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; +use dash_spv::{ClientConfig, DashSpvClient}; +use dashcore::{Network, OutPoint}; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dpp::prelude::AssetLockProof; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; +use platform_wallet::changeset::{ + CoreChangeSet, PersistenceCapabilities, PlatformWalletPersistence, +}; +use platform_wallet::key_wallet_manager::test_utils::MockWallet; +use platform_wallet::test_support::core_fixtures::{ + block, outside, pay, records_of, spend_of, stored_utxo, wallet_with_receive_address, +}; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet_storage::sqlite::schema::blob; +use tokio::sync::RwLock; + +use crate::common::{ + authorize, checked_instant_lock, fresh_persister, registration, LoadPolicy, SqlitePersister, + SqlitePersisterConfig, +}; + +#[tokio::test] +async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_filter() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(122)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = OutPoint::new(funding.txid(), 0); + let locked = authorize( + &wallet, + &info, + &address, + spend_of(&[coin], vec![pay(99_000, &address)]), + ); + let competitor = spend_of(&[coin], vec![pay(98_000, &address)]); + let mut alternative = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); + records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); + let fixture = InstantLockFixture::new(Network::Testnet, &locked).unwrap(); + let (persister, _directory, path) = fresh_persister(); + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + instant_locks_for_non_final_records: BTreeMap::from([( + locked.txid(), + fixture.instant_lock().clone(), + )]), + ..Default::default() + }), + ), + ) + .unwrap(); + let mut unsafe_record = persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .unwrap(); + unsafe_record.input_details[0].value = u64::MAX; + persister + .lock_conn_for_test() + .execute( + "UPDATE core_transactions SET record_blob = ?3 WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + locked.txid().as_byte_array().as_slice(), + blob::encode(&unsafe_record).unwrap() + ], + ) + .unwrap(); + + assert!( + persister + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "missing proof must fail before SQL effects" + ); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + + let spv_directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(dashcore::Network::Testnet) + .without_filters() + .with_storage_path(spv_directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + assert!(client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .is_none()); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + let evidence = ReplayEvidence { + instant_locks: BTreeMap::from([(proof.txid(), proof)]), + }; + let ready = persister.load_with_replay(&evidence).unwrap(); + assert!(!ready.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + + let mut changed = fixture.instant_lock().clone(); + changed.signature = Default::default(); + persister + .lock_conn_for_test() + .execute( + "UPDATE core_instant_locks SET islock_blob = ?3 WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + locked.txid().as_byte_array().as_slice(), + blob::encode(&changed).unwrap() + ], + ) + .unwrap(); + assert!( + persister.load_with_replay(&evidence).is_err(), + "prefetched proof must be rebound to the authoritative snapshot" + ); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(path).with_load_policy(LoadPolicy::Recovery), + ) + .unwrap(); + assert!( + recovery.load_with_replay(&evidence).is_err(), + "Recovery must not hide a missing required proof" + ); + assert!(recovery + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); +} + +#[test] +fn should_not_attest_operation_status_after_replay_cleanup() { + let (persister, _tmp, _path) = fresh_persister(); + let capabilities = persister.persistence_capabilities(); + assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); + assert!( + !capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS), + "replay cleanup has no durable operation tombstone to distinguish absence from never sent" + ); +} + +#[tokio::test] +async fn should_verify_tracked_funding_before_atomic_history_settlement() { + for recorded in [true, false] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(125)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = OutPoint::new(funding.txid(), 0); + let locked = authorize( + &wallet, + &info, + &address, + spend_of(&[coin], vec![pay(99_000, &address)]), + ); + let competitor = spend_of(&[coin], vec![pay(98_000, &address)]); + let mut alternative = info.clone(); + let locked_records = + records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await; + if recorded { + records.extend(locked_records); + } + records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); + let (instant_lock, evidence) = checked_instant_lock(&locked).await; + let (persister, _directory, path) = fresh_persister(); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ); + let out_point = OutPoint::new(locked.txid(), 0); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: BTreeMap::from([( + out_point, + AssetLockEntry { + out_point, + transaction: locked.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + locked.clone(), + 0, + ))), + operation_id: None, + }, + )]), + ..Default::default() + }); + persister.store(wallet.wallet_id, changes).unwrap(); + assert!( + persister + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "a tracked funding proof must be required before any SQL settlement" + ); + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + let loaded = persister.load_with_replay(&evidence).unwrap(); + assert!(!loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened.load_with_replay(&evidence).unwrap(); + assert!(loaded.state.wallets[&wallet.wallet_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&out_point))); + assert!( + reopened + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + } +} + +#[tokio::test] +async fn should_atomically_withhold_final_asset_lock_input_at_nonzero_vout_and_reopen() { + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::TransactionPayload; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + for status in [AssetLockStatus::ChainLocked, AssetLockStatus::Consumed] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(197)], + vec![pay(20_000, &address), pay(100_000, &address)], + ); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let spent = OutPoint::new(funding.txid(), 1); + let available = OutPoint::new(funding.txid(), 0); + let mut transaction = spend_of( + &[spent], + vec![dashcore::TxOut { + value: 99_000, + script_pubkey: dashcore::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + ); + transaction.version = 3; + transaction.special_transaction_payload = Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![pay(99_000, &address)]), + )); + let transaction = authorize(&wallet, &info, &address, transaction); + let out_point = OutPoint::new(transaction.txid(), 0); + let (persister, _directory, path) = fresh_persister(); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![ + stored_utxo(&funding, 0, address.clone()), + stored_utxo(&funding, 1, address), + ], + ..Default::default() + }), + ); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: [( + out_point, + AssetLockEntry { + out_point, + transaction: transaction.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status, + proof: None, + operation_id: Some([7; 16]), + }, + )] + .into(), + ..Default::default() + }); + persister.store(wallet.wallet_id, changes).unwrap(); + let encoded = blob::encode_outpoint(&spent).unwrap(); + // Simulate the legacy host projection that left a finalized input unspent. + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent=0, spent_in_txid=NULL, winner_mined_height=NULL WHERE wallet_id=?1 AND outpoint=?2", + rusqlite::params![wallet.wallet_id.as_slice(), &encoded], + ).unwrap(); + persister.lock_conn_for_test().execute_batch( + "CREATE TEMP TRIGGER reject_final_hold BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent=1 BEGIN SELECT RAISE(ABORT, 'injected final hold failure'); END;" + ).unwrap(); + let error = persister + .load_with_replay(&ReplayEvidence::default()) + .unwrap_err(); + assert!(format!("{error:?}").contains("injected final hold failure")); + let read_claim = |store: &SqlitePersister| { + store.lock_conn_for_test().query_row( + "SELECT spent, spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id=?1 AND outpoint=?2", + rusqlite::params![wallet.wallet_id.as_slice(), &encoded], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>>(1)?, row.get::<_, Option>(2)?)), + ).unwrap() + }; + assert_eq!(read_claim(&persister), (0, None, None)); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER reject_final_hold") + .unwrap(); + let loaded = persister + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + let restored = &loaded.state.wallets[&wallet.wallet_id].wallet_info; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert_eq!(restored.balance.total(), 20_000); + assert!(account.utxos.contains_key(&available)); + assert!(!account + .spendable_utxos(200) + .iter() + .any(|coin| coin.outpoint == spent)); + assert_eq!( + read_claim(&persister), + (1, Some(transaction.txid().to_byte_array().to_vec()), None) + ); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!( + loaded.state.wallets[&wallet.wallet_id] + .wallet_info + .balance + .total(), + 20_000 + ); + assert!(loaded.state.wallets[&wallet.wallet_id] + .wallet_info + .utxos() + .iter() + .all(|coin| coin.outpoint != spent)); + assert!( + loaded.outcomes[&wallet.wallet_id] + .withheld_asset_lock_inputs + .is_empty(), + "durable hold makes explicit retry idempotent" + ); + assert_eq!( + read_claim(&reopened), + (1, Some(transaction.txid().to_byte_array().to_vec()), None) + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs index d2a16697f68..1cb9a50f3e1 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs @@ -95,7 +95,7 @@ fn sqlite_does_not_attest_wallet_drain_without_wallet_restore() { // Native asset-lock persistence remains available independently of the // unsupported all-wallet shielded recovery contract. assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); - assert!(capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); + assert!(!capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); } #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs new file mode 100644 index 00000000000..d8708ecf917 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs @@ -0,0 +1,2994 @@ +#![cfg(feature = "sqlite")] + +use crate::common; + +use std::collections::BTreeMap; + +use common::{authorize, authorize_with_mode, chain_lock, registration, Case}; +use dashcore::{Address, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use key_wallet::{ + managed_account::{ + managed_account_trait::ManagedAccountTrait, transaction_record::TransactionRecord, + }, + transaction_checking::TransactionContext, + wallet::{ + initialization::WalletAccountCreationOptions, + managed_wallet_info::{ + coin_selection::{CoinSelector, SelectionStrategy}, + fee::FeeRate, + wallet_info_interface::WalletInfoInterface, + }, + ManagedWalletInfo, Wallet, + }, + Utxo, +}; +use platform_wallet::changeset::{ + changeset::SweepBatch, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::test_support::core_fixtures::{ + block, check, foreign_address, outside, pay, records_of, spend_of, stored_utxo, + wallet_with_receive_address, +}; +use platform_wallet_storage::sqlite::schema::blob; +use platform_wallet_storage::{ + LoadPolicy, LoadSite, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +const MEMPOOL: TransactionContext = TransactionContext::Mempool; + +/// One past half of `i64::MAX`: two owned outputs of it cannot be summed as `i64`. +const UNREPRESENTABLE_HALF: u64 = (1 << 62) + 1; + +/// Strict load must reject a corrupted body before it can delete unrelated history. +#[tokio::test] +async fn should_reject_corrupt_record_body_before_replay_settlement() { + assert_corrupt_record_body_is_rejected(LoadPolicy::Strict).await; +} + +#[tokio::test] +async fn should_skip_corrupt_record_wallet_in_recovery_without_mutating_history() { + assert_corrupt_record_body_is_rejected(LoadPolicy::Recovery).await; +} + +async fn assert_corrupt_record_body_is_rejected(policy: LoadPolicy) { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(93)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let settled = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ); + let pending = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 1)], + vec![pay(19_000, &address)], + ), + ); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + records.extend(records_of(&mut info, &mut wallet, &settled, block(101)).await); + records.extend(records_of(&mut info, &mut wallet, &pending, MEMPOOL).await); + let coins: Vec<_> = info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.values().cloned()) + .collect(); + let (persister, _dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: coins, + ..Default::default() + }, + ); + { + let conn = persister.lock_conn_for_test(); + let payload: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + settled.txid().as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + let mut corrupted: TransactionRecord = blob::decode(&payload).unwrap(); + corrupted.transaction.input[0].previous_output.vout = 1; + assert_ne!(corrupted.transaction.txid(), corrupted.txid); + conn.execute( + "UPDATE core_transactions SET record_blob = ?3 WHERE wallet_id = ?1 AND txid = ?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + settled.txid().as_byte_array().as_slice(), + blob::encode(&corrupted).unwrap() + ], + ) + .unwrap(); + } + + let path = persister.config_for_test().path.clone(); + drop(persister); + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(path).with_load_policy(policy)).unwrap(); + let changes_before = persister.lock_conn_for_test().total_changes(); + assert!(matches!( + typed_error( + persister + .get_core_tx_record(wallet.wallet_id, &settled.txid()) + .unwrap_err() + ), + WalletStorageError::BlobDecode { .. } + )); + let result = persister.load(); + let pending_retained: bool = persister + .lock_conn_for_test() + .query_row( + "SELECT EXISTS(SELECT 1 FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2)", + rusqlite::params![ + wallet.wallet_id.as_slice(), + pending.txid().as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert!( + pending_retained, + "corrupt history must not sweep an unrelated pending record" + ); + assert_eq!( + persister.lock_conn_for_test().total_changes(), + changes_before + ); + match policy { + LoadPolicy::Strict => { + let error = result.expect_err("strict load must reject the corrupted body"); + assert!(matches!( + typed_error(error), + WalletStorageError::BlobDecode { .. } + )); + } + LoadPolicy::Recovery => { + assert!(!result.unwrap().wallets.contains_key(&wallet.wallet_id)); + assert_eq!( + persister + .last_load_degradation() + .by_site + .get(&LoadSite::WalletRehydration), + Some(&1) + ); + } + } +} + +/// An output burning `value` to an empty script. +fn burn(value: u64) -> TxOut { + TxOut { + value, + script_pubkey: ScriptBuf::new(), + } +} + +/// A later round carrying only `core`. +fn core_round(core: CoreChangeSet) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } +} + +/// The sweep a sync persists when `winner`, mined at 101, removed `txids`. +fn sweep(txids: Vec, winner: &Transaction, released: Vec) -> SweepBatch { + SweepBatch { + txids, + superseded_by: winner.txid(), + winner_mined_height: Some(101), + released_outpoints: released, + } +} + +fn load_wallet(persister: &SqlitePersister, wallet_id: &[u8; 32]) -> (Wallet, ManagedWalletInfo) { + let restored = persister.load().unwrap().wallets.remove(wallet_id).unwrap(); + (restored.wallet, restored.wallet_info) +} + +/// `(wallet, outpoint, status)` of every `asset_locks` row, sorted. +fn asset_lock_rows(persister: &SqlitePersister) -> Vec<([u8; 32], OutPoint, String)> { + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare("SELECT wallet_id, outpoint, status FROM asset_locks") + .unwrap(); + let mut rows: Vec<_> = stmt + .query_map([], |row| { + let wallet: Vec = row.get(0)?; + let outpoint: Vec = row.get(1)?; + Ok((wallet, outpoint, row.get::<_, String>(2)?)) + }) + .unwrap() + .map(|row| { + let (wallet, outpoint, status) = row.unwrap(); + ( + wallet.try_into().unwrap(), + platform_wallet_storage::sqlite::schema::blob::decode_outpoint(&outpoint).unwrap(), + status, + ) + }) + .collect(); + rows.sort(); + rows +} + +fn count(persister: &SqlitePersister, sql: &str) -> i64 { + let conn = persister.lock_conn_for_test(); + conn.query_row(sql, [], |row| row.get(0)).unwrap() +} + +/// Pool rows as a live sync persists them, so load routes each coin to its owner. +fn insert_pool_rows(persister: &SqlitePersister, wallet_id: &[u8; 32], rows: &[(&str, &Address)]) { + let conn = persister.lock_conn_for_test(); + for (account_type, address) in rows { + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, pool_type, address_index, script) \ + VALUES (?1, ?2, 0, 0, 0, ?3)", + rusqlite::params![ + &wallet_id[..], + account_type, + address.script_pubkey().as_bytes() + ], + ) + .unwrap(); + } +} + +/// Store `records` and `coins` for `wallet`, checkpointed at 300, then drop +/// `unpooled`'s pool row and track `contact` only on a contact's watch-only chain. +fn store_with_contact_chain( + wallet: &Wallet, + records: Vec, + coins: Vec, + (unpooled, contact): (&Address, &Address), +) -> (SqlitePersister, tempfile::TempDir) { + let (persister, dir, _) = common::fresh_persister(); + let core = synced(CoreChangeSet { + records, + new_utxos: coins, + ..Default::default() + }); + persister + .store(wallet.wallet_id, registration(wallet, Some(core))) + .unwrap(); + let script = unpooled.script_pubkey(); + let sql = "DELETE FROM core_address_pool WHERE script = ?1"; + persister + .lock_conn_for_test() + .execute(sql, [script.as_bytes()]) + .unwrap(); + insert_pool_rows( + &persister, + &wallet.wallet_id, + &[("dashpay_external", contact)], + ); + (persister, dir) +} + +/// `core`, closed by a sync checkpoint at 300. +fn synced(core: CoreChangeSet) -> CoreChangeSet { + CoreChangeSet { + last_processed_height: Some(300), + synced_height: Some(300), + ..core + } +} + +/// Persist one Core round for `wallet` as a sync would, then reopen the store strictly. +fn store_then_reopen(wallet: &Wallet, core: CoreChangeSet) -> (SqlitePersister, tempfile::TempDir) { + let (persister, dir, path) = common::fresh_persister(); + persister + .store(wallet.wallet_id, registration(wallet, Some(core))) + .expect("the store accepts what the live checker recorded"); + drop(persister); + let config = SqlitePersisterConfig::new(path).with_load_policy(LoadPolicy::Strict); + (SqlitePersister::open(config).unwrap(), dir) +} + +/// A fresh wallet with a receive address on its first BIP44 and first BIP32 account. +fn two_account_wallet() -> (Wallet, ManagedWalletInfo, Address, Address) { + let (wallet, mut info, first) = wallet_with_receive_address(); + let xpub = wallet.accounts.standard_bip32_accounts[&0].account_xpub; + let account = info.accounts.standard_bip32_accounts.get_mut(&0).unwrap(); + let second = account.next_receive_address(Some(&xpub), true).unwrap(); + (wallet, info, first, second) +} + +fn coin_set(info: &ManagedWalletInfo) -> Vec { + info.accounts.standard_bip44_accounts[&0] + .utxos + .keys() + .copied() + .collect() +} + +/// Every funding account still holding a coin or a record of `transactions`. +fn traces(info: &ManagedWalletInfo, transactions: &[&Transaction]) -> Vec { + let txids: Vec = transactions.iter().map(|tx| tx.txid()).collect(); + info.accounts + .all_funding_accounts() + .into_iter() + .filter_map(|account| { + let coins = account + .utxos + .keys() + .filter(|outpoint| txids.contains(&outpoint.txid)) + .count(); + let records = txids + .iter() + .filter(|txid| account.transactions().contains_key(*txid)) + .count(); + let account_type = account.managed_account_type().to_account_type(); + (coins + records > 0) + .then(|| format!("{account_type:?}: {coins} coins, {records} records")) + }) + .collect() +} + +fn typed_error(error: platform_wallet::changeset::PersistenceError) -> WalletStorageError { + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("expected backend error") + }; + *source.downcast::().unwrap() +} + +struct Fixture { + signer: Wallet, + signer_info: ManagedWalletInfo, + persister: SqlitePersister, + _dir: tempfile::TempDir, + wallet_id: [u8; 32], + funding: Transaction, + spent: OutPoint, + available: OutPoint, +} + +impl Fixture { + async fn new(spend_context: TransactionContext) -> Self { + Self::build(block(100), spend_context, true).await + } + + /// Funding pays 100_000 (`spent`, which the stored spend takes) and + /// 20_000 (`available`). Without `store_funding_record` the funding is + /// persisted only through its UTXOs: a height-only row with no record to replay. + async fn build( + funding_context: TransactionContext, + spend_context: TransactionContext, + store_funding_record: bool, + ) -> Self { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(13)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let [spent, available] = [0, 1].map(|vout| OutPoint::new(funding.txid(), vout)); + let spending = authorize( + &wallet, + &info, + &address, + spend_of(&[spent], vec![burn(99_000)]), + ); + let mut records = records_of(&mut info, &mut wallet, &funding, funding_context).await; + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + let (new_utxos, spent_coin) = (coins.values().cloned().collect(), coins[&spent].clone()); + records.extend(records_of(&mut info, &mut wallet, &spending, spend_context).await); + assert_eq!(records.len(), 2); + if !store_funding_record { + records.retain(|record| record.txid != funding.txid()); + } + let (persister, dir, path) = common::fresh_persister(); + let core = synced(CoreChangeSet { + records, + new_utxos, + spent_utxos: vec![spent_coin], + ..Default::default() + }); + persister + .store(wallet.wallet_id, registration(&wallet, Some(core))) + .unwrap(); + drop(persister); + let persister = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + if !store_funding_record { + let (height, has_record): (Option, bool) = persister + .lock_conn_for_test() + .query_row( + "SELECT height, record_blob IS NOT NULL FROM core_transactions WHERE txid = ?1", + [funding.txid().as_byte_array().as_slice()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((height, has_record), (Some(100), false)); + } + Self { + signer: wallet.clone(), + signer_info: info.clone(), + persister, + _dir: dir, + wallet_id: wallet.wallet_id, + funding, + spent, + available, + } + } + + fn load(&self) -> (Wallet, ManagedWalletInfo) { + load_wallet(&self.persister, &self.wallet_id) + } + + /// The txid of the one stored row that carries a record. + fn recorded_txid(&self) -> Txid { + let stored: Vec = self + .persister + .lock_conn_for_test() + .query_row( + "SELECT txid FROM core_transactions WHERE record_blob IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + Txid::from_slice(&stored).unwrap() + } + + fn assert_spent_excluded(&self, info: &ManagedWalletInfo) { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&self.spent)); + assert!(coins.contains_key(&self.available)); + assert_eq!(info.balance.total(), 20_000); + let selector = CoinSelector::new(SelectionStrategy::LargestFirst); + assert!(selector + .select_coins(coins.values(), 50_000, FeeRate::default(), 300) + .is_err()); + let selection = selector + .select_coins(coins.values(), 5_000, FeeRate::default(), 300) + .unwrap(); + assert_eq!(selection.selected.len(), 1); + assert_eq!(selection.selected[0].outpoint, self.available); + } + + fn assert_spent_stored(&self, expected: bool) { + let spent: bool = self + .persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE substr(outpoint, 2, 32) = ?1 AND value = 100000", + [self.spent.txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(spent, expected, "stored spent flag of the reserved input"); + } + + async fn redeliver(&self, wallet: &mut Wallet, info: &mut ManagedWalletInfo) { + let records = records_of(info, wallet, &self.funding, block(100)).await; + self.assert_spent_excluded(info); + let new_utxos = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + let core = CoreChangeSet { + records, + new_utxos, + ..Default::default() + }; + self.persister + .store(self.wallet_id, core_round(core)) + .unwrap(); + let (_, reloaded) = self.load(); + self.assert_spent_excluded(&reloaded); + } +} + +/// One stored spend of a funded coin, restored and then confronted with its +/// funding transaction again. +struct SpentCase { + label: &'static str, + funding: TransactionContext, + spend: TransactionContext, + height_only_funding: bool, + /// The store's projection still parks the spent output as unspent. + stale_projection: bool, + /// A chain lock at 300 is stored before the restart. + persisted_chain_lock: bool, + /// A chain lock at 300 and a sync checkpoint prune observed spends after it. + pruned: bool, +} + +/// The spent coin stays out of the spendable set after the restart and through +/// funding redelivery; an unconfirmed spend keeps it reserved, and stored spent. +#[tokio::test] +async fn should_keep_spent_output_excluded_after_restart_and_funding_redelivery() { + let case = |label, spend| SpentCase { + label, + funding: block(100), + spend, + height_only_funding: false, + stale_projection: false, + persisted_chain_lock: false, + pruned: false, + }; + let cases = [ + case("spent output after reload and redelivery", block(200)), + SpentCase { + pruned: true, + ..case("finality pruning", block(200)) + }, + SpentCase { + pruned: true, + height_only_funding: true, + ..case("finality pruning with height-only funding", block(200)) + }, + SpentCase { + height_only_funding: true, + ..case("unconfirmed spend with height-only funding", MEMPOOL) + }, + SpentCase { + stale_projection: true, + ..case( + "stale unspent projection against confirmed history", + block(200), + ) + }, + case("inputs reserved by an unconfirmed spend", MEMPOOL), + SpentCase { + funding: MEMPOOL, + ..case("unconfirmed funding confirming after reload", MEMPOOL) + }, + case("funding and spend in the same block", block(100)), + SpentCase { + persisted_chain_lock: true, + ..case( + "persisted finality before the sync checkpoint advances", + block(100), + ) + }, + ]; + for case in cases { + let _case = Case(case.label.into()); + let fixture = Fixture::build( + case.funding.clone(), + case.spend.clone(), + !case.height_only_funding, + ) + .await; + if case.stale_projection { + let conn = fixture.persister.lock_conn_for_test(); + conn.execute("UPDATE core_utxos SET spent = 0", []).unwrap(); + } + if case.persisted_chain_lock { + let core = CoreChangeSet { + last_applied_chain_lock: Some(chain_lock(300, 30)), + ..Default::default() + }; + let round = core_round(core); + fixture.persister.store(fixture.wallet_id, round).unwrap(); + } + let (mut wallet, mut info) = fixture.load(); + let unconfirmed_spend = case.spend == MEMPOOL; + if case.funding == MEMPOOL { + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&fixture.spent)); + } else { + fixture.assert_spent_excluded(&info); + } + if unconfirmed_spend { + assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); + } + if case.persisted_chain_lock { + let account = &info.accounts.standard_bip44_accounts[&0]; + assert!( + !account + .keys() + .transaction_is_finalized(&fixture.funding.txid()), + "a higher checkpoint alone cannot prove branch membership" + ); + info.update_synced_height(300); + } + if case.pruned { + info.apply_chain_lock(chain_lock(300, 30)); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + } + fixture.redeliver(&mut wallet, &mut info).await; + if unconfirmed_spend { + fixture.assert_spent_stored(true); + } + } +} + +#[test] +fn should_restore_without_an_async_runtime() { + let fixture = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(Fixture::new(block(200))); + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_restore_on_managers_blocking_pool() { + let fixture = Fixture::new(block(200)).await; + tokio::task::spawn_blocking(move || { + let (_, info) = fixture.load(); + fixture.assert_spent_excluded(&info); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn should_keep_replayed_output_only_in_its_owning_account() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip32_accounts[&0].account_xpub; + let account = info.accounts.standard_bip32_accounts.get_mut(&0).unwrap(); + let address = account.next_receive_address(Some(&xpub), true).unwrap(); + // vout 1: no pool row and no replay owner, so the first-account fallback + // must hold. vout 2: tracked only by a contact's watch-only chain. + let (unowned, contact) = (foreign_address(0x61), foreign_address(0x62)); + let outputs = vec![ + pay(70_000, &address), + pay(5_000, &unowned), + pay(9_000, &contact), + ]; + let funding = spend_of(&[outside(14)], outputs); + let [coin, fallback, contact_coin] = [0, 1, 2].map(|vout| OutPoint::new(funding.txid(), vout)); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let account = &info.accounts.standard_bip32_accounts[&0]; + let mut coins: Vec<_> = account.utxos.values().cloned().collect(); + assert_eq!(coins.len(), 1); + coins.push(stored_utxo(&funding, 1, unowned)); + coins.push(stored_utxo(&funding, 2, contact.clone())); + // Without its pool row the loader cannot attribute the coin and parks + // it in the first funds account; replay then finds the real owner. + let (persister, _dir) = store_with_contact_chain(&wallet, records, coins, (&address, &contact)); + + let (_, info) = load_wallet(&persister, &wallet.wallet_id); + let holders = |outpoint: &OutPoint| { + info.accounts + .all_funding_accounts() + .into_iter() + .filter(|account| account.utxos.contains_key(outpoint)) + .count() + }; + assert_eq!( + holders(&coin), + 1, + "one outpoint must live in exactly one account" + ); + assert!(info.accounts.standard_bip32_accounts[&0] + .utxos + .contains_key(&coin)); + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fallback), + "an output replay cannot attribute keeps the first-account fallback" + ); + assert_eq!(holders(&contact_coin), 0, "a contact's coin is not ours"); + assert_eq!(info.balance.total(), 75_000); + assert_eq!(info.get_spendable_utxos().len(), 2); + assert_eq!( + count( + &persister, + "SELECT count(*) FROM core_utxos WHERE spent = 0" + ), + 3, + "load must not delete stored rows" + ); +} + +#[tokio::test] +async fn should_drop_replay_credit_for_contact_only_script() { + let mut wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let account = info.accounts.standard_bip44_accounts.get_mut(&0).unwrap(); + let [contact, ours]: [Address; 2] = account + .next_receive_addresses(Some(&xpub), 2, true) + .unwrap() + .try_into() + .unwrap(); + let funding = spend_of( + &[outside(16)], + vec![pay(40_000, &contact), pay(7_000, &ours)], + ); + let [contact_coin, our_coin] = [0, 1].map(|vout| OutPoint::new(funding.txid(), vout)); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let account = &info.accounts.standard_bip44_accounts[&0]; + let coins: Vec<_> = account.utxos.values().cloned().collect(); + assert_eq!(coins.len(), 2); + // The store tracks the script only on a contact's watch-only chain, + // yet the rebuilt funds account still derives it, so replay credits it. + let (persister, _dir) = store_with_contact_chain(&wallet, records, coins, (&contact, &contact)); + + let (_, info) = load_wallet(&persister, &wallet.wallet_id); + assert!( + info.accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&contact_coin)), + "a replay-credited contact coin must not survive load" + ); + assert!(info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&our_coin)); + assert_eq!(info.balance.total(), 7_000); +} + +/// A loser spending a shared and an extra coin, a competing winner spending +/// only the shared one, stored unsettled and repaired at load. +#[derive(Clone, Copy, Debug)] +enum ConflictCase { + /// A persisted conflict releases the extra input. + Released, + /// The same, with the funding persisted as a height-only row. + HeightOnlyFunding, + /// A surviving spend keeps the extra input reserved. + SurvivingClaim, + /// A persisted chainlock resolves an InstantSend-locked competitor. + PersistedChainLock, + /// A recovery load does not rewrite the conflicting history. + Recovery, + /// A recordless winner's claim on the extra input survives replay. + RecordlessClaim, + /// So does a recordless winner's sweep placeholder. + RecordlessPlaceholder, + /// An unknown durable claim survives startup conflict repair. + UnknownClaim { + placeholder: bool, + height: Option, + }, + /// A malformed claimant fails the load. + MalformedClaim, + /// A failed replay repair rolls back. + FailedRepair, + /// Swept asset locks are removed on restart. + Assets, + /// Recovery rolls the swept asset locks back. + AssetsRecovery, + /// A SQL failure rolls the swept asset locks back. + AssetsFailedRepair, + /// A replay that cannot converge is reported for its wallet. + Unconverged, + /// A defeated lock cannot remove a surviving spender. + DefeatedLock, +} + +#[tokio::test] +async fn should_settle_a_persisted_conflict_on_restart() { + use ConflictCase::*; + let unknown = |placeholder, height| UnknownClaim { + placeholder, + height, + }; + for case in [ + Released, + HeightOnlyFunding, + SurvivingClaim, + PersistedChainLock, + Recovery, + RecordlessClaim, + RecordlessPlaceholder, + unknown(false, None), + unknown(false, Some(102)), + unknown(true, None), + unknown(true, Some(102)), + MalformedClaim, + FailedRepair, + DefeatedLock, + Assets, + AssetsRecovery, + AssetsFailedRepair, + Unconverged, + ] { + let _case = Case(format!("{case:?}")); + assert_conflict_restart(case).await; + } +} + +async fn assert_conflict_restart(case: ConflictCase) { + use ConflictCase::*; + + let record_funding = !matches!(case, HeightOnlyFunding); + let surviving_claim = matches!(case, SurvivingClaim | DefeatedLock); + let chainlocked = matches!(case, PersistedChainLock | DefeatedLock); + let recordless_claim = matches!(case, RecordlessClaim | RecordlessPlaceholder); + let unknown_claim = matches!(case, UnknownClaim { .. }); + let expect_released = !surviving_claim && !recordless_claim && !unknown_claim; + let recovery = matches!(case, Recovery | AssetsRecovery); + let assets = matches!(case, Assets | AssetsRecovery | AssetsFailedRepair); + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(71)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let coins: Vec<_> = (0..2) + .map(|vout| { + let outpoint = OutPoint::new(funding.txid(), vout); + let output = funding.output[vout as usize].clone(); + Utxo::new(outpoint, output, address.clone(), 100, false) + }) + .collect(); + let (shared, extra) = (coins[0].outpoint, coins[1].outpoint); + let loser = authorize( + &wallet, + &info, + &address, + spend_of(&[shared, extra], vec![pay(119_000, &address)]), + ); + let winner = spend_of(&[shared], vec![burn(99_000)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + if !record_funding { + records.clear(); + } + let (mut competing, mut other_spender) = (info.clone(), info.clone()); + records.extend(records_of(&mut info, &mut wallet, &loser, MEMPOOL).await); + records.extend(records_of(&mut competing, &mut wallet, &winner, MEMPOOL).await); + let mut surviving_txid = None; + if surviving_claim { + let extra_spender = authorize( + &wallet, + &info, + &address, + spend_of(&[extra], vec![burn(19_000)]), + ); + surviving_txid = Some(extra_spender.txid()); + records.extend(records_of(&mut other_spender, &mut wallet, &extra_spender, MEMPOOL).await); + } + let locked = if chainlocked { &loser } else { &winner }; + let (lock, evidence) = common::checked_instant_lock(locked).await; + if chainlocked { + records + .iter_mut() + .find(|r| r.txid == winner.txid()) + .unwrap() + .context = block(101); + } + let locks = [(locked.txid(), lock)].into(); + let (persister, _dir, path) = common::fresh_persister(); + let loser_coin = Utxo::new( + OutPoint::new(loser.txid(), 0), + loser.output[0].clone(), + address, + 0, + false, + ); + let core = CoreChangeSet { + records, + new_utxos: coins.iter().cloned().chain([loser_coin]).collect(), + spent_utxos: coins.clone(), + instant_locks_for_non_final_records: locks, + last_applied_chain_lock: chainlocked.then(|| chain_lock(101, 101)), + ..Default::default() + }; + persister + .store(wallet.wallet_id, registration(&wallet, Some(core))) + .unwrap(); + let wallet_key = wallet.wallet_id.as_slice(); + let extra_key: Vec = persister + .lock_conn_for_test() + .query_row( + "SELECT outpoint FROM core_utxos WHERE wallet_id = ?1 AND value = 20000", + [wallet_key], + |row| row.get(0), + ) + .unwrap(); + // `(spent, spent_in_txid, winner_mined_height, is_sweep_placeholder)` of the extra coin. + let extra_row = |persister: &SqlitePersister| -> (bool, Option>, Option, bool) { + persister + .lock_conn_for_test() + .query_row( + "SELECT spent, spent_in_txid, winner_mined_height, is_sweep_placeholder \ + FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![wallet_key, &extra_key], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("the extra coin's row survives repair and reopen") + }; + let exec = |sql: &str, params: &[&dyn rusqlite::ToSql]| { + persister.lock_conn_for_test().execute(sql, params).unwrap(); + }; + let loser_kept = || { + let record = persister.get_core_tx_record(wallet.wallet_id, &loser.txid()); + record.unwrap().is_some() + }; + // The store itself names the loser as claimant; only an unknown durable claim stays held. + if let UnknownClaim { height, .. } = case { + exec( + "UPDATE core_utxos SET spent_in_txid = NULL, winner_mined_height = ?1 \ + WHERE wallet_id = ?2 AND outpoint = ?3", + &[&height, &wallet_key, &extra_key], + ); + } + if matches!(case, MalformedClaim) { + exec( + "UPDATE core_utxos SET spent_in_txid = zeroblob(31) WHERE wallet_id = ?1 AND outpoint = ?2", + &[&wallet_key, &extra_key], + ); + assert!(matches!( + typed_error( + persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap_err() + ), + WalletStorageError::BlobDecode { .. } + )); + assert!(loser_kept()); + return; + } + let missing_winner = Txid::from_byte_array([72; 32]); + if recordless_claim { + let missing = missing_winner.as_byte_array().as_slice(); + exec( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized) VALUES (?1, ?2, 101, 0)", + &[&wallet_key, &missing], + ); + exec( + "UPDATE core_utxos SET spent_in_txid = ?1 WHERE wallet_id = ?2 AND value = 20000", + &[&missing, &wallet_key], + ); + } + if matches!( + case, + RecordlessPlaceholder + | UnknownClaim { + placeholder: true, + .. + } + ) { + exec( + "UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 \ + WHERE wallet_id = ?1 AND outpoint = ?2", + &[&wallet_key, &extra_key], + ); + } + let mut other_wallet_id = None; + // The asset-lock rows a load leaves: the loser's non-consumed lifecycle row + // goes with the Core repair, in the same transaction, so whatever that row + // carries goes with it. Its consumed history and the other wallet's row of + // the same outpoint stay. + let expected_assets = |swept_removed: bool, other: [u8; 32]| { + let row = |wallet, vout, status: &str| { + (wallet, OutPoint::new(loser.txid(), vout), status.to_owned()) + }; + let mut rows = vec![ + row(wallet.wallet_id, 1, "consumed"), + row(other, 0, "broadcast"), + ]; + if !swept_removed { + rows.push(row(wallet.wallet_id, 0, "broadcast")); + } + rows.sort(); + rows + }; + if assets { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let entry = |vout, status| { + let out_point = OutPoint::new(loser.txid(), vout); + let entry = AssetLockEntry { + out_point, + transaction: loser.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status, + proof: None, + operation_id: None, + }; + (out_point, entry) + }; + let entries: BTreeMap<_, _> = [ + entry(0, AssetLockStatus::Broadcast), + entry(1, AssetLockStatus::Consumed), + ] + .into(); + let locks = |asset_locks| { + Some(AssetLockChangeSet { + asset_locks, + ..Default::default() + }) + }; + let other = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + other_wallet_id = Some(other.wallet_id); + let other_round = PlatformWalletChangeSet { + asset_locks: locks([entry(0, AssetLockStatus::Broadcast)].into()), + ..registration(&other, None) + }; + persister.store(other.wallet_id, other_round).unwrap(); + let round = PlatformWalletChangeSet { + asset_locks: locks(entries), + ..Default::default() + }; + persister.store(wallet.wallet_id, round).unwrap(); + } + if matches!(case, FailedRepair | AssetsFailedRepair) { + persister.lock_conn_for_test().execute_batch( + "CREATE TRIGGER fail_replay_repair BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'injected replay repair failure'); END;", + ).unwrap(); + assert!(persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .is_err()); + if assets { + assert_eq!( + asset_lock_rows(&persister), + expected_assets(false, other_wallet_id.unwrap()), + "failed reconciliation must roll back asset lifecycle deletions" + ); + } + assert!(loser_kept()); + assert!(extra_row(&persister).0); + exec("DROP TRIGGER fail_replay_repair", &[]); + } + if matches!(case, Unconverged) { + persister.lock_conn_for_test().execute_batch("CREATE TRIGGER preserve_history BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(IGNORE); END;").unwrap(); + let error = persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap_err(); + assert!(error + .to_string() + .contains("still conflicts after atomic replay")); + assert!(loser_kept()); + return; + } + drop(persister); + let policy = if recovery { + LoadPolicy::Recovery + } else { + LoadPolicy::Strict + }; + for _ in 0..2 { + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) + .unwrap(); + let mut state = persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap(); + if assets { + assert!( + state.wallets[&wallet.wallet_id] + .unused_asset_locks + .is_empty(), + "swept asset locks must not be resumable" + ); + assert_eq!( + asset_lock_rows(&persister), + expected_assets(!recovery, other_wallet_id.unwrap()), + "only the loser's non-consumed row goes; Recovery rolls the removal back" + ); + } + if let Some(other_id) = other_wallet_id { + assert!( + state.wallets[&other_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&OutPoint::new(loser.txid(), 0))), + "the same asset lock in another wallet must survive" + ); + } + let info = &state.wallets[&wallet.wallet_id].wallet_info; + let account = &info.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser.txid())); + if let Some(txid) = surviving_txid { + assert!(account.transactions().contains_key(&txid)); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &txid) + .unwrap() + .is_some()); + } + assert!( + !account.utxos.contains_key(&shared), + "winner still spends the shared input" + ); + assert_eq!( + account.utxos.contains_key(&extra), + expect_released, + "the extra input is free only when no surviving transaction claims it" + ); + assert_eq!( + info.balance.total(), + if expect_released { 20_000 } else { 0 } + ); + assert_eq!( + persister + .get_core_tx_record(wallet.wallet_id, &loser.txid()) + .unwrap() + .is_some(), + recovery + ); + if recordless_claim { + let claim = extra_row(&persister).1; + assert_eq!( + claim.as_deref(), + Some(missing_winner.as_byte_array().as_slice()) + ); + } + let selection = CoinSelector::new(SelectionStrategy::LargestFirst).select_coins( + account.utxos.values(), + 5_000, + FeeRate::default(), + 100, + ); + if !expect_released { + assert!(selection.is_err()); + } else { + assert_eq!(selection.unwrap().selected[0].outpoint, extra); + } + if recordless_claim || unknown_claim { + let loaded = state.wallets.get_mut(&wallet.wallet_id).unwrap(); + let (info, wallet) = (&mut loaded.wallet_info, &mut loaded.wallet); + check(info, wallet, &funding, block(100)).await; + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&extra), + "recordless claim must survive funding redelivery" + ); + assert_eq!(info.balance.total(), 0); + } + if let UnknownClaim { + placeholder, + height, + } = case + { + assert_eq!( + extra_row(&persister), + (true, None, height, placeholder), + "repair must retain nullable provenance through reload and funding redelivery" + ); + } + } +} + +#[tokio::test] +async fn should_guard_recordless_spends_with_missing_funding_and_finality() { + let flags = [false, true]; + let cases = ["absent", "height_only", "record"] + .into_iter() + .flat_map(|funding| { + flags.into_iter().flat_map(move |spender_row| { + flags.into_iter().flat_map(move |placeholder| { + flags.map(move |known_claimant| { + (funding, spender_row, placeholder, known_claimant) + }) + }) + }) + }); + for (funding_state, spender_row, placeholder, known_claimant) in cases { + let _case = Case(format!( + "funding={funding_state} spender_row={spender_row} \ + placeholder={placeholder} known_claimant={known_claimant}" + )); + let fixture = Fixture::build(block(100), block(200), funding_state == "record").await; + { + let conn = fixture.persister.lock_conn_for_test(); + let funding = fixture.funding.txid(); + let funding = [funding.as_byte_array().as_slice()]; + let spender = if spender_row { + "UPDATE core_transactions SET record_blob = NULL WHERE txid != ?1" + } else { + "DELETE FROM core_transactions WHERE txid != ?1" + }; + conn.execute(spender, funding).unwrap(); + if funding_state == "absent" { + let sql = "DELETE FROM core_transactions WHERE txid = ?1"; + conn.execute(sql, funding).unwrap(); + } + if !known_claimant { + let sql = "UPDATE core_utxos SET spent_in_txid = NULL WHERE spent = 1"; + conn.execute(sql, []).unwrap(); + } + if placeholder { + conn.execute("UPDATE core_utxos SET value = 0, script = X'', is_sweep_placeholder = 1 WHERE spent = 1", []).unwrap(); + } + } + let (mut wallet, mut info) = fixture.load(); + info.apply_chain_lock(chain_lock(300, 30)); + check(&mut info, &mut wallet, &fixture.funding, block(100)).await; + fixture.assert_spent_excluded(&info); + // A later loser must not release a different durable claimant's input. + let inputs = [fixture.spent, fixture.available]; + let mut loser = spend_of(&inputs, vec![fixture.funding.output[0].clone()]); + loser.output[0].value = 119_000; + check(&mut info, &mut wallet, &loser, MEMPOOL).await; + let winner = spend_of(&[fixture.available], vec![burn(19_000)]); + check(&mut info, &mut wallet, &winner, block(301)).await; + check(&mut info, &mut wallet, &fixture.funding, block(100)).await; + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&fixture.spent), + "a later conflict cannot release a recordless durable claim" + ); + assert_eq!(info.balance.total(), 0); + } +} + +/// A claim restored at load holds for a funding account registered afterwards: +/// the account that receives the spent coin is unknown to the store, is added +/// once the wallet is loaded, and then sees the funding transaction again. +#[tokio::test] +async fn should_guard_a_restored_claim_in_an_account_registered_after_load() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::ManagedCoreFundsAccount; + + let late_type = AccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP44Account, + }; + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let late_account = { + let mut with_late = wallet.clone(); + with_late.add_account(late_type, None).unwrap(); + with_late.accounts.standard_bip44_accounts[&7].clone() + }; + let late_managed = || { + let mut managed = ManagedCoreFundsAccount::from_account(&late_account); + let address = managed + .next_receive_address(Some(&late_account.account_xpub), true) + .unwrap(); + (managed, address) + }; + let (_, address) = late_managed(); + let funding = spend_of( + &[outside(17)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let [claimed, unclaimed] = [0, 1].map(|vout| OutPoint::new(funding.txid(), vout)); + + // A recordless claim: the spent coin, with neither its funding nor its spender. + let (persister, _dir) = store_then_reopen( + &wallet, + synced(CoreChangeSet { + spent_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ); + assert_eq!( + count( + &persister, + "SELECT COUNT(*) FROM core_utxos WHERE spent = 1" + ), + 1, + "the store holds the recordless claim" + ); + + let (mut wallet, mut info) = load_wallet(&persister, &wallet.wallet_id); + assert!(!info.accounts.standard_bip44_accounts.contains_key(&7)); + + let (managed, _) = late_managed(); + wallet + .add_account(late_type, Some(late_account.account_xpub)) + .unwrap(); + info.accounts.insert_funds_bearing_account(managed).unwrap(); + + check(&mut info, &mut wallet, &funding, block(100)).await; + + let coins = &info.accounts.standard_bip44_accounts[&7].utxos; + assert!( + coins.contains_key(&unclaimed), + "the late account receives the funding transaction" + ); + assert!( + !coins.contains_key(&claimed), + "a claim restored before the account existed still guards it" + ); + assert_eq!(info.balance.total(), 20_000); +} + +/// Replace the stored history with one `context` record spending `values` and receiving `received`. +async fn stage_replay_amounts( + context: TransactionContext, + values: &[u64], + received: u64, +) -> Fixture { + use key_wallet::managed_account::transaction_record::{InputDetail, OutputDetail, OutputRole}; + use platform_wallet_storage::sqlite::schema::blob; + let fixture = Fixture::build(block(100), context, false).await; + let conn = fixture.persister.lock_conn_for_test(); + let sql = "SELECT record_blob FROM core_transactions WHERE record_blob IS NOT NULL"; + let bytes: Vec = conn.query_row(sql, [], |row| row.get(0)).unwrap(); + let mut record: TransactionRecord = blob::decode(&bytes).unwrap(); + let address = record.input_details[0].address.clone(); + let parent = Txid::from_byte_array([90; 32]); + let parents: Vec<_> = (0..values.len() as u32) + .map(|vout| OutPoint::new(parent, vout)) + .collect(); + record.transaction = authorize( + &fixture.signer, + &fixture.signer_info, + &address, + spend_of(&parents, vec![pay(received, &address)]), + ); + record.input_details = (0..) + .zip(values) + .map(|(index, value)| InputDetail { + index, + value: *value, + address: address.clone(), + }) + .collect(); + record.output_details = vec![OutputDetail { + index: 0, + value: received, + address: Some(address), + role: OutputRole::Received, + }]; + record.net_amount = + i64::try_from(i128::from(received) - values.iter().map(|v| i128::from(*v)).sum::()) + .unwrap(); + record.txid = record.transaction.txid(); + conn.execute("DELETE FROM core_transactions", []).unwrap(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, ?3, 0, ?4)", + rusqlite::params![ + fixture.wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + record.block_info().map(|block| block.height()), + blob::encode(&record).unwrap() + ], + ) + .unwrap(); + drop(conn); + fixture +} + +#[tokio::test] +async fn should_reject_overflowing_replay_amounts_with_fitting_net() { + let max = i64::MAX as u64; + for (label, values, received) in [ + ("individually overflowing input", &[1_u64 << 63][..], 1), + ("cumulatively overflowing inputs", &[max, 1], 1), + ("overflowing output", &[max], 1_u64 << 63), + ] { + let _case = Case(label.into()); + let fixture = stage_replay_amounts(block(200), values, received).await; + let error = fixture + .persister + .load() + .expect_err("unsafe checker operands must return a typed error"); + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("expected typed backend error"); + }; + assert!( + matches!( + source.downcast_ref::(), + Some(platform_wallet::manager::history_replay::HistoryReplayError::UnsafeMinedAmounts { txids }) + if txids.contains(&fixture.recorded_txid()) + ), + "{label}: unsafe mined operands must fail before committed replay effects" + ); + } +} + +/// A mempool record whose input amounts are unsafe to sum is restored +/// without replay and without them, its inputs stay reserved, and the store +/// keeps the amounts, also when the record is stored again. +#[tokio::test] +async fn should_restore_mempool_record_with_unsafe_input_amounts_without_its_input_details() { + let fixture = stage_replay_amounts(MEMPOOL, &[i64::MAX as u64, 1], 1).await; + let txid = fixture.recorded_txid(); + + let (_, info) = fixture.load(); + + fixture.assert_spent_excluded(&info); + let restored = info.accounts.standard_bip44_accounts[&0].transactions()[&txid].clone(); + assert!( + restored.input_details.is_empty(), + "history holds the record without the amounts that are unsafe to sum" + ); + let round = core_round(CoreChangeSet { + records: vec![restored], + ..Default::default() + }); + fixture.persister.store(fixture.wallet_id, round).unwrap(); + let persisted = fixture + .persister + .get_core_tx_record(fixture.wallet_id, &txid); + let inputs: Vec<_> = persisted.unwrap().unwrap().input_details; + assert_eq!( + inputs.iter().map(|d| d.value).collect::>(), + vec![i64::MAX as u64, 1], + "the store keeps the input amounts it already knew" + ); +} + +#[tokio::test] +async fn should_load_wallet_holding_mempool_receipt_with_overflowing_foreign_output() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + // Unminable, but nothing rejects it on the way in: only the owned output is accounted. + let receipt = authorize( + &wallet, + &info, + &address, + spend_of( + &[outside(81)], + vec![ + pay(5_000, &address), + pay(i64::MAX as u64, &foreign_address(82)), + ], + ), + ); + let records = records_of(&mut info, &mut wallet, &receipt, MEMPOOL).await; + assert_eq!(records.len(), 1, "the live checker records the receipt"); + let new_utxos = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .cloned() + .collect(); + let core = CoreChangeSet { + records, + new_utxos, + ..Default::default() + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + + let mut state = persister + .load() + .expect("an unminable mempool receipt must not fail a strict load"); + + let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + assert_eq!( + coin_set(&info), + vec![OutPoint::new(receipt.txid(), 0)], + "the owned receipt survives the restart" + ); + assert_eq!(info.balance.total(), 5_000); +} + +#[tokio::test] +async fn should_release_forged_mempool_spend_with_overflowing_foreign_output() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(83)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = + info.accounts.standard_bip44_accounts[&0].utxos[&OutPoint::new(funding.txid(), 0)].clone(); + // Mempool signatures are not verified, so a peer can name a wallet coin as an input. + let forged = spend_of( + &[coin.outpoint], + vec![ + pay(i64::MAX as u64, &foreign_address(84)), + pay(1_000, &address), + ], + ); + records.extend(records_of(&mut info, &mut wallet, &forged, MEMPOOL).await); + assert_eq!( + records.len(), + 2, + "the live checker records the forged spend" + ); + let change = OutPoint::new(forged.txid(), 1); + let change_coin = info.accounts.standard_bip44_accounts[&0].utxos[&change].clone(); + let core = CoreChangeSet { + records, + new_utxos: vec![coin.clone(), change_coin], + spent_utxos: vec![coin.clone()], + ..Default::default() + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + + let mut state = persister + .load() + .expect("an unminable mempool spend must not fail a strict load"); + + let loaded = state.wallets.get_mut(&wallet.wallet_id).unwrap(); + let (info, wallet) = (&mut loaded.wallet_info, &mut loaded.wallet); + check(info, wallet, &funding, block(100)).await; + assert_eq!( + coin_set(info), + vec![coin.outpoint], + "an unauthenticated spend must not reserve a real coin or credit forged change" + ); + assert_eq!(info.balance.total(), 100_000); +} + +/// How a forged, unminable mempool transaction makes its amounts unsafe. +#[derive(Clone, Copy, Debug)] +enum UnsafeLoser { + /// It spends two confirmed wallet coins, a final competitor only one of + /// them; its foreign output overflows the replay arithmetic, its change is small. + OverflowingForeignOutput, + /// It spends a foreign coin and a wallet coin, a final competitor only the + /// foreign one; it pays the wallet twice, for a total past `i64`. + UnrepresentableReceipt, +} + +/// An [`UnsafeLoser`] as stored, optionally beside its final competitor. +struct StoredLoser { + wallet: Wallet, + funding: Transaction, + /// The wallet coin only the forged transaction claims. + extra: OutPoint, + forged: Transaction, + /// The confirmed competitor, mined at 101. + winner: Transaction, + /// The bip44 coins, and the balance, once `winner` has settled the conflict. + settled_coins: Vec, + settled_balance: u64, + persister: SqlitePersister, + _dir: tempfile::TempDir, +} + +impl StoredLoser { + async fn new(shape: UnsafeLoser, with_winner: bool) -> Self { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let (funding, extra, forged, winner, core, settled_coins, settled_balance) = match shape { + UnsafeLoser::OverflowingForeignOutput => { + let funding = spend_of( + &[outside(85)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + let shared = coins[&OutPoint::new(funding.txid(), 0)].clone(); + let extra = coins[&OutPoint::new(funding.txid(), 1)].clone(); + let winner = spend_of(&[shared.outpoint], vec![pay(99_000, &foreign_address(86))]); + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + let winner_records = + records_of(&mut scratch, &mut scratch_wallet, &winner, block(101)).await; + let outputs = vec![ + pay(i64::MAX as u64, &foreign_address(87)), + pay(1_000, &address), + ]; + let forged = authorize( + &wallet, + &info, + &address, + spend_of(&[shared.outpoint, extra.outpoint], outputs), + ); + records.extend(records_of(&mut info, &mut wallet, &forged, MEMPOOL).await); + assert_eq!( + records.len(), + 2, + "the live checker records the forged spend" + ); + assert_eq!(winner_records.len(), 1); + let change = info.accounts.standard_bip44_accounts[&0].utxos + [&OutPoint::new(forged.txid(), 1)] + .clone(); + if with_winner { + records.extend(winner_records); + } + let core = CoreChangeSet { + records, + new_utxos: vec![shared.clone(), extra.clone(), change], + spent_utxos: vec![shared, extra.clone()], + ..Default::default() + }; + let settled = vec![extra.outpoint]; + ( + funding, + extra.outpoint, + forged, + winner, + core, + settled, + 20_000, + ) + } + UnsafeLoser::UnrepresentableReceipt => { + let funding = spend_of(&[outside(96)], vec![pay(20_000, &address)]); + let extra = OutPoint::new(funding.txid(), 0); + let shared = outside(95); + let value = UNREPRESENTABLE_HALF; + let outputs = vec![pay(value, &address), pay(value, &address)]; + let forged = authorize( + &wallet, + &info, + &address, + spend_of(&[shared, extra], outputs), + ); + let winner = spend_of(&[shared], vec![pay(1_000, &address)]); + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + let mut records = + records_of(&mut scratch, &mut scratch_wallet, &funding, block(100)).await; + // Seen before its own input is funded: a debug checker cannot net this + // record against a known input, a release one wraps and stores it too. + records.extend(records_of(&mut info, &mut wallet, &forged, MEMPOOL).await); + assert_eq!( + records.len(), + 2, + "the live checker records the forged receipt" + ); + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + let running = check(&mut scratch, &mut scratch_wallet, &winner, block(101)).await; + assert_eq!( + running.swept_transactions, + vec![forged.txid()], + "the running wallet removes the forged receipt" + ); + let coin = stored_utxo(&funding, 0, address.clone()); + let mut new_utxos = vec![ + coin.clone(), + stored_utxo(&forged, 0, address.clone()), + stored_utxo(&forged, 1, address.clone()), + ]; + if with_winner { + records.extend(running.new_records); + new_utxos.push(stored_utxo(&winner, 0, address.clone())); + } + let core = CoreChangeSet { + records, + new_utxos, + spent_utxos: vec![coin], + ..Default::default() + }; + let mut settled = vec![extra, OutPoint::new(winner.txid(), 0)]; + settled.sort(); + (funding, extra, forged, winner, core, settled, 21_000) + } + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + Self { + wallet, + funding, + extra, + forged, + winner, + settled_coins, + settled_balance, + persister, + _dir, + } + } +} + +const UNSAFE_LOSERS: [UnsafeLoser; 2] = [ + UnsafeLoser::OverflowingForeignOutput, + UnsafeLoser::UnrepresentableReceipt, +]; + +#[tokio::test] +async fn should_settle_unsafe_mempool_loser_against_persisted_final_winner_on_restart() { + for shape in UNSAFE_LOSERS { + let _case = Case(format!("{shape:?}")); + let stored = StoredLoser::new(shape, true).await; + + let (_, info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + + let forged = stored.forged.txid(); + assert!( + !info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&forged), + "the forged transaction lost to the stored competitor at load" + ); + let record = stored + .persister + .get_core_tx_record(stored.wallet.wallet_id, &forged); + assert!(record.unwrap().is_none()); + assert_eq!( + coin_set(&info), + stored.settled_coins, + "the forged outputs are gone and the coin only it claimed is released" + ); + assert_eq!(info.balance.total(), stored.settled_balance); + } +} + +#[tokio::test] +async fn should_settle_unsafe_mempool_loser_against_final_winner_after_restart() { + for shape in UNSAFE_LOSERS { + let _case = Case(format!("{shape:?}")); + let stored = StoredLoser::new(shape, false).await; + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + + let result = check(&mut info, &mut wallet, &stored.winner, block(101)).await; + + assert_eq!( + result.swept_transactions, + vec![stored.forged.txid()], + "a final competitor arriving after restart still removes the forged transaction" + ); + assert_eq!( + result.released_outpoints, + vec![stored.extra], + "the wallet coin only the forged transaction claimed is released" + ); + check(&mut info, &mut wallet, &stored.funding, block(100)).await; + assert_eq!( + coin_set(&info), + stored.settled_coins, + "the forged outputs are gone and funding redelivery returns the released coin" + ); + assert_eq!(info.balance.total(), stored.settled_balance); + } +} + +#[tokio::test] +async fn should_report_balances_while_holding_unsafe_mempool_record_after_restart() { + let stored = StoredLoser::new(UnsafeLoser::OverflowingForeignOutput, false).await; + let change = OutPoint::new(stored.forged.txid(), 1); + + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + + assert!( + info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&stored.forged.txid()), + "the unsettled forged spend stays in history" + ); + // Recompute from scratch, then through the late-funding path that revisits stored records. + info.update_balance(); + assert_eq!(coin_set(&info), vec![change]); + assert_eq!(info.balance.total(), 1_000); + check(&mut info, &mut wallet, &stored.funding, block(100)).await; + info.update_balance(); + assert_eq!( + coin_set(&info), + vec![change], + "both claimed coins stay reserved while no competitor is final" + ); + assert_eq!(info.balance.total(), 1_000); + assert_eq!(info.balance.confirmed(), 0); +} + +#[tokio::test] +async fn should_keep_stored_owned_outputs_when_a_record_restored_without_them_is_stored_again() { + let stored = StoredLoser::new(UnsafeLoser::UnrepresentableReceipt, false).await; + let txid = stored.forged.txid(); + let (_, info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + let restored = info.accounts.standard_bip44_accounts[&0].transactions()[&txid].clone(); + assert!( + restored.output_details.is_empty(), + "history holds the record without the amounts that are unsafe to sum" + ); + + let round = core_round(CoreChangeSet { + records: vec![restored], + ..Default::default() + }); + stored + .persister + .store(stored.wallet.wallet_id, round) + .unwrap(); + + let persisted = stored + .persister + .get_core_tx_record(stored.wallet.wallet_id, &txid); + let owned: Vec<_> = persisted.unwrap().unwrap().output_details; + assert_eq!( + owned.iter().map(|d| d.value).collect::>(), + vec![UNREPRESENTABLE_HALF, UNREPRESENTABLE_HALF], + "the store keeps the owned outputs it already knew" + ); +} + +#[tokio::test] +async fn should_recompute_unsafe_mempool_record_when_its_funding_arrives_after_restart() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(88)], vec![pay(100_000, &address)]); + let coin = OutPoint::new(funding.txid(), 0); + // Seen before its funding, so stored without the input's amount. + let forged = authorize( + &wallet, + &info, + &address, + spend_of( + &[coin], + vec![ + pay(i64::MAX as u64, &foreign_address(89)), + pay(1_000, &address), + ], + ), + ); + let rival = authorize( + &wallet, + &info, + &address, + spend_of(&[coin], vec![pay(500, &address)]), + ); + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + let mut records = records_of(&mut scratch, &mut scratch_wallet, &forged, MEMPOOL).await; + records.extend(records_of(&mut info, &mut wallet, &rival, MEMPOOL).await); + assert_eq!(records.len(), 2); + let new_utxos = vec![ + stored_utxo(&forged, 1, address.clone()), + stored_utxo(&rival, 0, address.clone()), + ]; + let core = CoreChangeSet { + records, + new_utxos, + ..Default::default() + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + let (mut wallet, mut info) = load_wallet(&persister, &wallet.wallet_id); + + check(&mut info, &mut wallet, &funding, block(100)).await; + info.update_balance(); + + let record = &info.accounts.standard_bip44_accounts[&0].transactions()[&forged.txid()]; + assert_eq!( + record.input_details.len(), + 1, + "the late funding is attributed to the record restored as stored" + ); + assert_eq!(record.net_amount, 1_000 - 100_000); + assert_eq!(info.balance.total(), 1_500); +} + +/// The loser spends a shared and an extra coin, a second unconfirmed spend +/// takes the extra coin and a third one, and the confirmed winner spends only +/// the shared coin. A live sweep persists the sweep the running wallet +/// reported; otherwise the conflict is stored unsettled and repaired at load. +/// Either way the extra coin is released once its surviving spender is removed. +#[tokio::test] +async fn should_release_extra_input_when_its_surviving_spender_is_removed_after_restart() { + for live_sweep in [true, false] { + let _case = Case(format!("live_sweep={live_sweep}")); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let foreign = |marker, value| pay(value, &foreign_address(marker)); + let funding = spend_of( + &[outside(90)], + [100_000, 20_000, 5_000].map(|v| pay(v, &address)).into(), + ); + let coin = |vout| OutPoint::new(funding.txid(), vout); + let loser = authorize( + &wallet, + &info, + &address, + spend_of(&[coin(0), coin(1)], vec![foreign(91, 119_000)]), + ); + let survivor = authorize( + &wallet, + &info, + &address, + spend_of(&[coin(1), coin(2)], vec![foreign(92, 24_000)]), + ); + let winner = spend_of(&[coin(0)], vec![pay(99_000, &address)]); + let final_spend = spend_of(&[coin(2)], vec![foreign(93, 4_000)]); + + let mut records = Vec::new(); + for (transaction, context) in [ + (&funding, block(100)), + (&loser, MEMPOOL), + (&survivor, MEMPOOL), + ] { + records.extend(records_of(&mut info, &mut wallet, transaction, context).await); + } + assert_eq!( + records.len(), + 3, + "one wallet records both unconfirmed spenders of the extra coin" + ); + let settled = check(&mut info, &mut wallet, &winner, block(101)).await; + assert_eq!(settled.swept_transactions, vec![loser.txid()]); + assert!( + settled.released_outpoints.is_empty(), + "the survivor still spends the extra coin" + ); + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + let running = check(&mut scratch, &mut scratch_wallet, &final_spend, block(102)).await; + assert_eq!( + running.released_outpoints, + vec![coin(1)], + "the running wallet frees the extra coin with its surviving spender" + ); + let coins: Vec<_> = (0..3) + .map(|vout| stored_utxo(&funding, vout, address.clone())) + .collect(); + let mut seen = CoreChangeSet { + records, + new_utxos: coins.clone(), + spent_utxos: coins, + ..Default::default() + }; + let settlement = CoreChangeSet { + records: settled.new_records, + new_utxos: vec![stored_utxo(&winner, 0, address.clone())], + sweeps: vec![sweep( + settled.swept_transactions, + &winner, + settled.released_outpoints, + )], + ..Default::default() + }; + assert_eq!(settlement.records.len(), 1); + let (persister, _dir) = if live_sweep { + let (persister, dir) = store_then_reopen(&wallet, seen); + let round = core_round(settlement); + persister.store(wallet.wallet_id, round).unwrap(); + (persister, dir) + } else { + seen.records.extend(settlement.records); + seen.new_utxos.extend(settlement.new_utxos); + store_then_reopen(&wallet, seen) + }; + + let (mut wallet, mut info) = load_wallet(&persister, &wallet.wallet_id); + let result = check(&mut info, &mut wallet, &final_spend, block(102)).await; + + assert_eq!(result.swept_transactions, vec![survivor.txid()]); + assert_eq!( + result.released_outpoints, + vec![coin(1)], + "the extra coin is free once its surviving spender is gone" + ); + check(&mut info, &mut wallet, &funding, block(100)).await; + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coin(1)), + "funding redelivery returns the released coin" + ); + } +} + +/// How a mempool receipt paying two funding accounts reaches the store. +#[derive(Clone, Copy, Debug, Default)] +struct TwoAccountShape { + /// Value of each of the two owned outputs. + value: u64, + /// Pays only the second account, so the stored record names and details + /// that account alone. + second_only: bool, + /// Adds a foreign output no chain could hold. + foreign_output: bool, + /// The wallet-level record names the first account, not the second. + recorded_under_first: bool, + /// Persists no address pool rows, so load parks both coins in the first account. + unrouted: bool, + /// Adds a mempool child spending the first account's coin, with its change there. + /// It is recorded only in the account whose coin it spends, which is not + /// the one the stored receipt names. + child: bool, + /// The receipt also spends a coin of the first account funded only after + /// the restart, and so does a plain mempool rival that marks it spent. + unfunded_input: bool, +} + +struct TwoAccountReceipt { + wallet: Wallet, + /// An address of the first (BIP44) account. + first: Address, + funding: Transaction, + receipt: Transaction, + child: Option, + /// A confirmed transaction spending the receipt's foreign input. + winner: Transaction, + persister: SqlitePersister, + _dir: tempfile::TempDir, +} + +impl TwoAccountReceipt { + async fn stored(shape: TwoAccountShape) -> Self { + let (mut wallet, mut info, first, second) = two_account_wallet(); + let funding = spend_of(&[outside(96)], vec![pay(20_000, &first)]); + let shared = outside(97); + let mut inputs = vec![shared]; + if shape.unfunded_input { + // Seen before this input is funded, as a debug checker requires. + inputs.push(OutPoint::new(funding.txid(), 0)); + } + let mut outputs = vec![pay(shape.value, &first), pay(shape.value, &second)]; + if shape.second_only { + outputs.remove(0); + } + if shape.foreign_output { + outputs.push(pay(i64::MAX as u64, &foreign_address(98))); + } + let receipt = authorize(&wallet, &info, &first, spend_of(&inputs, outputs)); + let winner = spend_of(&[shared], vec![pay(1_000, &first)]); + let mut records = records_of(&mut info, &mut wallet, &receipt, MEMPOOL).await; + let mut new_utxos = if shape.second_only { + assert_eq!( + records.len(), + 1, + "the checker records it in the second account" + ); + vec![stored_utxo(&receipt, 0, second.clone())] + } else { + assert_eq!(records.len(), 2, "the checker records it in both accounts"); + // One wallet-level record per transaction, as a sync folds the + // per-account slices: it names one account and details both. + if !shape.recorded_under_first { + records.reverse(); + } + let other = records.pop().unwrap(); + records[0].output_details.extend(other.output_details); + records[0].output_details.sort_by_key(|detail| detail.index); + vec![ + stored_utxo(&receipt, 0, first.clone()), + stored_utxo(&receipt, 1, second.clone()), + ] + }; + let mut spent_utxos = Vec::new(); + let child = shape.child.then(|| { + authorize( + &wallet, + &info, + &first, + spend_of( + &[OutPoint::new(receipt.txid(), 0)], + vec![pay(1_500, &first)], + ), + ) + }); + if let Some(child) = &child { + records.extend(records_of(&mut info, &mut wallet, child, MEMPOOL).await); + spent_utxos.push(new_utxos[0].clone()); + new_utxos.push(stored_utxo(child, 0, first.clone())); + } + if shape.unfunded_input { + let rival = authorize( + &wallet, + &info, + &first, + spend_of(&[OutPoint::new(funding.txid(), 0)], vec![pay(500, &first)]), + ); + records.extend(records_of(&mut info, &mut wallet, &rival, MEMPOOL).await); + new_utxos.push(stored_utxo(&rival, 0, first.clone())); + } + let (mut running, mut running_wallet) = (info.clone(), wallet.clone()); + check(&mut running, &mut running_wallet, &winner, block(101)).await; + assert_eq!( + traces(&running, &[&receipt, child.as_ref().unwrap_or(&receipt)]), + Vec::::new(), + "the running wallet settles every account" + ); + let core = CoreChangeSet { + records, + new_utxos, + spent_utxos, + ..Default::default() + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + if !shape.unrouted { + let rows = [("standard_bip44", &first), ("standard_bip32", &second)]; + insert_pool_rows(&persister, &wallet.wallet_id, &rows); + } + Self { + wallet, + first, + funding, + receipt, + child, + winner, + persister, + _dir, + } + } +} + +/// A final competitor arriving after the restart must leave no coin of the +/// receipt in any account, in the running wallet and after the next load. +#[tokio::test] +async fn should_settle_two_account_mempool_receipt_against_final_winner_after_restart() { + let shape = |value| TwoAccountShape { + value, + ..Default::default() + }; + let unrepresentable = shape(UNREPRESENTABLE_HALF); + for shape in [ + shape(5_000), + unrepresentable, + TwoAccountShape { + recorded_under_first: true, + ..unrepresentable + }, + TwoAccountShape { + foreign_output: true, + ..shape(7_000) + }, + TwoAccountShape { + child: true, + ..unrepresentable + }, + TwoAccountShape { + unrouted: true, + ..unrepresentable + }, + // Load parks the coin in the first account, which the record never + // names: settlement must still find it there. + TwoAccountShape { + second_only: true, + foreign_output: true, + unrouted: true, + ..shape(7_000) + }, + ] { + let _case = Case(format!("{shape:?}")); + let stored = TwoAccountReceipt::stored(shape).await; + let losers = [ + &stored.receipt, + stored.child.as_ref().unwrap_or(&stored.receipt), + ]; + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + + let result = check(&mut info, &mut wallet, &stored.winner, block(101)).await; + + assert!(result.swept_transactions.contains(&stored.receipt.txid())); + assert_eq!( + traces(&info, &losers), + Vec::::new(), + "no account of the running wallet keeps a coin of the removed receipt" + ); + assert_eq!(info.balance.total(), 1_000); + + let round = core_round(CoreChangeSet { + records: result.new_records, + new_utxos: vec![stored_utxo(&stored.winner, 0, stored.first.clone())], + sweeps: vec![sweep( + result.swept_transactions, + &stored.winner, + result.released_outpoints, + )], + ..Default::default() + }); + stored + .persister + .store(stored.wallet.wallet_id, round) + .unwrap(); + let (_, reloaded) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + assert_eq!( + traces(&reloaded, &losers), + Vec::::new(), + "the next load holds no coin of the removed receipt either" + ); + assert_eq!(reloaded.balance.total(), 1_000); + } +} + +/// Each account holds the receipt without the amounts that are unsafe to +/// sum, so reading balances stays in range. A late funding rebuilds the owning +/// account's complete slice (rust-dashcore#1106): its net is its own coin less +/// the funded input, which stays in range, and the balance is unchanged. +#[tokio::test] +async fn should_recompute_unrepresentable_two_account_receipt_when_its_funding_arrives_after_restart( +) { + let stored = TwoAccountReceipt::stored(TwoAccountShape { + value: UNREPRESENTABLE_HALF, + unfunded_input: true, + ..Default::default() + }) + .await; + let txid = stored.receipt.txid(); + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + let copies = |info: &ManagedWalletInfo| { + let accounts = &info.accounts; + ( + accounts.standard_bip44_accounts[&0].transactions()[&txid].clone(), + accounts.standard_bip32_accounts[&0].transactions()[&txid].clone(), + ) + }; + let (first, second) = copies(&info); + assert!( + first.output_details.is_empty() && second.output_details.is_empty(), + "each account holds the receipt without the amounts unsafe to sum" + ); + assert_eq!( + info.balance.total(), + 2 * UNREPRESENTABLE_HALF + 500, + "both coins are credited" + ); + + check(&mut info, &mut wallet, &stored.funding, block(100)).await; + info.update_balance(); + + let (first, second) = copies(&info); + assert_eq!( + (first.input_details.len(), first.net_amount), + (1, UNREPRESENTABLE_HALF as i64 - 20_000), + "the late funding is attributed in the account that owns it" + ); + assert!(second.input_details.is_empty()); + assert_eq!(info.balance.total(), 2 * UNREPRESENTABLE_HALF + 500); +} + +/// A stored mempool record spending `values` (safe to sum, then not), beaten +/// after the restart by a final transaction the wallet has no other interest +/// in: no owned output, and no input the wallet can attribute. Whatever the +/// checker reports is persisted as a sync would, and the store is loaded again. +#[tokio::test] +async fn should_settle_mempool_record_against_recordless_final_competitor_after_restart() { + for values in [[5_000, 1], [i64::MAX as u64, 1]] { + let _case = Case(format!("inputs={values:?}")); + let fixture = stage_replay_amounts(MEMPOOL, &values, 1).await; + let txid = fixture.recorded_txid(); + let loser = fixture + .persister + .get_core_tx_record(fixture.wallet_id, &txid) + .unwrap() + .unwrap(); + let address = loser.output_details[0].address.clone().unwrap(); + // The loser's own output, as the round that stored the record wrote it. + let round = core_round(CoreChangeSet { + new_utxos: vec![stored_utxo(&loser.transaction, 0, address)], + ..Default::default() + }); + fixture.persister.store(fixture.wallet_id, round).unwrap(); + let (mut wallet, mut info) = fixture.load(); + assert_eq!(traces(&info, &[&loser.transaction]).len(), 1); + + let inputs = &loser.transaction.input; + let winner = spend_of( + &[inputs[0].previous_output], + vec![pay(900, &foreign_address(77))], + ); + let result = check(&mut info, &mut wallet, &winner, block(101)).await; + + assert!( + !result.is_relevant && result.new_records.is_empty(), + "the competitor leaves no record of its own" + ); + assert_eq!(result.swept_transactions, vec![txid]); + assert_eq!( + result.released_outpoints, + vec![inputs[1].previous_output], + "the input only the loser claimed is released" + ); + assert_eq!(traces(&info, &[&loser.transaction]), Vec::::new()); + let round = core_round(CoreChangeSet { + sweeps: vec![sweep( + result.swept_transactions, + &winner, + result.released_outpoints, + )], + ..Default::default() + }); + fixture.persister.store(fixture.wallet_id, round).unwrap(); + + let (_, reloaded) = fixture.load(); + assert_eq!( + traces(&reloaded, &[&loser.transaction]), + Vec::::new(), + "the next load holds neither the record nor its coin" + ); + let rows: i64 = fixture + .persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_transactions WHERE txid = ?1", + [txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(rows, 0, "and the store holds its record no longer"); + } +} + +/// How a mempool spend of two forged receipts and two real coins reaches the store. +#[derive(Clone, Copy, Debug, Default)] +struct ForgedInputShape { + /// Value of each forged receipt: two of `UNREPRESENTABLE_HALF` cannot be + /// summed as `i64`, so the spend's input amounts are unsafe. + forged: u64, + /// The spend also pays the second funding account. + two_accounts: bool, + /// The real coins are funded only after the restart, and a plain mempool + /// rival spends one of them too, which marks it spent. + late_funding: bool, +} + +struct ForgedInputSpend { + wallet: Wallet, + /// Pays the wallet 20_000 (`shared`) and 30_000 (`extra`), confirmed. + funding: Transaction, + receipts: [Transaction; 2], + /// Spends `shared`, `extra` and both receipts. + spend: Transaction, + /// A confirmed transaction spending `shared` and paying outside the wallet. + winner: Transaction, + persister: SqlitePersister, + _dir: tempfile::TempDir, +} + +impl ForgedInputSpend { + async fn stored(shape: ForgedInputShape) -> Self { + let (mut wallet, mut info, first, second) = two_account_wallet(); + let funding = spend_of( + &[outside(73)], + vec![pay(20_000, &first), pay(30_000, &first)], + ); + let [shared, extra] = [0, 1].map(|vout| OutPoint::new(funding.txid(), vout)); + let receipts = + [71, 72].map(|byte| spend_of(&[outside(byte)], vec![pay(shape.forged, &first)])); + let mut outputs = vec![pay(1_000, &first)]; + if shape.two_accounts { + outputs.push(pay(1_500, &second)); + } + let receipt_coins = receipts + .each_ref() + .map(|receipt| OutPoint::new(receipt.txid(), 0)); + let spend = authorize( + &wallet, + &info, + &first, + spend_of( + &[shared, extra, receipt_coins[0], receipt_coins[1]], + outputs, + ), + ); + let winner = spend_of(&[shared], vec![pay(49_000, &foreign_address(74))]); + + let mut records = Vec::new(); + let mut new_utxos = Vec::new(); + let mut spent_utxos = Vec::new(); + if !shape.late_funding { + records.extend(records_of(&mut info, &mut wallet, &funding, block(100)).await); + new_utxos.extend([0, 1].map(|vout| stored_utxo(&funding, vout, first.clone()))); + spent_utxos.extend(new_utxos.clone()); + } + // Each receipt is seen by a wallet that does not hold the spend: a + // debug checker cannot attribute both to one record. + for receipt in &receipts { + let (mut scratch, mut scratch_wallet) = (info.clone(), wallet.clone()); + records.extend(records_of(&mut scratch, &mut scratch_wallet, receipt, MEMPOOL).await); + let coin = stored_utxo(receipt, 0, first.clone()); + new_utxos.push(coin.clone()); + spent_utxos.push(coin); + } + let mut slices = records_of(&mut info, &mut wallet, &spend, MEMPOOL).await; + assert_eq!(slices.len(), 1 + usize::from(shape.two_accounts)); + // One wallet-level record, as a sync folds the per-account slices. + let mut folded = slices.remove(0); + for slice in slices { + folded.output_details.extend(slice.output_details); + } + records.push(folded); + new_utxos.push(stored_utxo(&spend, 0, first.clone())); + if shape.two_accounts { + new_utxos.push(stored_utxo(&spend, 1, second.clone())); + } + if shape.late_funding { + let rival = authorize( + &wallet, + &info, + &first, + spend_of(&[shared], vec![pay(500, &first)]), + ); + records.extend(records_of(&mut info, &mut wallet, &rival, MEMPOOL).await); + new_utxos.push(stored_utxo(&rival, 0, first.clone())); + } + let core = CoreChangeSet { + records, + new_utxos, + spent_utxos, + ..Default::default() + }; + let (persister, _dir) = store_then_reopen(&wallet, core); + if shape.two_accounts { + let rows = [("standard_bip44", &first), ("standard_bip32", &second)]; + insert_pool_rows(&persister, &wallet.wallet_id, &rows); + } + Self { + wallet, + funding, + receipts, + spend, + winner, + persister, + _dir, + } + } +} + +/// A final competitor that spends one of the wallet's own coins and pays +/// outside it. Beside an ordinary spend it leaves no record, since that spend +/// already took the coin; beside a restored one the wallet still attributes +/// the coin and records it. Either way it must remove the stored spend, in the +/// running wallet and for the next load, and release what only that spend claimed. +/// The 7_000 receipts are safe to sum: the control, an ordinary record. +#[tokio::test] +async fn should_settle_mempool_spend_against_final_outside_spend_of_its_coin_after_restart() { + let shape = |forged, two_accounts| ForgedInputShape { + forged, + two_accounts, + late_funding: false, + }; + for shape in [ + shape(7_000, false), + shape(UNREPRESENTABLE_HALF, false), + shape(UNREPRESENTABLE_HALF, true), + ] { + let _case = Case(format!("{shape:?}")); + let stored = ForgedInputSpend::stored(shape).await; + let outputs = 1_000 + if shape.two_accounts { 1_500 } else { 0 }; + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + assert_eq!( + info.balance.total(), + outputs, + "only the spend's own outputs are spendable after the restart" + ); + + let result = check(&mut info, &mut wallet, &stored.winner, block(101)).await; + + assert_eq!(result.swept_transactions, vec![stored.spend.txid()]); + assert_eq!( + traces(&info, &[&stored.spend]), + Vec::::new(), + "no account of the running wallet keeps a coin of the removed spend" + ); + let round = core_round(CoreChangeSet { + records: result.new_records, + sweeps: vec![sweep( + result.swept_transactions, + &stored.winner, + result.released_outpoints, + )], + ..Default::default() + }); + stored + .persister + .store(stored.wallet.wallet_id, round) + .unwrap(); + + let (_, reloaded) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + assert_eq!(traces(&reloaded, &[&stored.spend]), Vec::::new()); + let coins: Vec = reloaded + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.keys().copied()) + .collect(); + let mut released = vec![ + OutPoint::new(stored.funding.txid(), 1), + OutPoint::new(stored.receipts[0].txid(), 0), + OutPoint::new(stored.receipts[1].txid(), 0), + ]; + released.sort(); + assert_eq!( + coins, released, + "the next load returns what only the spend claimed, not the coin the competitor spent" + ); + assert_eq!(reloaded.balance.total(), 30_000 + 2 * shape.forged); + } +} + +/// The real coins are funded only after the restart. A restored record holds +/// no input amounts and marks nothing spent by itself: the coin the rival +/// also spends is attributed to it, and that one amount is all its net sums; +/// the coin only it spends is credited when its funding arrives. Replayed as +/// an ordinary record (the 7_000 control), the same spend also knows its +/// receipts and keeps both real coins reserved. +#[tokio::test] +async fn should_attribute_late_funding_to_a_mempool_spend_restored_or_replayed() { + // (forged receipt value, attributed inputs, net, wallet total once funded) + for (forged, expected) in [ + (UNREPRESENTABLE_HALF, (1, 1_000 - 20_000, 1_500 + 30_000)), + (7_000, (4, 1_000 - 50_000 - 14_000, 1_500)), + ] { + let _case = Case(format!("forged={forged}")); + let stored = ForgedInputSpend::stored(ForgedInputShape { + forged, + late_funding: true, + ..Default::default() + }) + .await; + let (mut wallet, mut info) = load_wallet(&stored.persister, &stored.wallet.wallet_id); + assert_eq!(info.balance.total(), 1_500); + + check(&mut info, &mut wallet, &stored.funding, block(100)).await; + info.update_balance(); + + let account = &info.accounts.standard_bip44_accounts[&0]; + let record = &account.transactions()[&stored.spend.txid()]; + assert_eq!( + ( + record.input_details.len(), + record.net_amount, + info.balance.total() + ), + expected + ); + } +} + +/// How the store reads once a load has settled a conflict that removes a +/// DashPay payment's transaction. +#[derive(Debug, PartialEq)] +struct SettledPayment { + /// Stored records of the payment's transaction. + loser_rows: i64, + /// The payment's status in the state the settling load returned. + returned: platform_wallet::wallet::identity::PaymentStatus, + /// Overlay rows written for the payment: one per verdict that reached the store. + stored_verdicts: i64, + /// The payment's status as a later strict load reads it. + reopened: platform_wallet::wallet::identity::PaymentStatus, + /// An unrelated sent payment of the same identity, on that later load. + unrelated: platform_wallet::wallet::identity::PaymentStatus, +} + +/// A DashPay payment whose transaction lost a double-spend. The winner's +/// record reached the store; the round carrying the sweep, and with it the +/// payment's verdict, did not. A load under `policy` settles the conflict. +async fn settle_payment_at_load( + mut payment: platform_wallet::wallet::identity::PaymentEntry, + status: platform_wallet::wallet::identity::PaymentStatus, + policy: LoadPolicy, +) -> SettledPayment { + use dpp::prelude::Identifier; + use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; + use platform_wallet::wallet::identity::{IdentityStatus, PaymentEntry}; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(91)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = OutPoint::new(funding.txid(), 0); + let loser = authorize( + &wallet, + &info, + &address, + spend_of( + &[coin], + vec![pay(59_000, &foreign_address(92)), pay(40_000, &address)], + ), + ); + records.extend(records_of(&mut info, &mut wallet, &loser, MEMPOOL).await); + let winner = spend_of(&[coin], vec![pay(99_000, &address)]); + let won = check(&mut info, &mut wallet, &winner, block(101)).await; + assert_eq!(won.swept_transactions, vec![loser.txid()]); + records.extend(won.new_records); + + let funded = stored_utxo(&funding, 0, address.clone()); + let (persister, dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: vec![ + funded.clone(), + stored_utxo(&loser, 1, address.clone()), + stored_utxo(&winner, 0, address.clone()), + ], + spent_utxos: vec![funded], + ..Default::default() + }, + ); + let owner = Identifier::from([0xA7; 32]); + let contact = Identifier::from([0xB7; 32]); + let (txid, unrelated) = (loser.txid().to_string(), "a".repeat(64)); + payment.status = status; + let payments = [ + (txid.clone(), payment), + ( + unrelated.clone(), + PaymentEntry::new_sent(contact, 1_000, None), + ), + ]; + let entry = IdentityEntry { + id: owner, + balance: 0, + revision: 0, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id: Some(wallet.wallet_id), + dashpay_profile: None, + dashpay_payments: payments.into(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), + }; + let mut identities = IdentityChangeSet::default(); + identities.identities.insert(owner, entry); + let round = PlatformWalletChangeSet { + identities: Some(identities), + ..Default::default() + }; + persister.store(wallet.wallet_id, round).unwrap(); + drop(persister); + + let open = |policy| { + let path = dir.path().join("wallet.db"); + SqlitePersister::open(SqlitePersisterConfig::new(path).with_load_policy(policy)).unwrap() + }; + let status_of = |persister: &SqlitePersister, txid: &str| { + let state = persister.load().unwrap(); + let identities = &state.wallets[&wallet.wallet_id].identity_manager; + let managed = &identities.wallet_identities[&wallet.wallet_id][&0]; + managed.dashpay().payments[txid].status + }; + + let settling = open(policy); + let returned = status_of(&settling, &txid); + let loser_rows = count( + &settling, + "SELECT count(*) FROM core_transactions WHERE height IS NULL", + ); + let stored_verdicts = count(&settling, "SELECT count(*) FROM dashpay_payments_overlay"); + drop(settling); + let reopened = open(LoadPolicy::Strict); + SettledPayment { + loser_rows, + returned, + stored_verdicts, + reopened: status_of(&reopened, &txid), + unrelated: status_of(&reopened, &unrelated), + } +} + +/// A sent payment whose transaction a load settles away reads `Failed` on that +/// load and the next, an InstantSend-locked one (`Confirmed` until a final +/// competitor removes it) too. A received payment's status is settled when it +/// is recorded, so a sweep of its transaction does not move it. A recovery load +/// returns the settled state and writes none of it: the verdict rolls back +/// with the record removal it follows from, and the next strict load settles again. +#[tokio::test] +async fn should_settle_the_dashpay_payment_of_a_transaction_a_load_settlement_removes() { + use platform_wallet::wallet::identity::PaymentEntry; + use platform_wallet::wallet::identity::PaymentStatus::{Confirmed, Failed, Pending}; + + let contact = dpp::prelude::Identifier::from([0xB7; 32]); + let sent = || PaymentEntry::new_sent(contact, 59_000, None); + let received = PaymentEntry::new_received(contact, 59_000, None); + let settled = |loser_rows, returned, stored_verdicts, reopened| SettledPayment { + loser_rows, + returned, + stored_verdicts, + reopened, + unrelated: Pending, + }; + for (label, payment, status, policy, expected) in [ + ( + "pending sent", + sent(), + Pending, + LoadPolicy::Strict, + settled(0, Failed, 1, Failed), + ), + ( + "confirmed sent", + sent(), + Confirmed, + LoadPolicy::Strict, + settled(0, Failed, 1, Failed), + ), + ( + "received", + received, + Confirmed, + LoadPolicy::Strict, + settled(0, Confirmed, 0, Confirmed), + ), + ( + "pending sent, recovery load", + sent(), + Pending, + LoadPolicy::Recovery, + settled(1, Failed, 0, Failed), + ), + ] { + let _case = Case(label.into()); + let outcome = settle_payment_at_load(payment, status, policy).await; + assert_eq!(outcome, expected, "{label}"); + } +} + +/// A later ChainLock does not prove that a stored block belongs to its branch. +#[tokio::test] +async fn should_preserve_instant_send_replacement_when_stale_branch_has_lower_height() { + for lock_height in [101, 102] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(94)], vec![pay(100_000, &address)]); + let coin = OutPoint::new(funding.txid(), 0); + let stale = spend_of(&[coin], vec![pay(60_000, &address)]); + let replacement = spend_of(&[coin], vec![pay(50_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let mut fork = info.clone(); + records.extend(records_of(&mut fork, &mut wallet, &stale, block(101)).await); + let (lock, evidence) = common::checked_instant_lock(&replacement).await; + records.extend( + records_of( + &mut info, + &mut wallet, + &replacement, + TransactionContext::InstantSend(lock.clone()), + ) + .await, + ); + let (persister, _dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: vec![ + stored_utxo(&stale, 0, address.clone()), + stored_utxo(&replacement, 0, address), + ], + instant_locks_for_non_final_records: BTreeMap::from([(replacement.txid(), lock)]), + last_applied_chain_lock: Some(chain_lock(lock_height, 95)), + ..Default::default() + }, + ); + for _ in 0..2 { + let loaded = persister.load_with_replay(&evidence).unwrap().state; + let restored = &loaded.wallets[&wallet.wallet_id].wallet_info; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!( + account.transactions().contains_key(&replacement.txid()), + "height {lock_height}: a stale branch must not erase the authenticated replacement" + ); + assert!(account + .utxos + .contains_key(&OutPoint::new(replacement.txid(), 0))); + assert!( + !account.keys().transaction_is_finalized(&stale.txid()), + "height alone is not branch membership" + ); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &replacement.txid()) + .unwrap() + .is_some()); + } + } +} + +#[tokio::test] +async fn should_repair_invalid_claims_without_releasing_authorized_competitors() { + for context in [ + MEMPOOL, + block(101), + TransactionContext::InstantSend(Default::default()), + ] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(96)], + vec![pay(100_000, &address), pay(20_000, &address)], + ); + let shared = OutPoint::new(funding.txid(), 0); + let extra = OutPoint::new(funding.txid(), 1); + let mut bad = authorize( + &wallet, + &info, + &address, + spend_of(&[shared, extra], vec![pay(119_000, &address)]), + ); + bad.output[0].value -= 1; // A peer cannot modify an authorized transaction. + let survivor = authorize( + &wallet, + &info, + &address, + spend_of(&[shared], vec![pay(99_000, &address)]), + ); + let child = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(bad.txid(), 0)], + vec![pay(118_000, &address)], + ), + ); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let mut alternative = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &bad, MEMPOOL).await); + records.extend(records_of(&mut info, &mut wallet, &child, MEMPOOL).await); + let (checked_lock, evidence) = common::checked_instant_lock(&survivor).await; + let context = if matches!(context, TransactionContext::InstantSend(_)) { + TransactionContext::InstantSend(checked_lock) + } else { + context + }; + records.extend(records_of(&mut alternative, &mut wallet, &survivor, context).await); + let funding_coins: Vec<_> = (0..2) + .map(|i| stored_utxo(&funding, i, address.clone())) + .collect(); + let bad_coin = stored_utxo(&bad, 0, address.clone()); + let (persister, _dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: funding_coins + .iter() + .cloned() + .chain([ + bad_coin.clone(), + stored_utxo(&survivor, 0, address.clone()), + stored_utxo(&child, 0, address.clone()), + ]) + .collect(), + spent_utxos: funding_coins.into_iter().chain([bad_coin]).collect(), + ..Default::default() + }, + ); + for _ in 0..2 { + let mut state = persister.load_with_replay(&evidence).unwrap().state; + let restored = state.wallets.get_mut(&wallet.wallet_id).unwrap(); + let mut expected = vec![extra, OutPoint::new(survivor.txid(), 0)]; + expected.sort(); + assert_eq!(coin_set(&restored.wallet_info), expected); + check( + &mut restored.wallet_info, + &mut restored.wallet, + &funding, + block(100), + ) + .await; + assert_eq!( + coin_set(&restored.wallet_info), + expected, + "funding redelivery must retain the authorized reservation" + ); + for rejected in [&bad, &child] { + assert!(persister + .get_core_tx_record(wallet.wallet_id, &rejected.txid()) + .unwrap() + .is_none()); + } + assert!(persister + .get_core_tx_record(wallet.wallet_id, &survivor.txid()) + .unwrap() + .is_some()); + } + } +} + +#[tokio::test] +async fn should_keep_authorization_repair_atomic_and_recovery_read_only() { + for case in ["sql_failure", "unsupported_signature", "recovery"] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(97)], vec![pay(100_000, &address)]); + let coin = OutPoint::new(funding.txid(), 0); + let bad = spend_of(&[coin], vec![pay(99_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let mut alternative = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &bad, MEMPOOL).await); + if case == "unsupported_signature" { + let unsupported = authorize_with_mode( + &wallet, + &alternative, + &address, + spend_of(&[coin], vec![pay(98_000, &address)]), + 2, + ); + records.extend(records_of(&mut alternative, &mut wallet, &unsupported, MEMPOOL).await); + } + let (persister, _dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: vec![ + stored_utxo(&funding, 0, address.clone()), + stored_utxo(&bad, 0, address.clone()), + ], + spent_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }, + ); + if case == "sql_failure" { + persister.lock_conn_for_test().execute_batch( + "CREATE TRIGGER refuse_release BEFORE UPDATE OF spent ON core_utxos \ + WHEN OLD.spent = 1 AND NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'release refused'); END;" + ).unwrap(); + } + let path = persister.config_for_test().path.clone(); + drop(persister); + let policy = if case == "recovery" { + LoadPolicy::Recovery + } else { + LoadPolicy::Strict + }; + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(path).with_load_policy(policy)) + .unwrap(); + let result = persister.load(); + if case == "recovery" { + assert_eq!( + coin_set(&result.unwrap().wallets[&wallet.wallet_id].wallet_info), + vec![coin] + ); + } else { + let error = typed_error(result.unwrap_err()); + if case == "unsupported_signature" { + assert!(matches!(error, WalletStorageError::SpendAuthorization(_))); + } else { + assert!(matches!(error, WalletStorageError::Sqlite(_))); + } + } + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &bad.txid()) + .unwrap() + .is_some(), + "{case}: no rejected body may disappear on an unsuccessful or recovery load" + ); + let held: bool = persister + .lock_conn_for_test() + .query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + blob::encode_outpoint(&coin).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert!( + held, + "{case}: durable reservation must roll back with its record" + ); + } +} + +#[tokio::test] +async fn should_remove_drifted_unauthorized_rows_from_the_recovery_snapshot() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(98)], vec![pay(100_000, &address)]); + let coin = OutPoint::new(funding.txid(), 0); + let bad = spend_of(&[coin], vec![pay(99_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + records.extend(records_of(&mut info, &mut wallet, &bad, MEMPOOL).await); + let (persister, _dir) = store_then_reopen( + &wallet, + CoreChangeSet { + records, + new_utxos: vec![ + stored_utxo(&funding, 0, address.clone()), + stored_utxo(&bad, 0, address.clone()), + ], + spent_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }, + ); + let path = persister.config_for_test().path.clone(); + drop(persister); + // Deliberately corrupt the physical key without cascading related rows. + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap(); + conn.execute( + "UPDATE core_transactions SET txid = ?1 WHERE txid = ?2", + rusqlite::params![ + [0xE1u8; 32].as_slice(), + bad.txid().as_byte_array().as_slice() + ], + ) + .unwrap(); + conn.execute( + "UPDATE core_transaction_inputs SET txid = ?1 WHERE txid = ?2", + rusqlite::params![ + [0xE1u8; 32].as_slice(), + bad.txid().as_byte_array().as_slice() + ], + ) + .unwrap(); + drop(conn); + let persister = SqlitePersister::open( + SqlitePersisterConfig::new(path).with_load_policy(LoadPolicy::Recovery), + ) + .unwrap(); + for _ in 0..2 { + let state = persister.load().unwrap(); + let restored = &state.wallets[&wallet.wallet_id]; + assert_eq!( + coin_set(&restored.wallet_info), + vec![coin], + "unauthorized drifted body must not restore a reservation" + ); + } + let count: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_transactions WHERE txid = ?1", + [[0xE1u8; 32].as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "Recovery repairs only its rolled-back snapshot"); +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs index 43200970b2a..edb8c1f7e82 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs @@ -3754,7 +3754,10 @@ fn should_preserve_mainline_sweep_holds_through_rehydration_migrations() { params![w.as_slice(), encoded], ) .unwrap(); - migrations::run(&mut conn).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); assert_eq!( utxo_row_state(&conn, &w, &input), Some((true, true, Some(400))) @@ -3766,6 +3769,13 @@ fn should_preserve_mainline_sweep_holds_through_rehydration_migrations() { assert!(row_exists(&conn, &w, &input)); apply_heights(&mut conn, &w, 400); assert!(!row_exists(&conn, &w, &input)); + conn.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, is_sweep_placeholder, winner_mined_height) VALUES (?1, ?2, 0, X'', 1, 1, 400)", + params![w.as_slice(), blob::encode_outpoint(&input).unwrap()]).unwrap(); + migrations::run(&mut conn).unwrap(); + assert!( + !row_exists(&conn, &w, &input), + "V021 rescans mined sweep holds" + ); } #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs new file mode 100644 index 00000000000..32770cc2930 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs @@ -0,0 +1,285 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use dash_spv::error::NetworkResult; +use dash_spv::network::{Inbound, MessageType, NetworkEvent, NetworkManager, RequestKey}; +use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; +use dash_spv::{ClientConfig, DashSpvClient, EventHandler}; +use dash_spv::{SyncEvent, SyncProgress}; +use dashcore::network::message::NetworkMessage; +use dashcore::{Network, OutPoint, Txid}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::key_wallet_manager::{WalletEvent, WalletManager}; +use platform_wallet::test_support::core_fixtures::{ + outside, pay, spend_of, wallet_with_receive_address, +}; +use platform_wallet::wallet::platform_wallet::PlatformWalletInfo; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use tokio::sync::{broadcast, mpsc, RwLock}; + +use crate::common::{ + authorize, fresh_persister, mine, persist_events, registration, secure_tempdir, +}; + +/// Shared handle to the existing in-process transport. No method opens sockets or resolves DNS. +#[derive(Clone)] +struct InjectedNetwork { + inner: Arc, +} + +impl InjectedNetwork { + fn new() -> Self { + Self { + inner: Arc::new(MockNetworkManager::new()), + } + } +} + +#[async_trait::async_trait] +impl NetworkManager for InjectedNetwork { + async fn start(&self) { + self.inner.start().await; + } + async fn stop(&self) { + self.inner.stop().await; + } + async fn enqueue(&self, height: u32, message: NetworkMessage) { + self.inner.enqueue(height, message).await; + } + async fn send_to(&self, address: SocketAddr, message: NetworkMessage) -> bool { + self.inner.send_to(address, message).await + } + async fn broadcast(&self, message: NetworkMessage) -> NetworkResult<()> { + self.inner.broadcast(message).await + } + async fn dispatch_local(&self, message: NetworkMessage) { + self.inner.dispatch_local(message).await; + } + async fn release_request(&self, key: RequestKey) { + self.inner.release_request(key).await; + } + async fn subscribe(&self, types: &[MessageType]) -> mpsc::UnboundedReceiver { + self.inner.subscribe(types).await + } + fn events(&self) -> broadcast::Receiver { + self.inner.events() + } + fn tip(&self) -> u32 { + self.inner.tip() + } + async fn connected_count(&self) -> u32 { + self.inner.connected_count().await + } + async fn ban_peer(&self, address: SocketAddr) { + self.inner.ban_peer(address).await; + } +} + +#[derive(Debug)] +enum Notice { + Detected(Txid), + Locked(Txid), + Proof(Txid), + Rejected(u32), + Error(String), +} + +struct Observer(mpsc::UnboundedSender); +impl EventHandler for Observer { + fn on_sync_event(&self, event: &SyncEvent) { + if let SyncEvent::InstantLockReceived { instant_lock } = event { + let _ = self.0.send(Notice::Proof(instant_lock.txid())); + } + } + fn on_progress(&self, progress: &SyncProgress) { + if let Ok(instantsend) = progress.instantsend() { + if instantsend.invalid() > 0 { + let _ = self.0.send(Notice::Rejected(instantsend.invalid())); + } + } + } + fn on_wallet_event(&self, event: &WalletEvent) { + let notice = match event { + WalletEvent::TransactionDetected { record, .. } => Notice::Detected(record.txid), + WalletEvent::TransactionInstantLocked { txid, .. } => Notice::Locked(*txid), + _ => return, + }; + let _ = self.0.send(notice); + } + fn on_error(&self, error: &str) { + let _ = self.0.send(Notice::Error(error.to_owned())); + } +} + +async fn receive_until( + receiver: &mut mpsc::UnboundedReceiver, + predicate: impl Fn(&Notice) -> bool, +) -> Vec { + tokio::time::timeout(Duration::from_secs(10), async { + let mut seen = Vec::new(); + loop { + let notice = receiver.recv().await.expect("SPV observer remains alive"); + if let Notice::Error(error) = ¬ice { + panic!("SPV failed: {error}"); + } + let done = predicate(¬ice); + seen.push(notice); + if done { + return seen; + } + } + }) + .await + .expect("in-process SPV must process the injected messages") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_transaction() { + for early in [false, true] { + let (persister, _directory, path) = fresh_persister(); + let persister = Arc::new(persister); + let (wallet, info, address) = wallet_with_receive_address(); + let wallet_id = wallet.wallet_id; + let funding = spend_of( + &[outside(191)], + vec![pay(50_000, &address), pay(50_000, &address)], + ); + let sends: Vec<_> = (0..2) + .map(|vout| { + authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), vout)], + vec![pay(49_000, &address)], + ), + ) + }) + .collect(); + let invalid_id = sends[0].txid(); + let valid_id = sends[1].txid(); + let invalid_fixture = InstantLockFixture::new(Network::Testnet, &sends[0]).unwrap(); + let valid_fixture = InstantLockFixture::new(Network::Testnet, &sends[1]).unwrap(); + let mut invalid = invalid_fixture.instant_lock().clone(); + invalid.signature = valid_fixture.instant_lock().signature; + let mut manager = WalletManager::::new(Network::Testnet); + let mut persistence_events = manager.take_persistence_receiver().unwrap(); + let mut managed = PlatformWalletInfo::from_wallet(&wallet, 0); + managed.core_wallet = info; + persister + .store(wallet_id, registration(&wallet, None)) + .unwrap(); + manager.insert_wallet(wallet, managed).unwrap(); + let manager = Arc::new(RwLock::new(manager)); + mine(&manager, wallet_id, funding, 100).await; + persist_events(&manager, &persister, &mut persistence_events).await; + + let network = InjectedNetwork::new(); + let spv_directory = secure_tempdir().unwrap(); + let (notices, mut observed) = mpsc::unbounded_channel(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(spv_directory.path()), + network.clone(), + manager.clone(), + vec![Arc::new(Observer(notices))], + ) + .await + .unwrap(); + client.run().await.unwrap(); + valid_fixture.install(&client).await.unwrap(); + assert!(client.verify_instant_lock(&invalid).await.is_err()); + let checked = client + .verify_instant_lock(valid_fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + let evidence = ReplayEvidence { + instant_locks: [(valid_id, checked)].into(), + }; + + if early { + network + .dispatch_local(NetworkMessage::ISLock(invalid.clone())) + .await; + let seen = receive_until(&mut observed, |n| matches!(n, Notice::Rejected(1))).await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); + } + for send in &sends { + network + .dispatch_local(NetworkMessage::Tx(Box::new(send.clone()))) + .await; + receive_until( + &mut observed, + |n| matches!(n, Notice::Detected(id) if *id == send.txid()), + ) + .await; + } + if !early { + network + .dispatch_local(NetworkMessage::ISLock(invalid)) + .await; + let seen = receive_until(&mut observed, |n| matches!(n, Notice::Rejected(1))).await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); + } + network + .dispatch_local(NetworkMessage::ISLock(valid_fixture.instant_lock().clone())) + .await; + let seen = receive_until( + &mut observed, + |n| matches!(n, Notice::Locked(id) if *id == valid_id), + ) + .await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); + // Both locks traverse the same ordered IS message receiver; the valid wallet + // event is a processing barrier, rather than a sleep asserting absence. + client.stop().await; + persist_events(&manager, &persister, &mut persistence_events).await; + let raw: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_instant_locks WHERE wallet_id=?1 AND txid=?2", + rusqlite::params![wallet_id.as_slice(), invalid_id.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + raw, 0, + "a rejected lock must not produce durable IS evidence" + ); + assert!(matches!( + persister + .get_core_tx_record(wallet_id, &invalid_id) + .unwrap() + .unwrap() + .context, + TransactionContext::Mempool + )); + drop((client, manager, persistence_events, persister)); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened.load_with_replay(&evidence).unwrap(); + let account = &loaded.state.wallets[&wallet_id] + .wallet_info + .accounts + .standard_bip44_accounts[&0]; + assert!(matches!( + account.transactions()[&invalid_id].context, + TransactionContext::Mempool + )); + assert!(!account.utxos[&OutPoint::new(invalid_id, 0)].is_instantlocked); + assert!(account.utxos[&OutPoint::new(valid_id, 0)].is_instantlocked); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index caf4448497f..a66d81f7f21 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -36,7 +36,7 @@ use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo}; use crate::changeset::dashpay_backfill::DashPayBackfillRecord; use crate::changeset::identity_scan_state::IdentityScanStateEntry; -use crate::changeset::wallet_accounting::apply_wallet_accounting; +use crate::changeset::wallet_accounting::{apply_wallet_accounting, has_missing_accounting_inputs}; use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; @@ -460,7 +460,7 @@ impl HighestUsedIndexes { /// - identity fields (`transaction`, `txid`, `transaction_type`, /// `label`, `account_type`) — from the FUNDING record (the one with /// input details) so the row's account attribution names the spender, -/// else the first record. +/// else an incomplete restored funding record, else the first record. /// /// Then EVERY record — folded or single — gets its wallet-level /// `net_amount` and `direction` from its details via @@ -510,11 +510,18 @@ fn fold_groups(records: &mut Vec) { let mut drop_idx: BTreeSet = BTreeSet::new(); let mut folded: BTreeMap = BTreeMap::new(); for group in by_txid.values().filter(|g| g.len() >= 2) { - // Base: the funding record (has input details), else the first. + // Prefer complete funding evidence, then a restored funding record + // whose missing inputs must not be mistaken for a receipt. let base_pos = group .iter() .copied() .find(|&i| !records[i].input_details.is_empty()) + .or_else(|| { + group + .iter() + .copied() + .find(|&i| has_missing_accounting_inputs(&records[i])) + }) .unwrap_or(group[0]); let mut merged = records[base_pos].clone(); let mut net: i64 = 0; @@ -571,9 +578,7 @@ fn fold_groups(records: &mut Vec) { // The funding slice is not necessarily the newest one — a // group can pair a stale `Mempool` sighting with the // confirmed snapshot of the same transaction. - if context_rank(&r.context) > context_rank(&merged.context) { - merged.context = r.context.clone(); - } + merged.context = most_advanced_transaction_context(&merged.context, &r.context).clone(); } // The base record's own index was skipped by the `i != base_pos` // guard above; drop every group member except the fold's output @@ -667,6 +672,21 @@ pub(crate) fn coalesce_account_records(records: &mut Vec) { } } +/// Select the more advanced observation of the same transaction, retaining +/// `current` on a tie. This merges metadata only; it does not verify finality or +/// resolve conflicting blocks. Callers must establish transaction identity and +/// compatible block observations before using it. +pub fn most_advanced_transaction_context<'a>( + current: &'a key_wallet::transaction_checking::TransactionContext, + incoming: &'a key_wallet::transaction_checking::TransactionContext, +) -> &'a key_wallet::transaction_checking::TransactionContext { + if context_rank(incoming) > context_rank(current) { + incoming + } else { + current + } +} + /// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) /// by how far along the confirmation lifecycle the observation is. /// Used by [`fold_same_txid_records`] so a fold never regresses a diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 90f18b08e5a..8ce5c8cc389 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -9,6 +9,7 @@ use std::collections::BTreeMap; use crate::changeset::dashpay_backfill::DashPayBackfillRecord; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; +use crate::changeset::recorded_history::RecordedHistory; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::{OutPoint, Transaction}; use key_wallet::wallet::ManagedWalletInfo; @@ -44,9 +45,7 @@ pub struct ClientWalletStartState { /// runs — dropping the input from `utxos` and recording it in /// `spent_outpoints`. That call is async and needs the `Wallet` and /// the `ManagedWalletInfo` together, so the replay happens at the - /// async boundary in - /// [`load_from_persistor`](crate::manager::load), not while this - /// snapshot is being built. + /// common wallet replay before the backend commits its load effects. /// /// # Why the replay exists /// @@ -60,7 +59,17 @@ pub struct ClientWalletStartState { /// input came back as spendable and the balance re-counted it, /// permanently. Replaying the record at load restores exactly the /// state the live process held. + /// + /// A send that is also in [`recorded_history`](Self::recorded_history) + /// is accounted by the history replay alone; this list then only + /// drives the load-time re-dispatch. pub unconfirmed_outgoing_txs: Vec, + /// The wallet's stored transaction history, replayed at load through the + /// wallet checker so confirmed and unconfirmed spends are guarded again — + /// see [`RecordedHistory`]. Empty when the persister supplies none. + /// + /// Applied by the common wallet replay inside the backend's load scope. + pub recorded_history: RecordedHistory, /// Durable record of the DashPay coreHeight backfill, as the host last /// stored it — which receival contacts the historical rescan already /// covers, from which height each. Empty (the default) when the host diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index eac7510cfd9..a2c91757f0b 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -71,6 +71,7 @@ use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::asset_lock::sync::reconstruction; use crate::wallet::identity::network::sent_payment_status_for_record; use crate::wallet::identity::types::dashpay::payment::{PaymentDirection, PaymentStatus}; +use crate::wallet::identity::PaymentEntry; use crate::wallet::platform_wallet::PlatformWalletInfo; /// Maximum number of `WalletEvent`s folded into a single @@ -1742,17 +1743,11 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Corrections can discover owned inputs or outputs after first observation. + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. - // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the // funding account's row with an incoming/positive @@ -2174,6 +2169,11 @@ fn verdict_for( next_sent_payment_status(entry.status, evidence) } +/// Shared payment verdict when replay retires a conflicting transaction. +pub fn swept_payment_status(entry: &PaymentEntry) -> Option { + verdict_for(entry, SentPaymentEvidence::Swept) +} + /// Rebuild the "addresses marked used" delta plus the post-batch /// highest-used watermarks for the accounts touched by `records`. /// @@ -4058,6 +4058,16 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records.len(), 1); assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.spent_utxos.len(), + 1, + "updated records project recognized inputs" + ); + assert_eq!( + cs.new_utxos.len(), + 1, + "updated records project owned change" + ); } #[tokio::test] diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 11aefd51976..562055385a0 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -21,6 +21,7 @@ pub mod persistence_capabilities; pub mod platform_address_sync_start_state; #[cfg(any(feature = "bls", feature = "eddsa"))] pub mod provider_key_account; +pub mod recorded_history; #[cfg(feature = "serde")] pub mod serde_adapters; #[cfg(feature = "shielded")] @@ -34,19 +35,21 @@ mod wallet_accounting; pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ - upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, - AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, - DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes, - IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, - KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, - PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, - PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, - SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + most_advanced_transaction_context, upsert_pending_contact_crypto, AccountAddressPoolEntry, + AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, DpnsNameSaleStatus, DpnsNameStateChangeSet, + DpnsNameStateEntry, HighestUsedIndexes, IdentityChangeSet, IdentityEntry, + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, InvitationChangeSet, + InvitationEntry, InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, + PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, + PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + ProviderPlatformNodePubKey, ReceivedContactRequestKey, SentContactRequestKey, + TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; +pub use core_bridge::swept_payment_status; pub use core_bridge::{ spawn_wallet_event_adapter, spawn_wallet_event_adapter_with_durable_cursors, DurableCursor, DurableCursorState, DurableCursors, RetiredBarrier, @@ -59,6 +62,7 @@ pub use identity_scan_state::IdentityScanStateEntry; pub use merge::Merge; pub use persistence_capabilities::{PersistenceCapabilities, PERSISTENCE_CAPABILITIES_VERSION}; pub use platform_address_sync_start_state::PlatformAddressSyncStartState; +pub use recorded_history::{RecordedHistory, StoredTransaction}; #[cfg(feature = "shielded")] pub use shielded_changeset::ShieldedChangeSet; #[cfg(not(feature = "shielded"))] @@ -69,4 +73,7 @@ pub use traits::{ persists_outbound_account_markers, ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, }; -pub use wallet_accounting::{is_owned, wallet_accounting, wallet_direction}; +pub use wallet_accounting::{ + funding_txids, history_accounting, is_owned, record_accounting, wallet_accounting, + wallet_direction, HistoryAccounting, WalletAccounting, +}; diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 6c17b550b1e..f19252c9359 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -105,6 +105,16 @@ impl PersistenceCapabilities { /// declaration only when `on_persist_dashpay_payments_fn` is actually /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); + /// Load restores the in-memory spend guards for confirmed AND unconfirmed + /// spends from the wallet's complete stored transaction history: either it + /// hands the history back + /// ([`ClientWalletStartState::recorded_history`](super::ClientWalletStartState::recorded_history)) + /// for the shared load replay, or it replays the history itself before + /// returning. Without it a funding transaction redelivered after restart + /// (rescan, gap-limit rediscovery) re-credits an output a confirmed spend + /// already consumed until the spend is observed again. Diagnostic only: + /// load warns when it is absent and gates nothing. + pub const CORE_HISTORY_RESTORE: Self = Self(1 << 14); /// Asset-lock operation UUIDs survive store/load, including consumed tombstones. /// Missing or malformed persistence data fails load rather than becoming an @@ -117,7 +127,7 @@ impl PersistenceCapabilities { /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 13; + const HIGHEST_DECLARED_BIT: u32 = 14; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -221,6 +231,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, "asset_lock_operation_ids", ), + ( + PersistenceCapabilities::CORE_HISTORY_RESTORE, + "core_history_restore", + ), ]; KNOWN @@ -238,7 +252,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() + PersistenceCapabilities::CORE_HISTORY_RESTORE.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -266,6 +280,7 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); + assert_eq!(PersistenceCapabilities::CORE_HISTORY_RESTORE.bits(), 0x4000); assert_eq!( PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits(), 0x2000 diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs new file mode 100644 index 00000000000..5452f0a1628 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -0,0 +1,135 @@ +//! Stored transaction history a persister hands back at load. +//! +//! The replay that consumes it lives in +//! [`history_replay`](crate::manager::history_replay); this module only +//! carries the persister-agnostic input shape. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::ClientWalletStartState; +use dash_spv::VerifiedInstantLock; +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::{OutPoint, Transaction, TxOut, Txid}; +use dpp::prelude::AssetLockProof; +use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, +}; +use key_wallet::transaction_checking::TransactionContext; + +/// Network evidence prefetched before a backend opens its authoritative snapshot. +#[derive(Debug, Clone, Default)] +pub struct ReplayEvidence { + /// Only the SPV verifier can construct these proofs; stored contexts cannot. + pub instant_locks: BTreeMap, +} + +/// Every stored transaction of one wallet, replayed at load to rebuild spend guards. +/// +/// Persisted UTXO rows say which outputs are unspent, but not which outpoints +/// the wallet has seen spent. Without that in-memory state a redelivered +/// funding transaction (rescan, gap-limit rediscovery) re-credits an output +/// that a confirmed spend already consumed. Replaying the stored history +/// through the wallet checker rebuilds it; the persisted UTXO set stays +/// authoritative for which outputs are credited. +/// +/// Empty means "no history supplied": load keeps the projection as restored. +/// +/// The load keeps the funding of a tracked asset lock at `ChainLocked`, +/// `Consumed` or `RecoveredFromChain` out of the replay when nothing here +/// places it in a block or locks it: its row here, and any unconfirmed record +/// already attached to the restored wallet, which it puts back afterwards. +#[derive(Debug, Clone, Default)] +pub struct RecordedHistory { + /// Persisted InstantSend observations; the owner decides whether their proof is required. + pub required_instant_locks: BTreeSet, + /// Stored coin material, including spent rows; ownership is checked by the wallet. + pub owned_prevouts: BTreeMap, + /// Stored transactions, in any order; the replay orders them. + pub transactions: Vec, + /// Persisted InstantSend locks keyed by the txid they lock. A lock that + /// arrived after its transaction was stored never rewrote the stored + /// context, so the replay upgrades a matching mempool record itself. + pub instant_locks: BTreeMap, +} + +impl RecordedHistory { + /// Whether the persister supplied no history at all. + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + } +} + +/// One stored transaction as its persister keeps it. +#[derive(Debug, Clone)] +pub struct StoredTransaction { + /// The txid the store keys this row under. Kept apart from + /// `transaction` because the two are not proof of each other: a lock is + /// only trusted when both agree with it. + pub txid: Txid, + /// The consensus transaction body. + pub transaction: Transaction, + /// The stored confirmation context. + pub context: TransactionContext, + /// The wallet-level net amount the store holds, when it keeps one. + pub stored_net_amount: Option, + /// The wallet-level direction the store holds, when it keeps one. + pub stored_direction: Option, + /// The wallet-owned inputs the store recorded for this transaction. + /// + /// Load excludes spent outputs, so a spend whose funding survives only as + /// a height row replays with no owned input; these let the replay stage + /// that input and rebuild its spent mark. Empty when the store keeps no + /// per-input ownership: the replay then only keeps the coin of a + /// chain-locked or InstantSend-locked spend from being credited again, + /// without attributing the spend. + /// + /// A store that drops the coin of an unconfirmed spend from the load + /// projection must record that spend's owned inputs here: the replay + /// guards an unconfirmed spend only through a coin it can spend. + pub owned_inputs: Vec, +} + +impl From for StoredTransaction { + fn from(record: TransactionRecord) -> Self { + Self { + txid: record.txid, + stored_net_amount: Some(record.net_amount), + stored_direction: Some(record.direction), + owned_inputs: record.input_details, + transaction: record.transaction, + context: record.context, + } + } +} + +/// Advisory proof candidates from every persisted source of wallet finality. +/// These bytes grant no authority until the SPV verifier and fresh snapshot agree. +pub fn replay_lock_candidates(state: &ClientWalletStartState) -> Vec { + let mut locks: Vec<_> = state + .recorded_history + .instant_locks + .values() + .cloned() + .collect(); + locks.extend( + state + .recorded_history + .transactions + .iter() + .filter_map(|record| match &record.context { + TransactionContext::InstantSend(lock) => Some(lock.clone()), + _ => None, + }), + ); + locks.extend( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter_map(|tracked| match &tracked.proof { + Some(AssetLockProof::Instant(proof)) => Some(proof.instant_lock().clone()), + _ => None, + }), + ); + locks +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 11e5d2c58a7..bbc28ccad46 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -8,8 +8,11 @@ use std::error::Error as StdError; use crate::changeset::changeset::{DpnsNameStateEntry, PlatformWalletChangeSet}; use crate::changeset::client_start_state::ClientStartState; use crate::changeset::persistence_capabilities::PersistenceCapabilities; +use crate::changeset::recorded_history::ReplayEvidence; +use crate::changeset::wallet_accounting::WalletAccounting; +use crate::manager::history_replay::HistoryReplayLoad; use crate::wallet::platform_wallet::WalletId; -use dashcore::Txid; +use dashcore::{Transaction, Txid}; use dpp::prelude::Identifier; use key_wallet::managed_account::transaction_record::TransactionRecord; @@ -447,6 +450,55 @@ pub trait PlatformWalletPersistence: Send + Sync { /// wallet attribution where needed. fn load(&self) -> Result; + /// Read startup data without settling history; active wallets may share this backend. + fn load_read_snapshot(&self) -> Result { + self.load() + } + + /// Restore with prefetched evidence inside the backend's atomic scope. + /// + /// The default supplies raw history for the common owner; it does not claim + /// that replay effects were committed. Backends override this only when + /// reading and applying effects share one transaction. + fn load_with_replay( + &self, + _evidence: &ReplayEvidence, + ) -> Result { + Ok(HistoryReplayLoad { + state: self.load()?, + outcomes: Default::default(), + }) + } + + /// Restore one wallet under the manager's publication fence, including explicit retries. + /// + /// The default reads raw history without applying effects. The manager rejects + /// histories requiring durable repair unless this method returns a committed outcome. + /// Transactional backends override this method as well as `load_with_replay`. + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + _evidence: &ReplayEvidence, + ) -> Result { + let mut state = self.load_read_snapshot()?; + state.wallets.retain(|id, _| *id == wallet_id); + state.platform_addresses.retain(|id, _| *id == wallet_id); + Ok(HistoryReplayLoad { + state, + outcomes: Default::default(), + }) + } + + /// Advisory lock candidates; callers must rebind verified proof in a fresh snapshot. + fn replay_lock_candidates(&self) -> Result, PersistenceError> { + Ok(self + .load_read_snapshot()? + .wallets + .values() + .flat_map(super::recorded_history::replay_lock_candidates) + .collect()) + } + /// Look up a single core transaction record by `txid` for `wallet_id`. /// /// Used by the asset-lock proof flow to recover records that the @@ -494,6 +546,28 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } + /// The body of a stored Core transaction, for the accounting backfill. + /// + /// Stricter than [`Self::get_core_tx_record`], whose policy suits the + /// asset-lock and DashPay callers that retry a miss anyway: here + /// `Ok(None)` must mean only "this backend does not store `txid`". A + /// stored transaction is returned in any context, InstantSend included, + /// and a stored one that cannot be read (a failing backend, an + /// undecodable body) is an `Err`. The backfill then fails its round and + /// runs again at the next load, rather than reading a wallet-owned input + /// as someone else's. + /// + /// The default delegates to [`Self::get_core_tx_record`]. + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + Ok(self + .get_core_tx_record(wallet_id, txid)? + .map(|record| record.transaction)) + } + /// Enumerate the persisted Core transaction ids that belong to /// `wallet_id`, each tagged with whether the transaction spends an /// input this wallet funded. @@ -528,6 +602,49 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } + /// The wallet's stored Core transactions that have no + /// [`WalletAccounting`] yet, or `None` once this wallet's history + /// backfill has completed (see [`Self::store_history_accounting`]). + /// + /// Read at every load of the wallet until the backfill completes. The + /// manager computes the missing rows with + /// [`history_accounting`](crate::changeset::history_accounting), + /// fetching the funding transactions it needs through + /// [`Self::get_core_transaction`] itself, and hands them back through + /// [`Self::store_history_accounting`]. Any subset is safe: a transaction + /// left out only stays without a row. + /// + /// A transaction without a row is "not yet accounted", never "accounted + /// as nothing": the host displays it without an amount or direction + /// until a backfill or a live update writes the row. + /// + /// The default (`None`) suits backends that never stored rows without + /// accounting, such as the SQLite store, whose own migration repairs + /// history. + fn load_core_history_for_accounting( + &self, + _wallet_id: WalletId, + ) -> Result>, PersistenceError> { + Ok(None) + } + + /// Persist the backfilled rows (upsert keyed by `(txid, wallet_id)`) + /// and, when `complete`, record that this wallet's history backfill is + /// done, as one atomic write; `rows` may be empty. After a complete + /// write, [`Self::load_core_history_for_accounting`] returns `None` for + /// the wallet. An incomplete one (some transaction could not be + /// accounted yet) leaves the backfill open, so the next load reads the + /// transactions still without a row and tries again. The default drops + /// both. + fn store_history_accounting( + &self, + _wallet_id: WalletId, + _rows: &[WalletAccounting], + _complete: bool, + ) -> Result<(), PersistenceError> { + Ok(()) + } + /// Look up the persisted DPNS marketplace row for one /// `(wallet_id, wallet_identity_id, normalized_label)` triple. /// diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 48ae44a914a..3299385ddf5 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -1,11 +1,16 @@ //! Wallet-level accounting of a Core transaction record: the ownership //! predicate ([`is_owned`]), the direction rule ([`wallet_direction`]) and //! the net formula ([`wallet_accounting`]). The live projection -//! (`fold_same_txid_records`) and the SQLite repair -//! (`platform-wallet-storage`'s `core_history`) both call them, so a row +//! (`fold_same_txid_records`), the per-wallet rows handed to hosts +//! ([`record_accounting`]), the host-history backfill +//! ([`history_accounting`]) and the SQLite repair +//! (`platform-wallet-storage`'s `core_history`) all call them, so a row //! cannot change accounting when storage repairs it; they differ only on //! an `i64` overflow (live saturates, repair errors). //! +//! Hosts (the Swift and Kotlin SDKs) persist [`WalletAccounting`] keyed by +//! `(txid, wallet id)` and display it; they never derive it themselves. +//! //! Upstream `key-wallet` classifies each matched account on its own, and //! its account-local views disagree for an asset lock: the funding //! account reads a lock with no change as `Outgoing` (the OP_RETURN burn @@ -14,12 +19,32 @@ //! moves Core duffs into its own Platform credits, so only the fee //! leaves the wallet. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use dashcore::blockdata::script::Instruction; +use dashcore::hashes::hash160; +use dashcore::{Address, OutPoint, PubkeyHash, ScriptBuf, Transaction, TxIn, Txid}; +use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_ref::ManagedAccountRef; +use key_wallet::managed_account::managed_core_funds_account::ManagedCoreFundsAccount; use key_wallet::managed_account::transaction_record::{ OutputRole, TransactionDirection, TransactionRecord, }; -use key_wallet::transaction_checking::transaction_router::TransactionType; +use key_wallet::transaction_checking::transaction_router::{TransactionRouter, TransactionType}; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + +/// One transaction's Core accounting from one wallet's point of view: the +/// value a host persists keyed by `(txid, wallet id)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WalletAccounting { + pub txid: Txid, + /// `Σ owned outputs − Σ owned inputs` in duffs, saturated to `i64`. + pub net_amount: i64, + pub direction: TransactionDirection, + /// The transaction fee, when the wallet funded every input; otherwise + /// the wallet cannot know it. + pub fee: Option, +} /// Classify a transaction from the wallet's point of view. /// @@ -30,11 +55,8 @@ use key_wallet::transaction_checking::transaction_router::TransactionType; /// /// An asset lock is `Internal` whenever nothing leaves the wallet, even /// with no change output: its OP_RETURN value becomes the wallet's own -/// Platform credits. The Swift SDK's -/// `PersistentTransaction.reconciledAccounting` applies the same rule; -/// keep them in step. The case table -/// shared with Swift lives in `platform-wallet-storage` -/// (`should_classify_repaired_direction_like_the_swift_sdk`). +/// Platform credits. The case table lives in `platform-wallet-storage` +/// (`should_classify_repaired_direction_like_the_live_projection`). pub fn wallet_direction( transaction_type: TransactionType, spends_ours: bool, @@ -52,23 +74,260 @@ pub fn wallet_direction( } } +/// A funding account's outgoing/internal direction requires owned inputs. +/// Restore can retain that direction and held outputs without those inputs; +/// keys-account markers carry no Core funding evidence either way. +pub(crate) fn has_missing_accounting_inputs(record: &TransactionRecord) -> bool { + matches!( + record.account_type, + AccountType::Standard { .. } + | AccountType::CoinJoin { .. } + | AccountType::DashpayReceivingFunds { .. } + ) && record.input_details.is_empty() + && matches!( + record.direction, + TransactionDirection::Outgoing | TransactionDirection::Internal + ) +} + +fn has_accounting_evidence(record: &TransactionRecord) -> bool { + !(record.input_details.is_empty() && record.output_details.is_empty()) + && !has_missing_accounting_inputs(record) +} + /// Recompute a record's wallet-level `net_amount` and `direction` from /// its input and output details, saturating a net that does not fit `i64`. /// /// A record with no details carries no accounting evidence (a keys-account /// marker that met no funding slice), so it is left as upstream emitted -/// it. The SQLite repair skips such records for the same reason. +/// it. A restored funding record without its owned inputs is also left +/// alone: its held outputs prove coin ownership, not complete accounting. pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { - if record.input_details.is_empty() && record.output_details.is_empty() { + if !has_accounting_evidence(record) { return; } let (net, direction) = wallet_accounting(record); - // Unreachable for real amounts (the whole supply fits i64 many times - // over); saturate rather than panic on a corrupt record. - record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); + record.net_amount = saturating_net(net); record.direction = direction; } +/// The wallet-level accounting `record` carries, or `None` when it carries +/// none. +/// +/// A record with no input or output details is no evidence of what the +/// transaction did to the wallet: a keys-account marker, or a record the +/// load path rebuilt from persisted bytes alone (an asset lock's funding +/// transaction, a spender of its input) and re-emits when a chain lock +/// promotes it. Such a record's `net_amount` is a placeholder, so the host +/// keeps whatever accounting it already stored. Restored outgoing/internal +/// funding records may name held outputs while still lacking their inputs; +/// those outputs do not turn the incomplete funding record into a receipt. +pub fn record_accounting(record: &TransactionRecord) -> Option { + if !has_accounting_evidence(record) { + return None; + } + let accounting = record_parts(record); + Some(WalletAccounting { + txid: record.txid, + net_amount: saturating_net(accounting.net), + direction: accounting.direction, + fee: accounting.fee, + }) +} + +/// The txids whose transactions [`history_accounting`] needs, beyond +/// `unaccounted` itself, to resolve the inputs of `unaccounted`: every +/// prevout that is neither one of those transactions' outputs nor one of the +/// wallet's restored UTXOs. Some of them are foreign and will not be found; +/// that is expected. +pub fn funding_txids(wallet: &ManagedWalletInfo, unaccounted: &[Transaction]) -> BTreeSet { + let own: BTreeSet = unaccounted.iter().map(Transaction::txid).collect(); + let utxos: BTreeSet = wallet_funds(wallet) + .flat_map(|funds| funds.utxos.keys().copied()) + .collect(); + unaccounted + .iter() + .flat_map(|transaction| &transaction.input) + .map(|input| input.previous_output) + .filter(|prevout| { + !prevout.is_null() && !own.contains(&prevout.txid) && !utxos.contains(prevout) + }) + .map(|prevout| prevout.txid) + .collect() +} + +/// Accounting for each of `unaccounted`, a host's stored transactions that +/// have no [`WalletAccounting`] yet, from the wallet's own view of which +/// scripts it owns. The live projection's rule, recomputed for rows a host +/// stored before it kept per-wallet accounting: +/// - an output is owned when its script belongs to one of the wallet's funds +/// accounts other than a contact's watch-only chain +/// ([`AccountType::is_contact_owned`](key_wallet::account::AccountType::is_contact_owned)); +/// an address-less, provably unspendable output is `Unspendable`, as +/// key-wallet classifies it; +/// - an input is owned when its prevout is an owned output of `unaccounted`, +/// of `funding` (the stored transactions [`funding_txids`] named), or one +/// of the wallet's restored UTXOs; +/// - type, net, direction and fee follow [`record_accounting`]. +/// +/// A transaction that touches no owned script yields no row, like a +/// detail-less record. +/// +/// An input whose prevout none of the above resolves is either someone +/// else's coin (every input of a genuine receipt) or the wallet's own coin +/// whose funding transaction the host could not supply. The two are told +/// apart by the input's redeem: when its scriptSig ends with a public key +/// whose P2PKH script the wallet owns, the wallet signed it. Such a +/// transaction gets no row and is listed in +/// [`HistoryAccounting::unresolved`] instead: reading the input as foreign +/// would turn a payment with change into an `Incoming` receipt of the +/// change. An input redeemed any other way reads as foreign; the wallet's +/// funds accounts hold only P2PKH scripts, so its own coins are always +/// redeemed with a public key. +/// +/// A transaction left without a row is not yet accounted, and the host +/// shows it as such until a later backfill (or the live projection) writes +/// it. Any subset of a host's unaccounted transactions is therefore safe to +/// pass. +pub fn history_accounting( + wallet: &ManagedWalletInfo, + unaccounted: &[Transaction], + funding: &[Transaction], +) -> HistoryAccounting { + let mut owned_scripts: BTreeSet = BTreeSet::new(); + let mut prevouts: BTreeMap = BTreeMap::new(); + for funds in wallet_funds(wallet) { + owned_scripts.extend(ManagedAccountRef::Funds(funds).all_script_pubkeys()); + for (outpoint, utxo) in &funds.utxos { + prevouts.insert(*outpoint, utxo.txout.value); + } + } + for transaction in unaccounted.iter().chain(funding) { + let txid = transaction.txid(); + for (vout, output) in transaction.output.iter().enumerate() { + if owned_scripts.contains(&output.script_pubkey) { + prevouts.insert( + OutPoint { + txid, + vout: vout as u32, + }, + output.value, + ); + } + } + } + + let mut accounted = HistoryAccounting::default(); + for transaction in unaccounted { + let mut spent: Vec = Vec::new(); + let mut owned_but_unresolved = false; + for input in &transaction.input { + match prevouts.get(&input.previous_output) { + Some(value) => spent.push(*value), + None if !input.previous_output.is_null() => { + owned_but_unresolved |= p2pkh_redeemed_script(input) + .is_some_and(|script| owned_scripts.contains(&script)); + } + None => {} + } + } + if owned_but_unresolved { + accounted.unresolved.push(transaction.txid()); + continue; + } + let owned: Vec<(usize, u64)> = transaction + .output + .iter() + .enumerate() + .filter(|(_, output)| owned_scripts.contains(&output.script_pubkey)) + .map(|(index, output)| (index, output.value)) + .collect(); + if spent.is_empty() && owned.is_empty() { + continue; + } + let unspendable: BTreeSet = transaction + .output + .iter() + .enumerate() + .filter(|(_, output)| { + Address::from_script(&output.script_pubkey, wallet.network).is_err() + && output.script_pubkey.is_provably_unspendable() + }) + .map(|(index, _)| index) + .collect(); + let accounting = accounting( + transaction, + TransactionRouter::classify_transaction(transaction), + &spent, + &owned, + &unspendable, + ); + accounted.rows.push(WalletAccounting { + txid: transaction.txid(), + net_amount: saturating_net(accounting.net), + direction: accounting.direction, + fee: accounting.fee, + }); + } + accounted +} + +/// What [`history_accounting`] made of a host's unaccounted transactions. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryAccounting { + /// One row per transaction it could account for. + pub rows: Vec, + /// Transactions left without a row because an input spends a coin the + /// wallet signed for but could not resolve. A later backfill retries + /// them. + pub unresolved: Vec, +} + +impl HistoryAccounting { + /// Whether every transaction that touches the wallet got its row, so the + /// backfill may be recorded as complete. + pub fn is_complete(&self) -> bool { + self.unresolved.is_empty() + } +} + +/// The P2PKH script `input` redeems, when its scriptSig ends with a public +/// key push (` `, how a P2PKH output is spent). `None` for any +/// other redeem. +fn p2pkh_redeemed_script(input: &TxIn) -> Option { + let Instruction::PushBytes(pubkey) = input.script_sig.instructions().last()?.ok()? else { + return None; + }; + let pubkey = pubkey.as_bytes(); + matches!(pubkey.len(), 33 | 65) + .then(|| ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(hash160::Hash::hash(pubkey)))) +} + +/// The wallet's funds accounts, without contacts' watch-only chains. +fn wallet_funds(wallet: &ManagedWalletInfo) -> impl Iterator + '_ { + wallet + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| match account { + ManagedAccountRef::Funds(funds) + if !account + .managed_account_type() + .to_account_type() + .is_contact_owned() => + { + Some(funds) + } + _ => None, + }) +} + +/// Unreachable for real amounts (the whole supply fits i64 many times over); +/// saturate rather than panic on a corrupt record. +fn saturating_net(net: i128) -> i64 { + i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }) +} + /// Wallet-level net amount and direction of `record`, from its details. /// /// The net is `Σ owned outputs − Σ owned inputs`: the change in the @@ -77,41 +336,548 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { /// nets `−(credit + fee)`. The net is returned as `i128` so each caller /// picks its own policy for a value outside `i64`. pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirection) { - let owned: i128 = record + let accounting = record_parts(record); + (accounting.net, accounting.direction) +} + +/// What [`accounting`] computes, before an `i64` policy is applied to `net`. +struct Accounting { + net: i128, + direction: TransactionDirection, + fee: Option, +} + +/// [`accounting`] over a record's details. +fn record_parts(record: &TransactionRecord) -> Accounting { + let spent: Vec = record.input_details.iter().map(|d| d.value).collect(); + let owned: Vec<(usize, u64)> = record .output_details .iter() .filter(|d| is_owned(d.role)) - .map(|d| i128::from(d.value)) - .sum(); - let spent: i128 = record - .input_details - .iter() - .map(|d| i128::from(d.value)) - .sum(); - let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); - // Indexed once so the per-output check below stays linear. - let accounted: BTreeSet = record + .map(|d| (d.index as usize, d.value)) + .collect(); + let unspendable: BTreeSet = record .output_details .iter() - .filter(|d| is_owned(d.role) || d.role == OutputRole::Unspendable) + .filter(|d| d.role == OutputRole::Unspendable) .map(|d| d.index as usize) .collect(); - let has_external = record - .transaction + accounting( + &record.transaction, + record.transaction_type, + &spent, + &owned, + &unspendable, + ) +} + +/// The net formula, direction rule and fee over a transaction's owned +/// inputs (`spent` values) and owned outputs (`(index, value)`); +/// `unspendable` outputs leave nothing the wallet could lose, like an +/// OP_RETURN. The fee is known only when every input is an owned one. +fn accounting( + transaction: &Transaction, + transaction_type: TransactionType, + spent: &[u64], + owned: &[(usize, u64)], + unspendable: &BTreeSet, +) -> Accounting { + let received: i128 = owned.iter().map(|(_, value)| i128::from(*value)).sum(); + let paid: i128 = spent.iter().map(|value| i128::from(*value)).sum(); + // Indexed once so the per-output check below stays linear. + let accounted: BTreeSet = owned + .iter() + .map(|(index, _)| *index) + .chain(unspendable.iter().copied()) + .collect(); + let has_external = transaction .output .iter() .enumerate() .any(|(index, output)| !output.script_pubkey.is_op_return() && !accounted.contains(&index)); let direction = wallet_direction( - record.transaction_type, - !record.input_details.is_empty(), - has_ours, + transaction_type, + !spent.is_empty(), + !owned.is_empty(), has_external, ); - (owned - spent, direction) + let fee = (!spent.is_empty() && spent.len() == transaction.input.len()) + .then(|| { + let funded = spent + .iter() + .try_fold(0u64, |sum, value| sum.checked_add(*value))?; + let paid_out = transaction + .output + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.value))?; + funded.checked_sub(paid_out) + }) + .flatten(); + Accounting { + net: received - paid, + direction, + fee, + } } /// Whether an output with this role belongs to the wallet. pub fn is_owned(role: OutputRole) -> bool { matches!(role, OutputRole::Received | OutputRole::Change) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::changeset::fold_same_txid_records; + use crate::test_support::core_fixtures::{ + block, foreign_address, outside, pay, records_of, spend_of, wallet_with_receive_address, + }; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::special_transaction::TransactionPayload; + use dashcore::{Network, TxOut}; + use key_wallet::account::{Account, AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::PublicKeyType; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Wallet; + + const FUNDING: u64 = 100_000; + const PAID: u64 = 30_000; + const CHANGE: u64 = 69_000; + const FEE: u64 = FUNDING - PAID - CHANGE; + /// A CoinJoin denomination (0.001 DASH + fee). + const DENOMINATION: u64 = 100_001; + + /// A payment from outside into the wallet, then `spend` of that coin, + /// both seen by the live projection. Returns the wallet as it stands + /// afterwards, the live rows, and the two transactions. + async fn live( + spend: impl FnOnce(OutPoint, &Address) -> Transaction, + ) -> ( + ManagedWalletInfo, + Vec, + Transaction, + Transaction, + ) { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let change = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_change_address(Some(&xpub), true) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let spend = spend(OutPoint::new(funding.txid(), 0), &change); + let mut rows = Vec::new(); + for (tx, height) in [(&funding, 100), (&spend, 101)] { + let mut records = records_of(&mut info, &mut wallet, tx, block(height)).await; + fold_same_txid_records(&mut records); + rows.extend(records.iter().filter_map(record_accounting)); + } + (info, rows, funding, spend) + } + + fn payment_with_change(coin: OutPoint, change: &Address) -> Transaction { + spend_of( + &[coin], + vec![pay(PAID, &foreign_address(9)), pay(CHANGE, change)], + ) + } + + /// The backfill must write exactly what the live projection emitted for + /// the same transactions: amount, direction AND fee. + async fn assert_backfill_matches_live( + spend: impl FnOnce(OutPoint, &Address) -> Transaction, + ) -> Vec { + let (info, live, funding, spend) = live(spend).await; + assert_eq!(live.len(), 2, "the live projection accounts for both"); + let backfilled = history_accounting(&info, &[funding, spend], &[]).rows; + assert_eq!(backfilled, live); + backfilled + } + + #[tokio::test] + async fn should_backfill_a_payment_with_change_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(payment_with_change).await; + + assert_eq!(rows[0].net_amount, FUNDING as i64); + assert_eq!(rows[0].fee, None, "a foreign input hides the fee"); + assert_eq!(rows[1].net_amount, CHANGE as i64 - FUNDING as i64); + assert_eq!(rows[1].direction, TransactionDirection::Outgoing); + assert_eq!(rows[1].fee, Some(FEE), "the wallet funded every input"); + } + + /// An asset lock with no change: the OP_RETURN burn becomes the wallet's + /// own Platform credits, so it is `Internal`, and the Core net is the + /// whole coin. + #[tokio::test] + async fn should_backfill_a_no_change_asset_lock_exactly_like_the_live_projection() { + let credit = FUNDING - FEE; + let rows = assert_backfill_matches_live(|coin, _| { + let mut lock = spend_of( + &[coin], + vec![TxOut { + value: credit, + script_pubkey: ScriptBuf::new_op_return(&[]), + }], + ); + lock.version = 3; + lock.special_transaction_payload = + Some(TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![pay(credit, &foreign_address(5))], + })); + lock + }) + .await; + + assert_eq!(rows[1].net_amount, -(FUNDING as i64)); + assert_eq!(rows[1].direction, TransactionDirection::Internal); + assert_eq!(rows[1].fee, Some(FEE)); + } + + /// An address-less, provably unspendable output that is not an OP_RETURN + /// takes nothing out of the wallet either: a self-transfer stays + /// `Internal`, as key-wallet's `Unspendable` role makes it live. + #[tokio::test] + async fn should_backfill_an_unspendable_output_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(|coin, change| { + spend_of( + &[coin], + vec![ + pay(CHANGE, change), + TxOut { + value: 0, + // OP_VERIF: an illegal opcode, so provably unspendable. + script_pubkey: ScriptBuf::from_bytes(vec![0x65]), + }, + ], + ) + }) + .await; + + assert_eq!(rows[1].direction, TransactionDirection::Internal); + } + + #[tokio::test] + async fn should_backfill_a_coinjoin_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(|coin, change| { + spend_of( + &[coin, outside(1), outside(2)], + vec![ + pay(DENOMINATION, change), + pay(DENOMINATION, &foreign_address(3)), + pay(DENOMINATION, &foreign_address(4)), + ], + ) + }) + .await; + + assert_eq!(rows[1].direction, TransactionDirection::CoinJoin); + assert_eq!(rows[1].fee, None, "other participants funded inputs"); + } + + /// A spend's coin resolves from a funding transaction passed in + /// `funding`; only `unaccounted` transactions get a row. + #[tokio::test] + async fn should_resolve_inputs_from_funding_transactions_and_answer_only_unaccounted() { + let (info, live, funding, spend) = live(payment_with_change).await; + + let backfilled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]).rows; + + assert_eq!(backfilled, vec![live[1]]); + } + + /// A spend whose funding transaction the host no longer has still + /// resolves while its coin is a restored UTXO. + #[tokio::test] + async fn should_resolve_an_input_from_a_restored_utxo_alone() { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + records_of(&mut info, &mut wallet, &funding, block(100)).await; + let spend = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(FUNDING - FEE, &foreign_address(9))], + ); + + let rows = history_accounting(&info, std::slice::from_ref(&spend), &[]).rows; + + assert_eq!( + rows, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -(FUNDING as i64), + direction: TransactionDirection::Outgoing, + fee: Some(FEE), + }] + ); + } + + /// The manager fetches only prevouts nothing else resolves: not the + /// unaccounted transactions' own outputs, not restored UTXOs, not a + /// coinbase's null prevout. + #[tokio::test] + async fn should_name_only_the_funding_transactions_nothing_else_resolves() { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let utxo_funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + records_of(&mut info, &mut wallet, &utxo_funding, block(100)).await; + let chained = spend_of(&[outside(8)], vec![pay(FUNDING, &foreign_address(1))]); + let spend = spend_of( + &[ + OutPoint::new(utxo_funding.txid(), 0), + OutPoint::new(chained.txid(), 0), + outside(6), + OutPoint::null(), + ], + vec![pay(1, &foreign_address(2))], + ); + + let needed = funding_txids(&info, &[chained.clone(), spend]); + + assert_eq!( + needed, + BTreeSet::from([outside(6).txid, outside(8).txid]), + "`chained` is itself unaccounted and the UTXO is restored" + ); + } + + #[tokio::test] + async fn should_yield_no_row_for_a_transaction_touching_no_owned_script() { + let (info, _, _, _) = live(payment_with_change).await; + let unrelated = spend_of(&[outside(3)], vec![pay(5_000, &foreign_address(4))]); + + assert_eq!( + history_accounting(&info, &[unrelated], &[]), + HistoryAccounting::default() + ); + } + + /// An output on a contact's watch-only chain is the contact's money, + /// not the wallet's: paying it is outgoing, the same rule the live + /// projection applies by dropping the contact account's slice. + #[tokio::test] + async fn should_not_count_a_contacts_watch_only_output_as_owned() { + let (_, mut info, receive) = wallet_with_receive_address(); + let contact_wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let contact_xpub = contact_wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let account = Account::new( + None, + AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [1; 32], + friend_identity_id: [2; 32], + }, + contact_xpub, + Network::Testnet, + ) + .unwrap(); + let contact_account = ManagedCoreFundsAccount::from_account(&account); + let contact_address = ManagedAccountRef::Funds(&contact_account) + .all_addresses() + .into_iter() + .next() + .expect("the contact chain derives addresses"); + info.accounts + .insert_funds_bearing_account(contact_account) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let to_contact = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(FUNDING - FEE, &contact_address)], + ); + + let rows = history_accounting(&info, std::slice::from_ref(&to_contact), &[funding]).rows; + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].net_amount, -(FUNDING as i64)); + assert_eq!(rows[0].direction, TransactionDirection::Outgoing); + } + + /// `tx` with input `index` redeemed the P2PKH way, ` `. + fn signed_by(mut tx: Transaction, index: usize, pubkey: &[u8]) -> Transaction { + let signature = [0x30; 71]; + tx.input[index].script_sig = dashcore::script::Builder::new() + .push_slice(<&dashcore::script::PushBytes>::try_from(&signature[..]).unwrap()) + .push_slice(<&dashcore::script::PushBytes>::try_from(pubkey).unwrap()) + .into_script(); + tx + } + + /// The compressed public key behind one of the wallet's own addresses. + fn owned_pubkey(info: &ManagedWalletInfo, address: &Address) -> Vec { + let account = &info.accounts.standard_bip44_accounts[&0]; + match ManagedAccountRef::Funds(account) + .get_address_info(address) + .and_then(|info| info.public_key) + { + Some(PublicKeyType::ECDSA(bytes)) => bytes, + other => panic!("the wallet derived {address} from an ECDSA key, got {other:?}"), + } + } + + /// A spend signed by the wallet's own key whose coin the host no longer + /// holds (funding transaction gone, no longer a UTXO) cannot be accounted: + /// reading the input as foreign would turn a payment with change into + /// an `Incoming` receipt of the change. It gets no row and is reported + /// unresolved, and accounts normally once the funding body is passed. + #[test] + fn should_withhold_the_row_of_a_spend_whose_owned_input_cannot_be_resolved() { + let (wallet, mut info, receive) = wallet_with_receive_address(); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let change = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_change_address(Some(&xpub), true) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let spend = signed_by( + payment_with_change(OutPoint::new(funding.txid(), 0), &change), + 0, + &owned_pubkey(&info, &receive), + ); + + let missing = history_accounting(&info, std::slice::from_ref(&spend), &[]); + assert_eq!( + missing, + HistoryAccounting { + rows: Vec::new(), + unresolved: vec![spend.txid()], + } + ); + assert!(!missing.is_complete()); + + let filled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]); + assert_eq!( + filled.rows, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: CHANGE as i64 - FUNDING as i64, + direction: TransactionDirection::Outgoing, + fee: Some(FEE), + }] + ); + assert!(filled.is_complete()); + } + + /// A genuine receipt's inputs are someone else's and never resolve; that + /// must not hold back its row. + #[test] + fn should_still_account_a_receipt_whose_foreign_inputs_cannot_be_resolved() { + let (_, info, receive) = wallet_with_receive_address(); + let mut foreign_key = vec![0x02]; + foreign_key.extend([7u8; 32]); + let receipt = signed_by( + spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]), + 0, + &foreign_key, + ); + + let accounted = history_accounting(&info, std::slice::from_ref(&receipt), &[]); + + assert_eq!( + accounted, + HistoryAccounting { + rows: vec![WalletAccounting { + txid: receipt.txid(), + net_amount: FUNDING as i64, + direction: TransactionDirection::Incoming, + fee: None, + }], + unresolved: Vec::new(), + } + ); + } + + #[test] + fn should_keep_stored_accounting_when_restored_change_is_folded() { + use key_wallet::managed_account::transaction_record::OutputDetail; + + let tx = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let restored = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + block(10), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + vec![OutputDetail { + index: 0, + role: OutputRole::Change, + address: Some(foreign_address(2)), + value: 5_000, + }], + 0, + ); + let mut receiving_slice = restored.clone(); + receiving_slice.account_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + receiving_slice.direction = TransactionDirection::Incoming; + receiving_slice.net_amount = 5_000; + receiving_slice.output_details[0].role = OutputRole::Received; + let mut restored_without_change = restored.clone(); + restored_without_change.output_details.clear(); + for mut records in [ + vec![restored.clone()], + vec![restored.clone(), receiving_slice.clone()], + vec![receiving_slice.clone(), restored.clone()], + vec![receiving_slice.clone(), restored_without_change], + ] { + fold_same_txid_records(&mut records); + assert_eq!(records.len(), 1); + assert_eq!( + record_accounting(&records[0]), + None, + "a funding record missing its inputs cannot turn held change into income" + ); + assert_eq!(records[0].output_details.len(), 1); + assert!(is_owned(records[0].output_details[0].role)); + } + + // A keys-only asset-lock marker says nothing about who funded it. + // It must not suppress a genuine receipt from another wallet. + let mut keys_marker = restored; + keys_marker.account_type = AccountType::IdentityRegistration; + keys_marker.output_details.clear(); + for mut records in [ + vec![keys_marker.clone(), receiving_slice.clone()], + vec![receiving_slice, keys_marker], + ] { + fold_same_txid_records(&mut records); + let accounting = record_accounting(&records[0]).expect("receipt accounting"); + assert_eq!(accounting.direction, TransactionDirection::Incoming); + assert_eq!(accounting.net_amount, 5_000); + } + } + + /// A record rebuilt at load from bytes alone (an asset lock's funding + /// transaction) is re-emitted on chain-lock promotion with a placeholder + /// net of 0: it must reach the host as "no accounting", not as 0. + #[test] + fn should_report_no_accounting_for_a_record_without_details() { + let tx = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + block(10), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + + assert_eq!(record_accounting(&record), None); + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_authorization.rs b/packages/rs-platform-wallet/src/manager/history_authorization.rs new file mode 100644 index 00000000000..53888bc6e2e --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_authorization.rs @@ -0,0 +1,397 @@ +//! Pure owner-authorization planning for a selected portion of stored history. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::{OutPoint, TxOut, Txid}; +use key_wallet::transaction_checking::spend_authorization::{ + verify_input_authorization, SpendAuthorizationError, +}; + +use crate::changeset::StoredTransaction; + +/// Rejected history and the claims retained by every surviving transaction. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct HistoryAuthorization { + /// Candidate transactions with a demonstrably invalid owner authorization. + pub invalid_owner: BTreeSet, + /// Invalid owners and their descendants within the candidate subgraph. + pub rejected: BTreeSet, + /// All surviving claimants, including transactions outside the candidate set. + pub surviving_claimants: BTreeMap>, +} + +/// Plan authorization repair without interpreting stored finality or changing state. +/// +/// The caller selects candidates after resolving finality, supplies wallet-owned +/// prevouts, and validates that every `owned_inputs` annotation belongs to this +/// wallet. An annotation is an ownership hint, never proof of authorization. +/// Canonical funding bodies take precedence over remembered prevouts and hints; +/// a missing output in a present funding body is an invalid claim. +/// +/// All identifiers are computed from transaction bodies, independently of stored +/// row keys. Unsupported authorization returns an error so callers can preserve +/// existing holds instead of treating unavailable verification as rejection. +pub(super) fn plan_history_authorization( + records: &[StoredTransaction], + candidates: &BTreeSet, + owned_prevouts: &BTreeMap, +) -> Result { + let records: Vec<_> = records + .iter() + .map(|record| (record.transaction.txid(), record)) + .collect(); + let funding: BTreeMap<_, _> = records + .iter() + .map(|(txid, record)| (*txid, &record.transaction)) + .collect(); + let mut result = HistoryAuthorization::default(); + let mut children: BTreeMap> = BTreeMap::new(); + + for (txid, record) in &records { + if !candidates.contains(txid) { + continue; + } + for (index, input) in record.transaction.input.iter().enumerate() { + let outpoint = input.previous_output; + children.entry(outpoint.txid).or_default().insert(*txid); + let annotation = record + .owned_inputs + .iter() + .find(|detail| usize::try_from(detail.index) == Ok(index)); + let remembered = owned_prevouts.get(&outpoint); + if remembered.is_none() && annotation.is_none() { + continue; + } + let fallback; + let prevout = if let Some(parent) = funding.get(&outpoint.txid) { + let Some(output) = usize::try_from(outpoint.vout) + .ok() + .and_then(|vout| parent.output.get(vout)) + else { + result.invalid_owner.insert(*txid); + continue; + }; + output + } else if let Some(remembered) = remembered { + remembered + } else if let Some(annotation) = annotation { + fallback = TxOut { + value: annotation.value, + script_pubkey: annotation.address.script_pubkey(), + }; + &fallback + } else { + continue; + }; + if !verify_input_authorization(&record.transaction, index, prevout)? { + result.invalid_owner.insert(*txid); + } + } + } + + result.rejected = result.invalid_owner.clone(); + let mut pending = result.invalid_owner.clone(); + while let Some(parent) = pending.pop_first() { + if let Some(descendants) = children.get(&parent) { + for child in descendants { + if result.rejected.insert(*child) { + pending.insert(*child); + } + } + } + } + for (txid, record) in records { + if !result.rejected.contains(&txid) { + for input in &record.transaction.input { + result + .surviving_claimants + .entry(input.previous_output) + .or_default() + .insert(txid); + } + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::crypto::ecdsa::EcdsaSecretKey; + use dashcore::hashes::hash160; + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use dashcore::{Address, Network, ScriptBuf, Transaction, TxIn}; + use key_wallet::managed_account::transaction_record::InputDetail; + use key_wallet::transaction_checking::TransactionContext; + + fn coin(key: u8) -> TxOut { + let key = EcdsaSecretKey::from_bytes(&[key; 32]).unwrap(); + TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new_p2pkh( + &hash160::Hash::hash(&key.public_key().to_compressed()).into(), + ), + } + } + + fn spend(outpoint: OutPoint, key: u8) -> StoredTransaction { + let prevout = coin(key); + let secret = EcdsaSecretKey::from_bytes(&[key; 32]).unwrap(); + let mut transaction = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: outpoint, + ..Default::default() + }], + output: vec![prevout.clone()], + special_transaction_payload: None, + }; + let hash = SighashCache::new(&transaction) + .legacy_signature_hash(0, &prevout.script_pubkey, 1) + .unwrap(); + let mut signature = secret + .sign(&hash.to_byte_array()) + .with_der(|der| der.to_vec()); + signature.push(1); + transaction.input[0].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(signature).unwrap()) + .push_slice(secret.public_key().to_compressed()) + .into_script(); + StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + } + } + + fn outpoint(tag: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([tag; 32]), + vout: 0, + } + } + + fn annotate(record: &mut StoredTransaction, key: u8) { + record.owned_inputs.push(InputDetail { + index: 0, + value: coin(key).value, + address: Address::from_script(&coin(key).script_pubkey, Network::Testnet).unwrap(), + }); + } + + fn candidates(records: &[StoredTransaction]) -> BTreeSet { + records.iter().map(|r| r.transaction.txid()).collect() + } + + #[test] + fn should_preserve_signed_claimants_and_reject_forged_claims_across_accounts() { + let valid = spend(outpoint(1), 7); + let forged = spend(outpoint(2), 8); + let records = [valid.clone(), forged.clone()]; + let result = plan_history_authorization( + &records, + &candidates(&records), + &BTreeMap::from([(outpoint(1), coin(7)), (outpoint(2), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([forged.transaction.txid()]) + ); + assert_eq!(result.rejected, result.invalid_owner); + assert_eq!( + result.surviving_claimants, + BTreeMap::from([(outpoint(1), BTreeSet::from([valid.transaction.txid()]))]) + ); + } + + #[test] + fn should_reject_candidate_descendants_in_reverse_order_using_body_ids() { + let mut invalid = spend(outpoint(1), 8); + invalid.txid = outpoint(99).txid; + let child = spend( + OutPoint { + txid: invalid.transaction.txid(), + vout: 0, + }, + 8, + ); + let grandchild = spend( + OutPoint { + txid: child.transaction.txid(), + vout: 0, + }, + 8, + ); + let records = [grandchild, child, invalid.clone()]; + let result = plan_history_authorization( + &records, + &candidates(&records), + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert_eq!(result.rejected, candidates(&records)); + assert!(result.surviving_claimants.is_empty()); + } + + #[test] + fn should_keep_every_surviving_claimant_including_non_candidates() { + let invalid = spend(outpoint(1), 8); + let final_record = spend(outpoint(1), 9); + let valid = spend(outpoint(1), 7); + let records = [invalid.clone(), final_record.clone(), valid.clone()]; + let selected = BTreeSet::from([invalid.transaction.txid(), valid.transaction.txid()]); + let result = plan_history_authorization( + &records, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.rejected, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert_eq!( + result.surviving_claimants[&outpoint(1)], + BTreeSet::from([final_record.transaction.txid(), valid.transaction.txid()]) + ); + } + + #[test] + fn should_use_canonical_funding_before_corrupt_annotation_or_coin() { + let parent = spend(outpoint(1), 7); + let prevout = OutPoint { + txid: parent.transaction.txid(), + vout: 0, + }; + let mut valid = spend(prevout, 7); + annotate(&mut valid, 8); + let mut forged = spend(prevout, 8); + annotate(&mut forged, 8); + let selected = BTreeSet::from([valid.transaction.txid(), forged.transaction.txid()]); + let result = plan_history_authorization( + &[forged.clone(), valid, parent], + &selected, + &BTreeMap::from([(prevout, coin(8))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([forged.transaction.txid()]) + ); + } + + #[test] + fn should_reject_missing_canonical_output_without_annotation_fallback() { + let parent = spend(outpoint(1), 7); + let prevout = OutPoint { + txid: parent.transaction.txid(), + vout: 5, + }; + let mut child = spend(prevout, 7); + annotate(&mut child, 7); + let selected = BTreeSet::from([child.transaction.txid()]); + let result = plan_history_authorization( + &[child, parent], + &selected, + &BTreeMap::from([(prevout, coin(7))]), + ) + .unwrap(); + assert_eq!(result.invalid_owner, selected); + } + + #[test] + fn should_verify_annotation_only_owned_inputs_and_ignore_external_inputs() { + let mut valid = spend(outpoint(1), 7); + annotate(&mut valid, 7); + let mut forged = spend(outpoint(2), 8); + annotate(&mut forged, 7); + let mut external = spend(outpoint(3), 8); + external.transaction.input[0].script_sig = ScriptBuf::new(); + let records = [valid.clone(), forged.clone(), external.clone()]; + let result = + plan_history_authorization(&records, &candidates(&records), &BTreeMap::new()).unwrap(); + assert_eq!(result.rejected, BTreeSet::from([forged.transaction.txid()])); + assert_eq!( + result.surviving_claimants[&outpoint(1)], + BTreeSet::from([valid.transaction.txid()]) + ); + assert_eq!( + result.surviving_claimants[&outpoint(3)], + BTreeSet::from([external.transaction.txid()]) + ); + } + + #[test] + fn should_stop_descendant_rejection_at_a_non_candidate() { + let invalid = spend(outpoint(1), 8); + let final_record = spend( + OutPoint { + txid: invalid.transaction.txid(), + vout: 0, + }, + 8, + ); + let child = spend( + OutPoint { + txid: final_record.transaction.txid(), + vout: 0, + }, + 8, + ); + let records = [child.clone(), final_record.clone(), invalid.clone()]; + let selected = BTreeSet::from([invalid.transaction.txid(), child.transaction.txid()]); + let result = plan_history_authorization( + &records, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.rejected, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert!(result + .surviving_claimants + .values() + .any(|ids| ids.contains(&child.transaction.txid()))); + let mut reversed = records.to_vec(); + reversed.reverse(); + assert_eq!( + result, + plan_history_authorization( + &reversed, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]) + ) + .unwrap() + ); + } + + #[test] + fn should_return_unknown_authorization_as_error_without_a_repair_plan() { + let record = spend(outpoint(1), 7); + let selected = BTreeSet::from([record.transaction.txid()]); + let unknown = TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new(), + }; + assert_eq!( + plan_history_authorization( + &[record], + &selected, + &BTreeMap::from([(outpoint(1), unknown)]) + ), + Err(SpendAuthorizationError::UnsupportedScript) + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs new file mode 100644 index 00000000000..98736473c2e --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -0,0 +1,1014 @@ +//! Wallet-owned projection of replay verdicts onto durable input claims. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::{OutPoint, Txid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; + +use super::history_replay::{HistoryReplay, HistoryReplayError}; +use crate::changeset::{ + swept_payment_status, AssetLockChangeSet, ClientWalletStartState, StoredTransaction, +}; +use crate::wallet::asset_lock::tracked::AssetLockStatus; +use crate::wallet::identity::PaymentEntry; + +/// Attribute a withheld input to its surviving spender, or the external sweep winner. +pub fn held_sweep_claimant(winner: Txid, survivors: impl Iterator) -> Txid { + let mut selected = None; + for survivor in survivors { + if survivor == winner { + return winner; + } + selected = Some(selected.map_or(survivor, |known: Txid| known.min(survivor))); + } + selected.unwrap_or(winner) +} + +/// One durable hold, independent of a backend's physical row representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StoredReplayClaim { + /// Input held by persistence, including a placeholder without a funding output. + pub outpoint: OutPoint, + /// None preserves an anonymous hold; it never means permission to release. + pub claimant: Option, + /// Existing lifetime stamp, retained when a new unmined winner has none. + pub mined_height: Option, +} + +/// The exact state a backend applies to one input after deleting rejected records. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayClaimEffect { + /// The funding itself was rejected: remove the output or placeholder. + Remove, + /// Release a materialized output, or delete a placeholder. + Release, + /// Keep or create a hold; a missing claimant preserves anonymous provenance. + Hold { + /// The surviving claimant, or the preexisting anonymous hold. + claimant: Option, + /// The retained winner's own mining height, when available. + mined_height: Option, + }, +} + +/// Storage-independent effects computed from one authoritative wallet snapshot. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct HistoryReplayEffects { + /// Physical history keys to remove from this wallet. + pub removed_records: BTreeSet, + /// Rejected transaction identities whose outputs, proofs and operations are removed. + pub removed_evidence: BTreeSet, + /// Apply after row deletion, whose storage cascades may clear old claimant links. + pub claims: BTreeMap, +} + +/// Operation retirement selected by the wallet, applied in the replay transaction. +#[derive(Debug, Default, PartialEq)] +pub struct HistoryReplayOperationEffects { + /// Exact nonterminal asset-lock removals. + pub asset_locks: AssetLockChangeSet, + /// Exact payment overlays, preserving unrelated payment history. + pub payments: BTreeMap>, +} + +impl HistoryReplayEffects { + /// Select operation changes from the same fresh wallet snapshot as these effects. + pub fn operation_effects( + &self, + state: &ClientWalletStartState, + ) -> HistoryReplayOperationEffects { + self.select_operations( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .map(|lock| (lock.out_point, &lock.status)), + state + .identity_manager + .wallet_identities + .get(&state.wallet.compute_wallet_id()) + .into_iter() + .flat_map(BTreeMap::values) + .chain( + state + .identity_manager + .out_of_wallet_identities + .values() + .filter(|identity| { + identity.wallet_id == Some(state.wallet.compute_wallet_id()) + }), + ) + .map(|identity| (identity.identity.id(), &identity.dashpay().payments)), + ) + } + + fn select_operations<'a>( + &self, + locks: impl Iterator, + payments: impl Iterator)>, + ) -> HistoryReplayOperationEffects { + let mut effects = HistoryReplayOperationEffects::default(); + for (outpoint, status) in locks { + if self.removed_evidence.contains(&outpoint.txid) && !status.is_consumed() { + effects.asset_locks.removed.insert(outpoint); + } + } + let retired: BTreeSet<_> = self.removed_evidence.iter().map(Txid::to_string).collect(); + for (identity, rows) in payments { + for (txid, payment) in rows.iter().filter(|(txid, _)| retired.contains(*txid)) { + if let Some(status) = swept_payment_status(payment) { + effects.payments.entry(identity).or_default().insert( + txid.clone(), + PaymentEntry { + status, + ..payment.clone() + }, + ); + } + } + } + effects + } +} + +#[cfg(test)] +mod operation_tests { + use super::*; + use crate::wallet::identity::PaymentStatus; + + #[test] + fn should_settle_wallet_linked_payment_without_a_registration_index() { + use crate::changeset::IdentityManagerStartState; + use crate::wallet::identity::ManagedIdentity; + use dpp::identity::{v0::IdentityV0, Identity}; + let wallet = key_wallet::Wallet::from_seed_bytes( + [6; 64], + dashcore::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .unwrap(); + let wallet_id = wallet.compute_wallet_id(); + let txid = Txid::from_byte_array([7; 32]); + let mut identities = IdentityManagerStartState::default(); + for (marker, owner) in [(8, Some(wallet_id)), (9, None)] { + let id = Identifier::from([marker; 32]); + let mut identity = ManagedIdentity::new_out_of_wallet(Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + })); + identity.wallet_id = owner; + identity + .dashpay_payments_mut() + .insert(txid.to_string(), PaymentEntry::new_sent(id, 42, None)); + identities.out_of_wallet_identities.insert(id, identity); + } + let state = ClientWalletStartState { + wallet_info: key_wallet::wallet::ManagedWalletInfo::from_wallet(&wallet, 0), + wallet, + identity_manager: identities, + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), + dashpay_backfill: Default::default(), + }; + let effects = HistoryReplayEffects { + removed_evidence: [txid].into(), + ..Default::default() + }; + let operations = effects.operation_effects(&state); + assert_eq!(operations.payments.len(), 1); + assert_eq!( + operations.payments[&Identifier::from([8; 32])][&txid.to_string()].status, + PaymentStatus::Failed + ); + } + + #[test] + fn should_retire_only_rejected_nonterminal_asset_lock_operations() { + let rejected = Txid::from_byte_array([1; 32]); + let effects = HistoryReplayEffects { + removed_evidence: [rejected].into(), + ..Default::default() + }; + let locks = [ + ( + OutPoint { + txid: rejected, + vout: 0, + }, + AssetLockStatus::Broadcast, + ), + ( + OutPoint { + txid: rejected, + vout: 1, + }, + AssetLockStatus::Consumed, + ), + ( + OutPoint { + txid: rejected, + vout: 2, + }, + AssetLockStatus::DrainCompleted, + ), + ( + OutPoint { + txid: Txid::from_byte_array([2; 32]), + vout: 0, + }, + AssetLockStatus::Broadcast, + ), + ]; + let result = effects.select_operations( + locks.iter().map(|(key, status)| (*key, status)), + std::iter::empty(), + ); + assert_eq!(result.asset_locks.removed, [locks[0].0].into()); + } + + #[test] + fn should_project_existing_payment_retirement_without_changing_received_rows() { + let owner = Identifier::from([3; 32]); + let ids: Vec<_> = (1..=4) + .map(|byte| Txid::from_byte_array([byte; 32])) + .collect(); + let sent = PaymentEntry::new_sent(owner, 42, Some("memo".into())); + let mut confirmed = sent.clone(); + confirmed.status = PaymentStatus::Confirmed; + let rows = [ + (ids[0].to_string(), sent.clone()), + (ids[1].to_string(), confirmed), + ( + ids[2].to_string(), + PaymentEntry::new_received(owner, 42, None), + ), + (ids[3].to_string(), sent.clone()), + ] + .into(); + let effects = HistoryReplayEffects { + removed_evidence: ids[..3].iter().copied().collect(), + ..Default::default() + }; + let result = effects.select_operations(std::iter::empty(), [(owner, &rows)].into_iter()); + assert_eq!( + result.payments, + [( + owner, + [ + ( + ids[0].to_string(), + PaymentEntry { + status: PaymentStatus::Failed, + ..sent.clone() + } + ), + ( + ids[1].to_string(), + PaymentEntry { + status: PaymentStatus::Failed, + ..sent + } + ), + ] + .into() + )] + .into() + ); + } +} + +impl HistoryReplay { + /// Project already-verified replay verdicts from one wallet snapshot, without new finality evidence. + /// + /// # Errors + /// Returns [`HistoryReplayError::UnsettledAfterReplay`] if a final held winner + /// was removed and neither a surviving body nor durable claim can protect its input, + /// or an input has no authorization rejection or sweep permitting its release. + pub fn storage_effects( + &self, + records: &[StoredTransaction], + claims: &[StoredReplayClaim], + ) -> Result { + let records_by_key: BTreeMap<_, _> = records + .iter() + .map(|record| (record.txid, (record.transaction.txid(), record))) + .collect(); + // Sweep verdicts name physical keys; authorization names canonical bodies. + let mut removed_records: BTreeSet<_> = self.defeated.iter().copied().collect(); + removed_records.extend( + self.sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()), + ); + removed_records.extend(records_by_key.iter().filter_map(|(key, (body, _))| { + self.authorization.rejected.contains(body).then_some(*key) + })); + let surviving_bodies: BTreeSet<_> = records_by_key + .iter() + .filter(|(key, _)| !removed_records.contains(*key)) + .map(|(_, (body, _))| *body) + .collect(); + let mut removed_evidence = self.authorization.rejected.clone(); + removed_evidence.extend(removed_records.iter().copied()); + removed_evidence.extend( + records_by_key + .iter() + .filter_map(|(key, (body, _))| removed_records.contains(key).then_some(*body)), + ); + removed_evidence.retain(|txid| !surviving_bodies.contains(txid)); + let mut authorization_evidence = self.authorization.rejected.clone(); + authorization_evidence.extend(records_by_key.iter().filter_map(|(key, (body, _))| { + self.authorization.rejected.contains(body).then_some(*key) + })); + authorization_evidence.retain(|txid| !surviving_bodies.contains(txid)); + + let mut affected = BTreeSet::new(); + let mut release_allowed = BTreeSet::new(); + let mut survivors: BTreeMap>> = BTreeMap::new(); + for (key, (body, record)) in &records_by_key { + for input in &record.transaction.input { + if removed_records.contains(key) { + affected.insert(input.previous_output); + if self.authorization.rejected.contains(body) { + release_allowed.insert(input.previous_output); + } + } else { + let height = record.context.block_info().map(|block| block.height()); + survivors + .entry(input.previous_output) + .or_default() + .entry(*body) + .and_modify(|stamp| *stamp = merge_same_claim_stamp(*stamp, height)) + .or_insert(height); + } + } + } + for (outpoint, claimants) in &self.authorization.surviving_claimants { + for claimant in claimants + .iter() + .filter(|id| !removed_evidence.contains(*id)) + { + survivors + .entry(*outpoint) + .or_default() + .entry(*claimant) + .or_insert(None); + } + } + for (&input, &claimant) in &self.withheld_asset_lock_inputs { + if removed_evidence.contains(&claimant) { + return Err(HistoryReplayError::UnsettledAfterReplay); + } + affected.insert(input); + survivors + .entry(input) + .or_default() + .entry(claimant) + .or_insert(None); + } + let mut stored_claims: BTreeMap> = BTreeMap::new(); + for claim in claims { + if claim + .claimant + .is_some_and(|id| authorization_evidence.contains(&id)) + { + release_allowed.insert(claim.outpoint); + } + stored_claims + .entry(claim.outpoint) + .or_default() + .push(*claim); + // Deleting a protected physical alias can still clear its claimant links. + if removed_evidence.contains(&claim.outpoint.txid) + || claim.claimant.is_some_and(|id| { + removed_evidence.contains(&id) || removed_records.contains(&id) + }) + { + affected.insert(claim.outpoint); + } + } + let mut pending: BTreeMap<_, _> = affected + .iter() + .filter_map(|outpoint| { + stored_claims + .get(outpoint) + .and_then(|claims| select_claim(claims, |_| true)) + .map(|claim| (*outpoint, claim)) + }) + .collect(); + let mut sweep_held = BTreeSet::new(); + for sweep in &self.sweeps { + let mut loser_identities: BTreeSet<_> = sweep.txids.iter().copied().collect(); + let mut inputs: BTreeSet<_> = sweep.released_outpoints.iter().copied().collect(); + for loser in &sweep.txids { + if let Some((body, record)) = records_by_key.get(loser) { + loser_identities.insert(*body); + inputs.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output), + ); + } + } + // A placeholder survives its record, including an intermediate sweep winner. + inputs.extend(claims.iter().chain(pending.values()).filter_map(|claim| { + claim + .claimant + .filter(|id| loser_identities.contains(id)) + .map(|_| claim.outpoint) + })); + let released: BTreeSet<_> = sweep.released_outpoints.iter().copied().collect(); + release_allowed.extend(released.iter().copied()); + for outpoint in inputs { + affected.insert(outpoint); + if released.contains(&outpoint) { + pending.remove(&outpoint); + sweep_held.remove(&outpoint); + } else { + let winner = records_by_key + .get(&sweep.superseded_by) + .map_or(sweep.superseded_by, |(body, _)| *body); + let retained = survivors.get(&outpoint); + let claimant = held_sweep_claimant( + winner, + retained + .into_iter() + .flat_map(|claims| claims.keys().copied()), + ); + let stamp = if claimant == winner { + sweep + .winner_mined_height + .or_else(|| pending.get(&outpoint).and_then(|claim| claim.mined_height)) + } else { + retained.and_then(|claims| claims.get(&claimant).copied().flatten()) + }; + pending.insert( + outpoint, + StoredReplayClaim { + outpoint, + claimant: Some(claimant), + mined_height: stamp, + }, + ); + sweep_held.insert(outpoint); + } + } + } + let mut projected = BTreeMap::new(); + for outpoint in affected { + let strong = stored_claims.get(&outpoint).and_then(|claims| { + select_claim(claims, |claim| { + claim + .claimant + .is_none_or(|id| !removed_evidence.contains(&id)) + }) + }); + let survivor = survivors.get(&outpoint).and_then(BTreeMap::first_key_value); + let provisional = pending.get(&outpoint); + let effect = if removed_evidence.contains(&outpoint.txid) { + ReplayClaimEffect::Remove + } else if let Some(claim) = strong { + claim.effect() + } else if sweep_held.contains(&outpoint) { + let resolved = provisional.and_then(|claim| { + let id = claim.claimant?; + let claimant = if !removed_evidence.contains(&id) { + Some(id) + } else { + records_by_key + .get(&id) + .map(|(body, _)| *body) + .filter(|body| surviving_bodies.contains(body)) + }?; + Some(ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: claim.mined_height, + }) + }); + if let Some(effect) = resolved { + effect + } else if let Some((&claimant, &height)) = survivor { + ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: provisional.and_then(|claim| claim.mined_height).or(height), + } + } else { + return Err(HistoryReplayError::UnsettledAfterReplay); + } + } else if let Some((&claimant, &height)) = survivor { + ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: provisional.and_then(|claim| claim.mined_height).or(height), + } + } else if release_allowed.contains(&outpoint) { + ReplayClaimEffect::Release + } else { + return Err(HistoryReplayError::UnsettledAfterReplay); + }; + projected.insert(outpoint, effect); + } + Ok(HistoryReplayEffects { + removed_records, + removed_evidence, + claims: projected, + }) + } +} + +impl StoredReplayClaim { + fn effect(self) -> ReplayClaimEffect { + ReplayClaimEffect::Hold { + claimant: self.claimant, + mined_height: self.mined_height, + } + } +} + +// An unbounded observation cannot become collectible because another row has a stamp. +fn merge_same_claim_stamp(left: Option, right: Option) -> Option { + left.zip(right).map(|(left, right)| left.max(right)) +} + +fn select_claim( + claims: &[StoredReplayClaim], + retain: impl Fn(&StoredReplayClaim) -> bool, +) -> Option { + let mut selected: Option = None; + for claim in claims.iter().filter(|claim| retain(claim)) { + match selected.as_mut() { + // Option ordering preserves anonymous provenance before any named claim. + Some(prior) if prior.claimant < claim.claimant => {} + Some(prior) if prior.claimant == claim.claimant => { + prior.mined_height = merge_same_claim_stamp(prior.mined_height, claim.mined_height); + } + _ => selected = Some(*claim), + } + } + selected +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::changeset::SweepBatch; + use dashcore::{Transaction, TxIn}; + use key_wallet::transaction_checking::TransactionContext; + + fn point(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), 0) + } + + fn record(marker: u32, inputs: &[OutPoint]) -> StoredTransaction { + let transaction = Transaction { + version: 1, + lock_time: marker, + input: inputs + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![], + special_transaction_payload: None, + }; + StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + } + } + + fn claim(outpoint: OutPoint, claimant: Option) -> StoredReplayClaim { + StoredReplayClaim { + outpoint, + claimant, + mined_height: None, + } + } + + #[test] + fn should_release_only_rejected_claims_and_keep_anonymous_or_recordless_holds() { + let inputs = [point(1), point(2), point(3)]; + let bad = record(1, &inputs); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let claims = [ + claim(inputs[0], Some(bad.txid)), + claim(inputs[1], None), + claim(inputs[2], Some(point(9).txid)), + ]; + let effects = outcome + .storage_effects(std::slice::from_ref(&bad), &claims) + .unwrap(); + assert_eq!(effects.removed_records, [bad.txid].into()); + assert_eq!(effects.claims[&inputs[0]], ReplayClaimEffect::Release); + assert_eq!( + effects.claims[&inputs[1]], + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None + } + ); + assert_eq!( + effects.claims[&inputs[2]], + ReplayClaimEffect::Hold { + claimant: Some(point(9).txid), + mined_height: None + } + ); + } + + #[test] + fn should_reassign_rejected_claims_to_a_surviving_body() { + let input = point(4); + let bad = record(1, &[input]); + let survivor = record(2, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let effects = outcome + .storage_effects( + &[bad.clone(), survivor.clone()], + &[claim(input, Some(bad.txid))], + ) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor.txid), + mined_height: None + } + ); + } + + #[test] + fn should_project_sweep_releases_and_hold_unfunded_inputs_without_losing_names() { + let inputs = [point(5), point(6)]; + let loser = record(1, &inputs); + let child = record(2, &[OutPoint::new(loser.txid, 0)]); + let winner = record(3, &[inputs[0]]); + let mut outcome = HistoryReplay::default(); + outcome.defeated.extend([loser.txid, child.txid]); + outcome.sweeps.push(SweepBatch { + txids: vec![loser.txid, child.txid], + superseded_by: winner.txid, + winner_mined_height: Some(42), + released_outpoints: vec![inputs[1]], + }); + let effects = outcome + .storage_effects(&[loser.clone(), child], &[]) + .unwrap(); + assert_eq!( + effects.claims[&inputs[0]], + ReplayClaimEffect::Hold { + claimant: Some(winner.txid), + mined_height: Some(42) + } + ); + assert_eq!(effects.claims[&inputs[1]], ReplayClaimEffect::Release); + assert_eq!( + effects.claims[&OutPoint::new(loser.txid, 0)], + ReplayClaimEffect::Remove + ); + } + + #[test] + fn should_preserve_evidence_when_a_rejected_physical_key_names_a_surviving_body() { + let input = point(7); + let mut survivor = record(2, &[input]); + let surviving_body = survivor.txid; + survivor.txid = point(8).txid; + let mut bad = record(1, &[input]); + let bad_body = bad.txid; + bad.txid = surviving_body; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad_body); + let effects = outcome + .storage_effects( + &[bad, survivor.clone()], + &[claim(input, Some(surviving_body))], + ) + .unwrap(); + assert!(effects.removed_records.contains(&surviving_body)); + assert!(!effects.removed_evidence.contains(&surviving_body)); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(surviving_body), + mined_height: None + } + ); + } + + #[test] + fn should_map_defeated_physical_keys_without_removing_surviving_canonical_evidence() { + let input = point(10); + let mut survivor = record(2, &[input]); + let survivor_body = survivor.txid; + survivor.txid = point(11).txid; + let mut loser = record(1, &[input]); + let loser_body = loser.txid; + loser.txid = survivor_body; + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + let effects = outcome.storage_effects(&[loser, survivor], &[]).unwrap(); + assert_eq!(effects.removed_records, [survivor_body].into()); + assert_eq!(effects.removed_evidence, [loser_body].into()); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor_body), + mined_height: None, + } + ); + } + + fn chained(release: bool) -> (HistoryReplay, StoredTransaction, Txid, OutPoint) { + let input = point(12); + let loser = record(1, &[input]); + let middle = point(13).txid; + let winner = point(14).txid; + let mut outcome = HistoryReplay::default(); + outcome.defeated.extend([loser.txid, middle]); + outcome.sweeps = vec![ + SweepBatch { + txids: vec![loser.txid], + superseded_by: middle, + winner_mined_height: Some(42), + released_outpoints: vec![], + }, + SweepBatch { + txids: vec![middle], + superseded_by: winner, + winner_mined_height: None, + released_outpoints: if release { vec![input] } else { vec![] }, + }, + ]; + (outcome, loser, winner, input) + } + + #[test] + fn should_follow_chained_claims_without_an_intermediate_body_and_keep_the_mined_stamp() { + let (outcome, loser, winner, input) = chained(false); + let effects = outcome.storage_effects(&[loser], &[]).unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(winner), + mined_height: Some(42), + } + ); + assert!(!effects.removed_evidence.contains(&winner)); + } + + #[test] + fn should_apply_a_later_release_to_a_recordless_intermediate_claim() { + let (outcome, loser, _, input) = chained(true); + let effects = outcome.storage_effects(&[loser], &[]).unwrap(); + assert_eq!(effects.claims[&input], ReplayClaimEffect::Release); + } + + #[test] + fn should_keep_anonymous_provenance_over_named_claims_and_a_sweep_winner() { + let (outcome, loser, _, input) = chained(false); + let mut claims = [claim(input, Some(point(15).txid)), claim(input, None)]; + claims[0].mined_height = Some(99); + let effects = outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + } + ); + claims.reverse(); + assert_eq!(effects, outcome.storage_effects(&[loser], &claims).unwrap()); + } + + #[test] + fn should_choose_survivors_deterministically_and_preserve_existing_valid_provenance() { + let input = point(16); + let bad = record(1, &[input]); + let a = record(2, &[input]); + let b = record(3, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let mut records = [bad.clone(), a.clone(), b.clone()]; + let effects = outcome + .storage_effects(&records, &[claim(input, Some(bad.txid))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(a.txid.min(b.txid)), + mined_height: None, + } + ); + records.reverse(); + assert_eq!( + effects, + outcome + .storage_effects(&records, &[claim(input, Some(bad.txid))]) + .unwrap() + ); + let retained = a.txid.max(b.txid); + let effects = outcome + .storage_effects(&records, &[claim(input, Some(retained))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(retained), + mined_height: None, + } + ); + } + + #[test] + fn should_not_borrow_a_stamp_from_another_surviving_claimant() { + let (outcome, loser, _, input) = chained(false); + let survivor = point(17).txid; + let mut claims = [claim(input, Some(survivor)), claim(input, Some(loser.txid))]; + claims[1].mined_height = Some(70); + let effects = outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: None, + } + ); + claims[1].claimant = Some(survivor); + claims[0].mined_height = Some(50); + assert_eq!( + outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap() + .claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: Some(70), + } + ); + claims[0].mined_height = None; + assert_eq!( + outcome.storage_effects(&[loser], &claims).unwrap().claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: None, + } + ); + } + + #[test] + fn should_remove_co_removed_funding_even_with_an_anonymous_hold() { + let funding = record(1, &[]); + let outpoint = OutPoint::new(funding.txid, 0); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(funding.txid); + let effects = outcome + .storage_effects(&[funding], &[claim(outpoint, None)]) + .unwrap(); + assert_eq!(effects.claims[&outpoint], ReplayClaimEffect::Remove); + } + + #[test] + fn should_leave_retired_only_records_and_unaffected_claims_unchanged() { + let input = point(18); + let unsafe_record = record(1, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.retired.insert(unsafe_record.txid); + let claims = [claim(input, Some(unsafe_record.txid))]; + assert_eq!( + outcome.storage_effects(&[unsafe_record], &claims).unwrap(), + HistoryReplayEffects::default() + ); + } + + #[test] + fn should_not_delete_an_unrelated_physical_row_for_a_missing_rejected_body() { + let mut survivor = record(1, &[]); + let rejected = point(19).txid; + survivor.txid = rejected; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(rejected); + let effects = outcome.storage_effects(&[survivor], &[]).unwrap(); + assert!(effects.removed_records.is_empty()); + } + + #[test] + fn should_fail_without_partial_effects_when_a_final_sweep_winner_cannot_be_resolved() { + let (mut outcome, loser, winner, _) = chained(false); + outcome.authorization.rejected.insert(winner); + assert!(matches!( + outcome.storage_effects(&[loser], &[]), + Err(HistoryReplayError::UnsettledAfterReplay) + )); + } + + #[test] + fn should_resolve_a_sweep_winners_removed_physical_alias_to_its_surviving_body() { + let input = point(20); + let mut loser = record(1, &[input]); + let mut winner = record(2, &[input]); + winner.txid = loser.txid; + loser.txid = point(21).txid; + let winner_body = winner.transaction.txid(); + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + outcome.sweeps.push(SweepBatch { + txids: vec![loser.txid], + superseded_by: winner.txid, + winner_mined_height: Some(44), + released_outpoints: vec![], + }); + let effects = outcome.storage_effects(&[loser, winner], &[]).unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(winner_body), + mined_height: Some(44), + } + ); + } + + #[test] + fn should_restore_a_protected_alias_claim_even_outside_the_removed_bodys_inputs() { + let input = point(22); + let mut survivor = record(2, &[input]); + let survivor_body = survivor.txid; + survivor.txid = point(23).txid; + let mut bad = record(1, &[]); + let bad_body = bad.txid; + bad.txid = survivor_body; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad_body); + let effects = outcome + .storage_effects(&[bad, survivor], &[claim(input, Some(survivor_body))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor_body), + mined_height: None, + } + ); + } + + #[test] + fn should_refuse_to_release_a_defeated_claim_without_a_sweep_verdict() { + let input = point(24); + let loser = record(1, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + for claims in [vec![], vec![claim(input, Some(loser.txid))]] { + assert!(matches!( + outcome.storage_effects(std::slice::from_ref(&loser), &claims), + Err(HistoryReplayError::UnsettledAfterReplay) + )); + } + } + + #[test] + fn should_leave_a_settled_recordless_chain_unchanged_on_repeated_projection() { + let (outcome, loser, winner, input) = chained(false); + let first = outcome.storage_effects(&[loser], &[]).unwrap(); + let ReplayClaimEffect::Hold { + claimant, + mined_height, + } = first.claims[&input] + else { + panic!("the settled input must stay held") + }; + assert_eq!(claimant, Some(winner)); + let second = outcome + .storage_effects( + &[], + &[StoredReplayClaim { + outpoint: input, + claimant, + mined_height, + }], + ) + .unwrap(); + assert_eq!(first.removed_records, second.removed_records); + assert_eq!(first.removed_evidence, second.removed_evidence); + assert!( + second.claims.is_empty(), + "the surviving hold requires no rewrite" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs new file mode 100644 index 00000000000..cd4169abda4 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -0,0 +1,5326 @@ +//! Load-time replay of stored transaction history. +//! +//! A persister hands back its stored records as a [`RecordedHistory`]; +//! [`replay_recorded_history`] runs them through the wallet checker so the +//! in-memory spend state (`spent_outpoints`, `observed_spent_outpoints`, +//! InstantSend upgrades) matches what a live process held. Without it a +//! redelivered funding transaction re-credits an output a confirmed spend +//! already consumed. +//! +//! Backends apply typed repair effects in their atomic load scope and return +//! the replay outcome with the snapshot; raw legacy loads cannot claim a repair. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::future::Future; +use std::task::{Context, Poll, Waker}; + +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::transaction::TransactionPayload; +use dashcore::{Address, OutPoint, Transaction, TxOut, Txid}; +use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, +}; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::transaction_checking::{ + TransactionContext, TransactionType, WalletTransactionChecker, +}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Utxo; + +use super::history_authorization::plan_history_authorization; +pub use super::history_authorization::HistoryAuthorization; +pub use super::history_claims::{ + held_sweep_claimant, HistoryReplayEffects, HistoryReplayOperationEffects, ReplayClaimEffect, + StoredReplayClaim, +}; +use crate::changeset::changeset::SweepBatch; +use crate::changeset::recorded_history::ReplayEvidence; +use crate::changeset::{ + is_owned, ClientStartState, ClientWalletStartState, RecordedHistory, StoredTransaction, +}; +use crate::wallet::asset_lock::sync::recovery::{ + add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, + reattach_records, recorded_input_conflicts, withhold_chain_final_fundings, +}; +use crate::wallet::asset_lock::tracked::AssetLockStatus; +use crate::wallet::platform_wallet::ObservedInputConflict; +use crate::wallet::platform_wallet::WalletId; +use key_wallet::transaction_checking::spend_authorization::SpendAuthorizationError; + +/// Backend snapshot and the replay verdict committed with each wallet. +#[derive(Debug, Default)] +pub struct HistoryReplayLoad { + /// Authoritative wallet snapshots after successful replay. + pub state: ClientStartState, + /// Replay outcomes keyed by wallet; missing means the backend supplied raw history. + pub outcomes: BTreeMap, +} + +/// What [`replay_recorded_history`] made of the stored history. +#[derive(Debug, Default)] +pub struct HistoryReplay { + /// Confirmed competing spends observed before replay can evict their records. + pub recorded_conflicts: BTreeMap, + /// Owner-authorized rejections and surviving claims for atomic backend repair. + pub authorization: HistoryAuthorization, + /// How many records went through the wallet checker. + pub replayed: usize, + /// Stored transactions the replay removed or left out: the losers of a + /// settled conflict with their descendants, and the records with unsafe + /// amounts. + pub retired: HashSet, + /// The retired records a stored conflict winner defeated, with their + /// descendants. A record only left out for unsafe amounts is not among + /// them: nothing has proven the coins it spends free. + pub defeated: HashSet, + /// Conflict effects for the backend to apply in its load transaction. + pub sweeps: Vec, + /// Materialized inputs withheld for final asset locks, requiring a durable hold. + pub withheld_asset_lock_inputs: BTreeMap, + /// Recovery views cannot settle or resend even when proof is available. + pub read_only: bool, +} + +impl HistoryReplay { + /// The unconfirmed sends of `sends` still worth dispatching again. + /// + /// A dispatch hands the transaction back to the wallet as a mempool + /// sighting: a retired loser would be recorded again, and a record with + /// unsafe amounts would reach the checker the replay kept it from. + pub fn resendable(&self, sends: Vec) -> Vec { + if self.read_only { + return Vec::new(); + } + sends + .into_iter() + .filter(|send| !self.retired.contains(&send.txid())) + .collect() + } +} + +/// A blocking backend cannot commit a replay that suspended part-way through. +#[derive(Debug, thiserror::Error)] +pub enum HistoryReplayError { + /// The snapshot requires evidence the running SPV verifier cannot yet provide. + #[error("wallet history requires verified finality evidence for {txids:?}; synchronize SPV and retry load")] + MissingFinalityEvidence { txids: BTreeSet }, + /// A surviving mined record has operands no valid transaction can carry. + #[error("stored mined transactions have unsafe accounting amounts: {txids:?}")] + UnsafeMinedAmounts { txids: BTreeSet }, + /// Stored input authorization cannot be checked with supported rules. + #[error("cannot verify stored input authorization")] + SpendAuthorization(#[from] SpendAuthorizationError), + /// A legacy backend cannot persist the repair required by the common replay. + #[error("wallet history requires an atomic persistence replay before it can be loaded")] + AtomicPersistenceRequired, + /// The wallet checker requested asynchronous work during local restoration. + #[error("wallet history replay requires asynchronous work")] + Suspended, + /// Applying the planned effects did not reconcile this wallet's history. + #[error("wallet history still conflicts after atomic replay")] + UnsettledAfterReplay, +} + +/// Prepare every active persisted source before the common replay settles history. +/// On success, `recorded_history` retains the prepared identities for storage effects. +pub async fn replay_wallet_history( + state: &mut ClientWalletStartState, + evidence: &ReplayEvidence, +) -> Result { + let mut history = state.recorded_history.clone(); + let mut bodies: HashSet<_> = history + .transactions + .iter() + .map(|row| row.transaction.txid()) + .collect(); + // Every active persisted body enters the same authorization/finality pipeline. + // Terminal tracked proofs stay archival and do not create replay candidates. + for transaction in state.unconfirmed_outgoing_txs.iter().chain( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter(|lock| !lock.status.is_consumed()) + .map(|lock| &lock.transaction), + ) { + if bodies.insert(transaction.txid()) { + history.transactions.push(StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }); + } + } + for lock in state.unused_asset_locks.values().flat_map(BTreeMap::values) { + if lock.status == AssetLockStatus::InstantSendLocked { + history + .required_instant_locks + .insert(lock.transaction.txid()); + } + } + let history_txids: HashSet<_> = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + let recorded_conflicts = recorded_input_conflicts( + state.unused_asset_locks.values().flat_map(BTreeMap::values), + &history, + ); + add_tracked_instant_locks( + &mut history, + &history_txids, + state.unused_asset_locks.values().flat_map(BTreeMap::values), + ); + let prepared_history = history.clone(); + let withheld = withhold_chain_final_fundings( + &mut history, + state.unused_asset_locks.values().flat_map(BTreeMap::values), + ); + let withheld_records = detach_unconfirmed_records(&mut state.wallet_info, &withheld); + let mut outcome = replay_recorded_history_with_evidence( + &mut state.wallet_info, + &mut state.wallet, + history, + evidence, + ) + .await?; + reattach_records(&mut state.wallet_info, withheld_records); + state.recorded_history = prepared_history; + outcome.recorded_conflicts = recorded_conflicts; + state.unconfirmed_outgoing_txs = + outcome.resendable(std::mem::take(&mut state.unconfirmed_outgoing_txs)); + let final_claims = final_lock_input_claims( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter(|lock| { + let txid = lock.transaction.txid(); + !outcome.defeated.contains(&txid) && !outcome.authorization.rejected.contains(&txid) + }), + ); + let claimants: BTreeMap<_, _> = final_claims + .iter() + .filter_map(|(input, claimant)| claimant.map(|claimant| (*input, claimant))) + .collect(); + // Claims prevent future credits, but spendability checks the materialized UTXO set. + for account in state.wallet_info.accounts.all_funding_accounts_mut() { + account.utxos.retain(|input, _| { + if let Some(claimant) = claimants.get(input) { + outcome.withheld_asset_lock_inputs.insert(*input, *claimant); + false + } else { + true + } + }); + } + state.wallet_info.restore_spent_outpoints(&final_claims); + state.wallet_info.update_balance(); + Ok(outcome) +} + +/// Run wallet preparation and replay in the backend's synchronous atomic scope. +pub fn replay_wallet_history_blocking( + state: &mut ClientWalletStartState, + evidence: &ReplayEvidence, +) -> Result { + let original_wallet = state.wallet.clone(); + let original_info = state.wallet_info.clone(); + let original_history = state.recorded_history.clone(); + let original_sends = state.unconfirmed_outgoing_txs.clone(); + let result = { + let mut replay = std::pin::pin!(replay_wallet_history(state, evidence)); + replay + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + }; + let error = match result { + Poll::Ready(Ok(outcome)) => return Ok(outcome), + Poll::Ready(Err(error)) => error, + Poll::Pending => HistoryReplayError::Suspended, + }; + state.wallet = original_wallet; + state.wallet_info = original_info; + state.recorded_history = original_history; + state.unconfirmed_outgoing_txs = original_sends; + Err(error) +} + +/// Run the common replay inside a synchronous backend's transaction. +pub fn replay_recorded_history_blocking( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + history: RecordedHistory, + evidence: &ReplayEvidence, +) -> Result { + let original_info = wallet_info.clone(); + let original_wallet = wallet.clone(); + let outcome = { + let mut replay = std::pin::pin!(replay_recorded_history_with_evidence( + wallet_info, + wallet, + history, + evidence + )); + replay + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + }; + match outcome { + Poll::Ready(Ok(replay)) => Ok(replay), + Poll::Ready(Err(error)) => { + *wallet_info = original_info; + *wallet = original_wallet; + Err(error) + } + Poll::Pending => { + *wallet_info = original_info; + *wallet = original_wallet; + Err(HistoryReplayError::Suspended) + } + } +} + +/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. +/// +/// The persisted UTXO set already restored onto `wallet_info` stays +/// authoritative for credits: an output the replay credits but persistence +/// did not hold as unspent is dropped again, so the replay only ever removes. +/// A coin it keeps also keeps its stored lock, InstantSend status and +/// confirmation. +/// +/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a +/// redelivered funding transaction would re-credit an output they reserve. +/// A persisted lock upgrades its mempool record to InstantSend, because a lock +/// that arrived after its transaction was stored never rewrote the record. +/// +/// Raw restored records are replayed too: their presence alone does not +/// establish spend guards. Their labels, fees and proof-lookup records survive +/// replay unless a final conflicting transaction sweeps them. +/// +/// A stored transaction that a settled one beats to an input is not replayed, +/// nor are its unconfirmed descendants, and their persisted outputs are dropped. +/// +/// A record whose amounts would overflow the checker's signed arithmetic +/// ([`unsafe_replay_amounts`]) is left out when unmined. A surviving mined +/// record with such operands fails load as corrupt storage, after required +/// proofs and conflict precedence have been resolved. Restored coins stay reserved, and an +/// unconfirmed one stays on record, unchecked, so that a settled conflict +/// still sweeps it and releases them. A coin several such records spend is +/// held as [`reservation_claimant`] decides. +/// +/// Returns how many records were replayed and which stored transactions were +/// retired instead. +pub async fn replay_recorded_history( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + history: RecordedHistory, +) -> Result { + replay_recorded_history_with_evidence(wallet_info, wallet, history, &ReplayEvidence::default()) + .await +} + +/// Replay only after binding prefetched proof to this snapshot's exact body and lock. +pub async fn replay_recorded_history_with_evidence( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + history: RecordedHistory, + evidence: &ReplayEvidence, +) -> Result { + let mut deferred = BTreeSet::new(); + let mut history = history; + // A checkpoint proves its exact block, never every lower height. + for stored in &mut history.transactions { + if let TransactionContext::InBlock(block) = stored.context { + if wallet_info + .metadata + .last_applied_chain_lock + .as_ref() + .is_some_and(|lock| { + block.height() == lock.block_height && block.block_hash() == lock.block_hash + }) + { + stored.context = TransactionContext::InChainLockedBlock(block); + } + } + } + // Preserve the planner's protection of observed IS descendants, including sidecars. + // This is a local candidate context; verification below still gates every effect. + for stored in &mut history.transactions { + if matches!(stored.context, TransactionContext::Mempool) { + if let Some(lock) = history + .instant_locks + .get(&stored.txid) + .filter(|lock| lock_matches_record(lock, stored)) + { + stored.context = TransactionContext::InstantSend(lock.clone()); + } + } + } + // Preview only authoritative ChainLock winners with the same conflict planner. + // Their own old IS metadata, and that of definite losers, cannot change the verdict. + // No preview effect is applied before all remaining required proof is rebound. + let operands: Vec> = history + .transactions + .iter() + .map(|stored| { + ( + stored.txid, + &stored.transaction, + stored.owned_inputs.as_slice(), + ) + }) + .collect(); + let unsafe_txids = unsafe_replay_amounts(wallet_info, &operands) + .into_keys() + .collect(); + let marker_preview = (!history.required_instant_locks.is_empty()).then(|| { + let mut records = history.transactions.clone(); + for stored in &mut records { + if history.required_instant_locks.contains(&stored.txid) + && matches!(stored.context, TransactionContext::Mempool) + { + // The scratch sweep reads only this observation's variant, not a signature. + // Never return this placeholder to the replay or an evidence provider. + stored.context = TransactionContext::InstantSend(InstantLock { + txid: stored.txid, + inputs: stored + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }); + } + } + records + }); + let chain_final_losers = plan_replay_sweeps_where( + wallet_info, + marker_preview.as_deref().unwrap_or(&history.transactions), + &unsafe_txids, + |stored| matches!(stored.context, TransactionContext::InChainLockedBlock(_)), + ) + .removed; + for stored in &mut history.transactions { + if matches!(stored.context, TransactionContext::InChainLockedBlock(_)) + || chain_final_losers.contains(&stored.txid) + { + history.required_instant_locks.remove(&stored.txid); + history.instant_locks.remove(&stored.txid); + if matches!(stored.context, TransactionContext::InstantSend(_)) { + stored.context = TransactionContext::Mempool; + } + } + } + deferred.extend(history.required_instant_locks.iter().copied()); + for stored in &history.transactions { + if history.required_instant_locks.contains(&stored.txid) { + if let Some(proof) = evidence.instant_locks.get(&stored.txid).filter(|proof| { + proof.network() == wallet_info.network() + && proof.txid() == stored.txid + && proof.matches_transaction(wallet_info.network(), &stored.transaction) + }) { + deferred.remove(&stored.txid); + history + .instant_locks + .entry(stored.txid) + .or_insert_with(|| proof.instant_lock().clone()); + } + } + } + for stored in &history.transactions { + let locks = + history + .instant_locks + .get(&stored.txid) + .into_iter() + .chain(match &stored.context { + TransactionContext::InstantSend(lock) => Some(lock), + _ => None, + }); + for lock in locks { + if !evidence + .instant_locks + .get(&stored.txid) + .is_some_and(|proof| { + proof.network() == wallet_info.network() + && proof.txid() == stored.txid + && proof.instant_lock() == lock + && proof.matches_transaction(wallet_info.network(), &stored.transaction) + }) + { + deferred.insert(stored.txid); + } + } + } + if !deferred.is_empty() { + return Err(HistoryReplayError::MissingFinalityEvidence { txids: deferred }); + } + let RecordedHistory { + required_instant_locks: _, + mut owned_prevouts, + mut transactions, + instant_locks, + } = history; + if transactions.is_empty() { + return Ok(HistoryReplay::default()); + } + let owns = |script: &dashcore::ScriptBuf| { + dashcore::Address::from_script(script, wallet_info.network()) + .ok() + .is_some_and(|address| { + wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .any(|account| { + !funds_account_type(account).is_contact_owned() + && account.contains_address(&address) + }) + }) + }; + owned_prevouts.retain(|_, output| owns(&output.script_pubkey)); + for account in wallet_info.accounts.all_funding_accounts() { + if !funds_account_type(account).is_contact_owned() { + for (outpoint, coin) in &account.utxos { + owned_prevouts.insert(*outpoint, coin.txout.clone()); + } + } + } + for stored in &mut transactions { + stored + .owned_inputs + .retain(|detail| owns(&detail.address.script_pubkey())); + for (vout, output) in stored.transaction.output.iter().enumerate() { + if owns(&output.script_pubkey) { + owned_prevouts.insert( + OutPoint::new(stored.transaction.txid(), vout as u32), + output.clone(), + ); + } + } + } + // Mined contexts retain the existing trusted local producer/store contract; + // they are not independently reverified transaction-inclusion proofs here. + // InstantSend observations require the opaque SPV evidence checked above. + let candidates = transactions + .iter() + .filter(|stored| { + matches!(stored.context, TransactionContext::Mempool) + && !instant_locks.contains_key(&stored.txid) + && !chain_final_losers.contains(&stored.txid) + }) + .map(|stored| stored.transaction.txid()) + .collect(); + let authorization = plan_history_authorization(&transactions, &candidates, &owned_prevouts)?; + // A backend will rebuild released credits from its committed projection. + // Until then, never replay the rejected body or restore its fallback record. + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !authorization.rejected.contains(&outpoint.txid)); + } + for mut account in wallet_info.accounts.all_accounts_mut() { + account + .transactions_mut() + .retain(|_, record| !authorization.rejected.contains(&record.transaction.txid())); + } + transactions.retain(|stored| !authorization.rejected.contains(&stored.transaction.txid())); + // Where the load projection parked each unspent outpoint; its keys are + // the outputs persistence still considers unspent. + let placed: HashMap = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + account.utxos.keys().map(move |outpoint| (*outpoint, owner)) + }) + .collect(); + // The same coins as persistence restored them; see `restore_persisted_coin_state`. + let persisted: HashMap = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.clone()) + .collect(); + let replay_txids: HashSet = transactions.iter().map(|stored| stored.txid).collect(); + let mut held: HashMap> = HashMap::new(); + // Detach raw records so the checker rebuilds reservations even for known mempool txids. + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + account.transactions_mut().retain(|txid, record| { + if replay_txids.contains(txid) { + held.entry(owner).or_default().push(record.clone()); + false + } else { + true + } + }); + } + // TODO(bound-load-history-replay): every stored record is replayed on each + // load; bounding it to records above the last chain lock needs care so + // finality and spend guards for older records are not lost. + // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed + // on every load with no expiry, so an authorized never-mined spend of a wallet + // outpoint keeps its reservation across load and rescan; only a conflicting + // IS-locked or confirmed spend releases it. Sibling of + // TODO(release-repair-spends-after-reorg) in the SQLite `core_history`. + let mut replay = replay_order(transactions); + for stored in &mut replay { + stored.context = match &stored.context { + TransactionContext::Mempool => instant_locks + .get(&stored.txid) + .filter(|lock| lock_matches_record(lock, stored)) + .map(|lock| TransactionContext::InstantSend(lock.clone())) + .unwrap_or(TransactionContext::Mempool), + context => context.clone(), + }; + } + let operands: Vec> = replay + .iter() + .map(|stored| { + ( + stored.txid, + &stored.transaction, + stored.owned_inputs.as_slice(), + ) + }) + .collect(); + let unsafe_amounts = unsafe_replay_amounts(wallet_info, &operands); + let contexts: HashMap<_, _> = replay + .iter() + .map(|stored| (stored.txid, stored.context.clone())) + .collect(); + let unsafe_txids: HashSet = unsafe_amounts.keys().copied().collect(); + // Losers leave the replay and the projection; `swept` also keeps their + // held raw records from coming back as fallbacks. + let plan = plan_replay_sweeps(wallet_info, &replay, &unsafe_txids); + tracing::debug!( + wallet_id = %hex::encode(wallet_info.wallet_id), + sweeps = plan.sweeps, + examined = plan.examined, + removed = plan.removed.len(), + "load: settled stored transaction conflicts before the history replay" + ); + let mut swept = plan.removed; + let mut sweeps = plan.effects; + replay.retain(|stored| !swept.contains(&stored.txid)); + let corrupt_mined: BTreeSet<_> = replay + .iter() + .filter(|stored| { + stored.context.block_info().is_some() && unsafe_txids.contains(&stored.txid) + }) + .map(|stored| stored.txid) + .collect(); + if !corrupt_mined.is_empty() { + return Err(HistoryReplayError::UnsafeMinedAmounts { + txids: corrupt_mined, + }); + } + // Verified IS evidence survives a mining-block reorg independently of the + // record's block context. Records are detached here, so this cannot sweep + // outside the common planner. Only surviving transactions retain the metadata. + for stored in &replay { + let lock = match &stored.context { + TransactionContext::InstantSend(lock) => Some(lock), + _ => instant_locks.get(&stored.txid), + }; + if let Some(lock) = lock.filter(|lock| lock_matches_record(lock, stored)) { + wallet_info.mark_instant_send_utxos(&stored.txid, lock); + } + } + let (replay, set_aside): (Vec<_>, Vec<_>) = replay + .into_iter() + .partition(|stored| !unsafe_txids.contains(&stored.txid)); + if !set_aside.is_empty() { + // Counts only: the txids of unconfirmed records are peer-supplied. + tracing::warn!( + wallet_id = %hex::encode(wallet_info.wallet_id), + skipped = set_aside.len(), + "stored transaction records with unsafe amounts left out of the shared load replay; the coins they spend stay reserved" + ); + } + // Reserve restored coins and recorded owned inputs absent from the + // projection: unsafe stored amounts do not prove the transaction invalid. + let reserved_coins: HashSet = set_aside + .iter() + .flat_map(|stored| &stored.transaction.input) + .map(|input| input.previous_output) + .filter(|outpoint| placed.contains_key(outpoint)) + .chain(set_aside.iter().flat_map(|stored| { + stored.owned_inputs.iter().filter_map(|detail| { + stored + .transaction + .input + .get(detail.index as usize) + .map(|input| input.previous_output) + }) + })) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !swept.contains(&outpoint.txid)); + } + stage_recorded_spent_inputs(wallet_info, &replay, &placed); + + for stored in &replay { + let result = wallet_info + .check_core_transaction( + &stored.transaction, + stored.context.clone(), + wallet, + true, + false, + ) + .await; + if !result.swept_transactions.is_empty() { + swept.extend(result.swept_transactions.iter().copied()); + sweeps.push(replay_sweep( + stored, + result.swept_transactions, + result.released_outpoints, + )); + } + drop_fallback_copies(wallet_info, stored, &placed); + } + + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + // Replay credits an output to the account whose pool derives it. A + // fallback copy `drop_fallback_copies` did not see credited by then is + // dropped here, so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) + }) + }) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !reserved_coins.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); + for (outpoint, coin) in &mut account.utxos { + if let Some(persisted) = persisted.get(outpoint) { + restore_persisted_coin_state(coin, persisted); + } + } + } + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + for mut original in held.remove(&owner).into_iter().flatten() { + if swept.contains(&original.txid) { + continue; + } + if let Some(context) = contexts.get(&original.txid) { + original.context = context.clone(); + } + if unsafe_amounts.contains_key(&original.txid) { + sanitize_unsafe_record(&mut original); + } + // The checker keys legacy bodies canonically; keep one logical record. + let canonical = original.transaction.txid(); + let replayed_key = if account.transactions().contains_key(&canonical) { + canonical + } else { + original.txid + }; + if let Some(replayed) = account.transactions_mut().get_mut(&replayed_key) { + replayed.label = original.label; + replayed.fee = original.fee.or(replayed.fee); + } else { + // Proof lookup can require a record with no currently attributable inputs or outputs. + account.transactions_mut().insert(original.txid, original); + } + } + } + // An unconfirmed record left out stays on record, as stored, in every + // account owning a recorded input or holding a coin it spends or pays: + // a settled conflict must find it there to sweep it and release its coins. + for stored in &set_aside { + if stored.context.block_info().is_some() { + continue; + } + let outputs = (0..stored.transaction.output.len() as u32) + .map(|vout| OutPoint::new(stored.txid, vout)); + let owners: HashSet = stored + .transaction + .input + .iter() + .map(|input| input.previous_output) + .chain(outputs) + .filter_map(|outpoint| placed.get(&outpoint).copied()) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + let owns_recorded_input = stored.owned_inputs.iter().any(|detail| { + stored + .transaction + .input + .get(detail.index as usize) + .is_some() + && account.contains_address(&detail.address) + }); + let owns_output = stored.transaction.output.iter().any(|output| { + Address::from_script(&output.script_pubkey, wallet.network) + .is_ok_and(|address| account.contains_address(&address)) + }); + if owners.contains(&owner) || owns_recorded_input || owns_output { + account + .transactions_mut() + .entry(stored.txid) + .or_insert_with(|| raw_record(stored, owner)); + } + } + } + // A settled spend's first sweep cannot reach competitors replayed later, + // nor the raw records restored above. The upstream sweep preserves + // ChainLock/InstantSend precedence; it runs only for a spend something + // unconfirmed still contests, because each sweep scans every live record. + let competitors = unconfirmed_spends(wallet_info); + for stored in &replay { + if !matches!(stored.context, TransactionContext::Mempool) + && is_contested(stored, &competitors) + { + let sweep = wallet_info.sweep_conflicts(&stored.transaction, &stored.context); + if !sweep.txids.is_empty() { + swept.extend(sweep.txids.iter().copied()); + sweeps.push(replay_sweep(stored, sweep.txids, sweep.released_outpoints)); + } + } + } + // A claim guards a coin only once it has left the UTXO set. Each reserved + // coin is claimed for the records still on record that spend it. + let mut spenders: BTreeMap> = BTreeMap::new(); + for stored in set_aside + .iter() + .filter(|stored| !swept.contains(&stored.txid)) + { + for input in &stored.transaction.input { + if reserved_coins.contains(&input.previous_output) { + spenders + .entry(input.previous_output) + .or_default() + .insert(stored.txid); + } + } + } + let mut claims: Vec<(OutPoint, Option)> = spenders + .into_iter() + .map(|(coin, spenders)| (coin, reservation_claimant(&spenders, &unsafe_amounts))) + .collect(); + claims.extend(irreversible_spend_claims(&replay)); + wallet_info.restore_spent_outpoints(&claims); + wallet_info.update_balance(); + let defeated = swept.clone(); + swept.extend(set_aside.iter().map(|stored| stored.txid)); + swept.extend(authorization.rejected.iter().copied()); + Ok(HistoryReplay { + recorded_conflicts: BTreeMap::new(), + authorization, + replayed: replay.len(), + retired: swept, + defeated, + sweeps, + withheld_asset_lock_inputs: BTreeMap::new(), + read_only: false, + }) +} + +fn sanitize_unsafe_record(record: &mut TransactionRecord) { + fn fits(values: impl IntoIterator) -> bool { + values + .into_iter() + .try_fold(0_i64, |sum, value| { + sum.checked_add(i64::try_from(value).ok()?) + }) + .is_some() + } + if !fits(record.input_details.iter().map(|detail| detail.value)) { + record.input_details.clear(); + } + if !fits( + record + .output_details + .iter() + .filter(|detail| is_owned(detail.role)) + .map(|detail| detail.value), + ) { + record.output_details.clear(); + } +} + +fn replay_sweep( + stored: &StoredTransaction, + txids: Vec, + released_outpoints: Vec, +) -> SweepBatch { + SweepBatch { + txids, + superseded_by: stored.txid, + winner_mined_height: stored.context.block_info().map(|block| block.height()), + released_outpoints, + } +} + +/// The claimant a reserved coin is held for, out of the records left out +/// that spend it. +/// +/// The wallet holds one claimant per coin and releases the coin when that +/// one is swept or abandoned; two different claimants make a claim nothing +/// releases. A record unsafe by recorded outputs can never confirm, so among +/// such records the smallest txid stands for all. A record unsafe only by +/// stored amounts may still confirm, so the coin is held for it. Two of +/// those leave no claimant whose removal proves the coin free: the coin +/// stays claimed until the next load (`None`). +fn reservation_claimant( + spenders: &BTreeSet, + unsafe_amounts: &HashMap, +) -> Option { + let mut may_confirm = spenders + .iter() + .filter(|txid| unsafe_amounts.get(*txid) == Some(&UnsafeAmounts::Stored)); + match (may_confirm.next(), may_confirm.next()) { + (Some(_), Some(_)) => None, + (Some(only), None) => Some(*only), + _ => spenders.first().copied(), + } +} + +/// One stored transaction as [`unsafe_replay_amounts`] reads it: its txid, its +/// body and the wallet-owned inputs its store recorded. +pub type ReplayOperands<'a> = (Txid, &'a Transaction, &'a [InputDetail]); + +/// Which amounts make a stored transaction unsafe to replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsafeAmounts { + /// Outputs of recorded transactions: its own, or the ones it spends. + /// Their bodies commit to these amounts and consensus rejects them, so it + /// can never confirm. + Outputs, + /// Only amounts stored beside the transaction: its recorded owned inputs, + /// or the restored coins it spends. The transaction itself may be valid. + Stored, +} + +/// The stored transactions whose amounts the wallet checker cannot take. +/// +/// The checker casts each unsigned operand to `i64` before a signed +/// subtraction, so an amount above `i64::MAX`, alone or summed, overflows it: +/// a panic where overflow is checked, a wrapped net amount where it is not. A +/// fitting net does not make an overflowing operand safe. Every source of an +/// operand is covered: the coins restored onto `wallet_info`, the outputs of +/// the other `records` (asset-lock credit outputs included) and the recorded +/// owned inputs. Each rejected transaction comes with the reason: an overflow +/// the recorded transaction bodies prove on their own is +/// [`UnsafeAmounts::Outputs`], one that depends on a stored value is +/// [`UnsafeAmounts::Stored`]. +pub fn unsafe_replay_amounts( + wallet_info: &ManagedWalletInfo, + records: &[ReplayOperands<'_>], +) -> HashMap { + /// Whether every value and their running sum fit `i64`. + fn fits(values: impl IntoIterator) -> bool { + let mut sum = 0_u64; + values.into_iter().all(|value| { + sum = sum.saturating_add(value); + sum <= i64::MAX as u64 + }) + } + + let mut amounts: HashMap = HashMap::new(); + let mut remember = |outpoint: OutPoint, value: u64| { + let known = amounts.entry(outpoint).or_insert(value); + *known = (*known).max(value); + }; + for account in wallet_info.accounts.all_funding_accounts() { + for (outpoint, coin) in &account.utxos { + remember(*outpoint, coin.txout.value); + } + } + // What each recorded body pays, left-out records included: an outpoint + // commits to its transaction, so these amounts hold whatever is stored. + let mut committed: HashMap = HashMap::new(); + for (txid, transaction, _) in records { + for (vout, output) in transaction.output.iter().enumerate() { + committed.insert(OutPoint::new(*txid, vout as u32), output.value); + } + } + let mut unsafe_amounts = HashMap::new(); + for (txid, transaction, owned_inputs) in records { + let credit_outputs = match &transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(payload)) => { + payload.credit_outputs.as_slice() + } + _ => &[], + }; + let outputs = transaction.output.iter().chain(credit_outputs); + let reason = if !fits(outputs.map(|output| output.value)) { + Some(UnsafeAmounts::Outputs) + } else if !fits(owned_inputs.iter().map(|detail| detail.value)) { + Some(UnsafeAmounts::Stored) + } else { + None + }; + if let Some(reason) = reason { + // A record left out credits nothing, so its outputs are no child's input amount. + unsafe_amounts.insert(*txid, reason); + continue; + } + for (vout, output) in transaction.output.iter().enumerate() { + remember(OutPoint::new(*txid, vout as u32), output.value); + } + for detail in *owned_inputs { + if let Some(input) = transaction.input.get(detail.index as usize) { + remember(input.previous_output, detail.value); + } + } + } + for (txid, transaction, _) in records { + let inputs_fit = |known: &HashMap| { + fits( + transaction + .input + .iter() + .filter_map(|input| known.get(&input.previous_output).copied()), + ) + }; + if inputs_fit(&amounts) { + continue; + } + if inputs_fit(&committed) { + unsafe_amounts.entry(*txid).or_insert(UnsafeAmounts::Stored); + } else { + unsafe_amounts.insert(*txid, UnsafeAmounts::Outputs); + } + } + unsafe_amounts +} + +/// What [`plan_replay_sweeps`] decided, and what deciding it cost. +#[derive(Debug, Default)] +struct SweepPlan { + /// Every transaction a settled stored one beats to an input, with its + /// unconfirmed descendants. + removed: HashSet, + /// Conflict sweeps run. + sweeps: usize, + /// Records those sweeps had to look through, summed over the sweeps. + examined: usize, + effects: Vec, +} + +/// Resolve the full history before a defeated lock can sweep another spender. +/// +/// Planning ahead of the replay is what keeps the losers out: replayed after +/// its parent was swept, a descendant that pays the wallet would be recorded +/// again, and no later sweep reaches it without the parent. +/// +/// The upstream sweep decides every conflict, so finality precedence stays +/// its own. It scans the account it runs in whole, so each settled record's +/// sweep is handed only what it could remove: the unconfirmed records still +/// spending one of its inputs, and their unconfirmed descendants. A locked +/// competitor a plain block cannot remove is handed again to each such block. +/// +/// `unsafe_txids` are planned on their inputs alone, like every other record. +fn plan_replay_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], + unsafe_txids: &HashSet, +) -> SweepPlan { + plan_replay_sweeps_where(wallet_info, records, unsafe_txids, |_| true) +} + +fn plan_replay_sweeps_where( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], + unsafe_txids: &HashSet, + eligible_winner: impl Fn(&StoredTransaction) -> bool, +) -> SweepPlan { + // The scratch wallet lends the sweep its rules and nothing to scan: its + // unconfirmed records move to the pool, the rest and its coins are dropped. + let mut scratch = wallet_info.clone(); + let mut pool: HashMap = HashMap::new(); + for mut account in scratch.accounts.all_accounts_mut() { + for (txid, record) in std::mem::take(account.transactions_mut()) { + if !record.is_confirmed() { + pool.entry(txid).or_insert(record); + } + } + } + for account in scratch.accounts.all_funding_accounts_mut() { + account.utxos.clear(); + } + // One account for every sweep lets it follow descendants across accounts. + let Some(owner) = sweep_account(&mut scratch).map(|account| funds_account_type(account)) else { + return SweepPlan::default(); + }; + for stored in records { + if stored.context.block_info().is_none() { + pool.insert(stored.txid, raw_record(stored, owner)); + } + } + // Who spends each outpoint, and who spends an output of each transaction. + let mut spenders: HashMap> = HashMap::new(); + let mut children: HashMap> = HashMap::new(); + for (txid, record) in &pool { + for input in &record.transaction.input { + let spent = input.previous_output; + spenders.entry(spent).or_default().push(*txid); + children.entry(spent.txid).or_default().push(*txid); + } + } + let mut winners: Vec<&StoredTransaction> = records + .iter() + .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| eligible_winner(stored)) + // A record with unsafe amounts can lose a conflict, never win one. + .filter(|stored| !unsafe_txids.contains(&stored.txid)) + .collect(); + winners.sort_by_key(|stored| { + let priority = match stored.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, stored.txid) + }); + let mut plan = SweepPlan::default(); + for stored in winners { + if plan.removed.contains(&stored.txid) { + continue; + } + // A record leaves the pool when handed over, so one already removed + // or already handed is skipped. + let mut handed = BTreeMap::new(); + let mut queue: Vec = stored + .transaction + .input + .iter() + .filter_map(|input| spenders.get(&input.previous_output)) + .flatten() + .copied() + .collect(); + while let Some(txid) = queue.pop() { + if txid == stored.txid { + continue; + } + if let Some(record) = pool.remove(&txid) { + handed.insert(txid, record); + queue.extend(children.get(&txid).into_iter().flatten()); + } + } + let Some(account) = sweep_account(&mut scratch).filter(|_| !handed.is_empty()) else { + continue; + }; + plan.sweeps += 1; + plan.examined += handed.len(); + *account.transactions_mut() = handed; + let sweep = scratch.sweep_conflicts(&stored.transaction, &stored.context); + if !sweep.txids.is_empty() { + plan.removed.extend(sweep.txids.iter().copied()); + plan.effects + .push(replay_sweep(stored, sweep.txids, sweep.released_outpoints)); + } + // Whatever the sweep left standing can still lose to a later winner. + if let Some(account) = sweep_account(&mut scratch) { + pool.extend(std::mem::take(account.transactions_mut())); + } + } + let survivors = records + .iter() + .filter(|record| !plan.removed.contains(&record.txid)); + let mut claimed: HashSet<_> = survivors + .flat_map(|record| { + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + }) + .collect(); + let inputs: HashMap<_, _> = records + .iter() + .map(|record| (record.txid, &record.transaction.input)) + .collect(); + for sweep in plan.effects.iter_mut().rev() { + sweep.released_outpoints.retain(|outpoint| { + !plan.removed.contains(&outpoint.txid) && !claimed.contains(outpoint) + }); + for txid in &sweep.txids { + if let Some(inputs) = inputs.get(txid) { + claimed.extend(inputs.iter().map(|input| input.previous_output)); + } + } + } + plan +} + +/// The funds account of a scratch wallet that planned sweeps run in. +fn sweep_account(scratch: &mut ManagedWalletInfo) -> Option<&mut ManagedCoreFundsAccount> { + scratch + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() +} + +/// A history record holding only what the conflict sweep reads: the context +/// and the inputs. The amounts and details are filler. +fn raw_record(stored: &StoredTransaction, owner: AccountType) -> TransactionRecord { + TransactionRecord::new( + stored.transaction.clone(), + owner, + stored.context.clone(), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) +} + +/// The unconfirmed transactions of `wallet_info` spending each outpoint. One +/// recorded in several accounts is still one spender. +fn unconfirmed_spends(wallet_info: &ManagedWalletInfo) -> HashMap> { + let mut seen = HashSet::new(); + let mut spends: HashMap> = HashMap::new(); + for record in wallet_info + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .filter(|record| !record.is_confirmed()) + { + if !seen.insert(record.txid) { + continue; + } + for input in &record.transaction.input { + spends + .entry(input.previous_output) + .or_default() + .push(record.txid); + } + } + spends +} + +/// Whether an unconfirmed transaction other than `stored` itself still spends +/// one of its inputs: only then can a conflict sweep for it remove anything. +fn is_contested(stored: &StoredTransaction, competitors: &HashMap>) -> bool { + stored.transaction.input.iter().any(|input| { + competitors + .get(&input.previous_output) + .is_some_and(|spenders| spenders.iter().any(|txid| *txid != stored.txid)) + }) +} + +/// Drop the projection's fallback copy of every output of `stored` that its +/// replay just credited to the account deriving it. +/// +/// Left until the end of the replay, the copy lets a later record spend the +/// coin in both accounts: the fallback account records a spend of a coin it +/// never owned, and the checker's wallet total counts the coin twice. +fn drop_fallback_copies( + wallet_info: &mut ManagedWalletInfo, + stored: &StoredTransaction, + placed: &HashMap, +) { + for vout in 0..stored.transaction.output.len() as u32 { + let outpoint = OutPoint::new(stored.txid, vout); + let Some(parked) = placed.get(&outpoint) else { + continue; + }; + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + let credited_elsewhere = accounts.iter().any(|account| { + funds_account_type(account) != *parked && account.utxos.contains_key(&outpoint) + }); + if credited_elsewhere { + for account in &mut accounts { + if funds_account_type(account) == *parked { + account.utxos.remove(&outpoint); + } + } + } + } +} + +/// Spend claims for the inputs of every irreversible record in `replay` whose +/// funding the replay did not see. +/// +/// Such a spend marks no coin spent: the coin is not in the projection and, +/// unless the store recorded the owned inputs, cannot be staged. Only the +/// observed spend guards it, and finality prunes that, so a funding delivered +/// later would credit the coin again. A claim outlives the pruning. +/// +/// Nothing retires a claim when a fork drops its claimant, so only a spend no +/// fork can drop is claimed: one in a chain-locked block or under an +/// InstantSend lock. A spend in a block above the restored chain lock keeps +/// the observed spend alone, which a fork drops with it; the next load claims +/// it once its block is chain-locked. A plain mempool spend can still be +/// evicted and is not claimed. Inputs that are not the wallet's are claimed as +/// well; such a claim guards nothing. +fn irreversible_spend_claims(replay: &[StoredTransaction]) -> Vec<(OutPoint, Option)> { + let replayed: HashSet = replay.iter().map(|stored| stored.txid).collect(); + replay + .iter() + .filter(|stored| { + matches!( + stored.context, + TransactionContext::InChainLockedBlock(_) | TransactionContext::InstantSend(_) + ) + }) + .flat_map(|stored| { + stored + .transaction + .input + .iter() + .map(|input| (input.previous_output, Some(stored.txid))) + }) + .filter(|(outpoint, _)| !replayed.contains(&outpoint.txid)) + .collect() +} + +/// Give a coin the replay kept the state persistence stored on it. +/// +/// The replay rebuilds a coin from its stored funding record, and the record +/// can know less than the coin: it keeps saying mempool after a height-only +/// confirmation, and a coin credited to another account than the one it was +/// parked in starts without its flags. The spend lock, the InstantSend status +/// and the trusted mark are restored as stored. +/// +/// A stored confirmation is restored, with its height, only for a coin the +/// replay left unconfirmed. Where the replay confirmed the coin, the record's +/// block stands: the record names the block, the coin holds a bare height. +/// Nothing is raised beyond what was stored. +fn restore_persisted_coin_state(coin: &mut Utxo, persisted: &Utxo) { + coin.is_locked |= persisted.is_locked; + coin.is_instantlocked |= persisted.is_instantlocked; + coin.is_trusted |= persisted.is_trusted; + if persisted.is_confirmed && !coin.is_confirmed { + coin.is_confirmed = true; + coin.height = persisted.height; + } +} + +/// Park every owned input a record spends whose funding no replayed record credits. +/// +/// Persistence excludes spent outputs from the load projection, so without +/// this a spender of a height-only funding row replays with no owned input: +/// it marks no coin spent, its sent amount is missing, and it gets no record +/// at all when it pays nothing to the wallet. The stored +/// [`StoredTransaction::owned_inputs`] are the evidence. Without them only +/// [`irreversible_spend_claims`] keeps a redelivered funding from re-crediting +/// the coin. Staged coins are never in `placed`, so the retention pass drops +/// whatever replay leaves behind. +fn stage_recorded_spent_inputs( + wallet_info: &mut ManagedWalletInfo, + records: &[StoredTransaction], + placed: &HashMap, +) { + let replayed: HashSet = records.iter().map(|record| record.txid).collect(); + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + for record in records { + for detail in &record.owned_inputs { + let Some(input) = record.transaction.input.get(detail.index as usize) else { + continue; + }; + let outpoint = input.previous_output; + if placed.contains_key(&outpoint) || replayed.contains(&outpoint.txid) { + continue; + } + let Some(account) = accounts + .iter_mut() + .find(|account| account.contains_address(&detail.address)) + else { + continue; + }; + account.utxos.entry(outpoint).or_insert_with(|| { + let txout = TxOut { + value: detail.value, + script_pubkey: detail.address.script_pubkey(), + }; + Utxo::new(outpoint, txout, detail.address.clone(), 0, false) + }); + } + } +} + +/// Whether `lock` really locks `stored`, so upgrading its context is safe. +/// +/// The lock map is keyed by the stored txid and a record's txid is stored +/// beside its transaction, so neither is proof on its own. A mismatch keeps +/// the stored context: the lock's conflict sweep must not drop history on the +/// strength of a lock that belongs to another transaction. +pub(crate) fn lock_matches_record(lock: &InstantLock, stored: &StoredTransaction) -> bool { + let transaction_txid = stored.transaction.txid(); + let matches = lock.txid == stored.txid && transaction_txid == stored.txid; + if !matches { + tracing::warn!( + record_txid = %stored.txid, + transaction_txid = %transaction_txid, + lock_txid = %lock.txid, + "persisted InstantSend lock does not match its transaction record; replaying without it" + ); + } + matches +} + +/// Order records as the chain would deliver them: every in-set parent ahead of +/// its children, otherwise confirmed by block position, then unconfirmed. +/// +/// Dependencies span both partitions: a parent's stored record can still say +/// mempool after it confirmed (a height-only confirmation never rewrites an +/// existing record), while its child's record is already confirmed. +fn replay_order(records: Vec) -> Vec { + let mut records = records; + records.sort_by_key(|record| { + let block = record.context.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) + }); + let index: HashMap = records + .iter() + .enumerate() + .map(|(position, record)| (record.txid, position)) + .collect(); + let mut children: Vec> = vec![Vec::new(); records.len()]; + let mut waiting_on: Vec = vec![0; records.len()]; + for (child, record) in records.iter().enumerate() { + let parents: BTreeSet = record + .transaction + .input + .iter() + .filter_map(|input| index.get(&input.previous_output.txid).copied()) + .filter(|parent| *parent != child) + .collect(); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); + } + } + // Sorted positions, so the smallest ready one is always next in chain order. + let mut ready: BTreeSet = (0..records.len()) + .filter(|position| waiting_on[*position] == 0) + .collect(); + let mut emitted = vec![false; records.len()]; + let mut order = Vec::with_capacity(records.len()); + while let Some(position) = ready.pop_first() { + emitted[position] = true; + order.push(position); + for &child in &children[position] { + waiting_on[child] -= 1; + if waiting_on[child] == 0 { + ready.insert(child); + } + } + } + // Unreachable for real transactions (txids cannot form a cycle); keep the + // rest in chain order rather than drop a reservation. + order.extend((0..records.len()).filter(|position| !emitted[*position])); + let mut slots: Vec> = records.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|position| slots[position].take()) + .collect() +} + +/// Account identity of a funds account, stable across replay mutations. +fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { + account.managed_account_type().to_account_type() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + use key_wallet::managed_account::transaction_record::TransactionRecord; + use key_wallet::mnemonic::Mnemonic; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// The test wallet, built from a fixed mnemonic: every txid a test derives + /// from its addresses is the same in each run, so no test depends on how + /// random txids happen to sort. + fn test_wallet(accounts: WalletAccountCreationOptions) -> Wallet { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap(); + Wallet::from_mnemonic(mnemonic, Network::Testnet, accounts).unwrap() + } + + fn authorize( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + mut tx: dashcore::Transaction, + ) -> dashcore::Transaction { + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let path = info + .accounts + .all_funding_accounts() + .into_iter() + .find_map(|account| account.address_derivation_path(address)) + .unwrap(); + let key = wallet.derive_private_key(&path).unwrap(); + for index in 0..tx.input.len() { + let hash = SighashCache::new(&tx) + .legacy_signature_hash(index, &address.script_pubkey(), 1) + .unwrap(); + let mut sig = key.sign(&hash.to_byte_array()).with_der(|der| der.to_vec()); + sig.push(1); + tx.input[index].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(sig).unwrap()) + .push_slice(key.public_key().to_compressed()) + .into_script(); + } + tx + } + + async fn checked_instant_lock( + transaction: &dashcore::Transaction, + ) -> (InstantLock, ReplayEvidence) { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ( + fixture.instant_lock().clone(), + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + }, + ) + } + + /// Stored history built from checker-produced records. + fn history( + records: Vec, + instant_locks: BTreeMap, + ) -> RecordedHistory { + RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: records.into_iter().map(StoredTransaction::from).collect(), + instant_locks, + } + } + + /// The test wallet and its first BIP44 receive address. + fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { + let wallet = test_wallet(WalletAccountCreationOptions::Default); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + (wallet, info, address) + } + + /// A confirmed funding with two outputs and a confirmed spend of output 0, + /// as checker-produced records, plus the persisted projection that holds + /// only the unspent output 1. + async fn confirmed_spend_fixture() -> ( + Wallet, + ManagedWalletInfo, + dashcore::Transaction, + Vec, + OutPoint, + ) { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([31; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let spent = OutPoint::new(funding.txid(), 0); + let available = OutPoint::new(funding.txid(), 1); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let spending = authorize(&wallet, &info, &address, spending); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, block(101), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + available, + Utxo { + outpoint: available, + txout: funding.output[1].clone(), + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + restored.update_balance(); + (wallet, restored, funding, records, spent) + } + + async fn stale_consumed_asset_lock_fixture() -> (ClientWalletStartState, OutPoint) { + use crate::wallet::asset_lock::tracked::TrackedAssetLock; + use crate::AssetLockFundingType; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + + let (wallet, mut info, funding, records, spent) = confirmed_spend_fixture().await; + let address = + Address::from_script(&funding.output[0].script_pubkey, wallet.network).unwrap(); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + spent, + Utxo::new( + spent, + funding.output[0].clone(), + address.clone(), + 100, + false, + ), + ); + let asset_lock = authorize( + &wallet, + &info, + &address, + Transaction { + version: 3, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 99_000, + script_pubkey: address.script_pubkey(), + }]), + )), + }, + ); + let out_point = OutPoint::new(asset_lock.txid(), 0); + let mut history = history(vec![records[0].clone()], BTreeMap::new()); + history.transactions.push(StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + }); + let tracked = TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 99_000, + status: AssetLockStatus::Consumed, + proof: None, + }; + info.update_balance(); + ( + ClientWalletStartState { + wallet, + wallet_info: info, + identity_manager: Default::default(), + unused_asset_locks: [(0, [(out_point, tracked)].into())].into(), + unconfirmed_outgoing_txs: vec![], + recorded_history: history, + dashpay_backfill: Default::default(), + }, + spent, + ) + } + + #[tokio::test] + async fn should_remove_stale_consumed_asset_lock_input_from_spendable_projection() { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + assert!(state + .wallet_info + .utxos() + .iter() + .any(|coin| coin.outpoint == spent)); + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let account = &state.wallet_info.accounts.standard_bip44_accounts[&0]; + assert_eq!( + ( + account + .spendable_utxos(200) + .iter() + .any(|coin| coin.outpoint == spent), + state.wallet_info.balance.total() + ), + (false, 20_000), + "send funding must exclude the consumed coin while preserving the unrelated coin" + ); + assert!( + !state.wallet_info.utxos().iter().any(|coin| coin.outpoint == spent), + "a terminal asset lock consumed this restored coin; a claim alone cannot make it unspendable" + ); + assert_eq!(state.wallet_info.balance.total(), 20_000); + } + + #[tokio::test] + async fn should_persist_hold_for_stale_consumed_asset_lock_input() { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + let outcome = + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let effects = outcome + .storage_effects(&state.recorded_history.transactions, &[]) + .unwrap(); + assert!( + matches!(effects.claims.get(&spent), Some(ReplayClaimEffect::Hold { .. })), + "the atomic adapter must durably repair the stale spent flag before publishing the wallet" + ); + } + + #[tokio::test] + async fn should_preserve_existing_claim_provenance_when_withholding_final_asset_lock_input() { + for claimant in [None, Some(Txid::from_byte_array([71; 32]))] { + for mined_height in [None, Some(123)] { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + let outcome = + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let existing = StoredReplayClaim { + outpoint: spent, + claimant, + mined_height, + }; + let effects = outcome + .storage_effects(&state.recorded_history.transactions, &[existing]) + .unwrap(); + assert_eq!( + effects.claims[&spent], + ReplayClaimEffect::Hold { + claimant, + mined_height + } + ); + assert!(effects.removed_records.is_empty()); + assert!(effects.removed_evidence.is_empty()); + } + } + } + + /// The bug the replay exists for: a funding transaction redelivered after + /// load (rescan, gap-limit rediscovery) must not resurrect an output a + /// confirmed spend already consumed. The control half pins that the same + /// redelivery does resurrect it when no history is replayed. + #[tokio::test] + async fn should_not_resurrect_confirmed_spent_output_on_funding_redelivery() { + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let redelivery = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + + let mut replayed = restored.clone(); + let count = replay_recorded_history( + &mut replayed, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await + .unwrap() + .replayed; + assert_eq!(count, 2); + replayed + .check_core_transaction(&funding, redelivery.clone(), &mut wallet, true, true) + .await; + assert!( + !replayed.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "a redelivered funding must not re-credit a confirmed-spent output" + ); + assert_eq!(replayed.balance.total(), 20_000); + + let mut unguarded = restored; + unguarded + .check_core_transaction(&funding, redelivery, &mut wallet, true, true) + .await; + assert!( + unguarded.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "control: without the replay the redelivery resurrects the output" + ); + } + + /// A spend whose funding survives only as a height row (no record to + /// replay, spent output excluded from the projection) still rebuilds its + /// spent mark from the stored owned inputs, and the staged coin does not + /// outlive the replay. Finality then prunes the observed spend, and the + /// redelivery is still not credited. + #[tokio::test] + async fn should_rebuild_spent_mark_for_height_only_funding_from_owned_inputs() { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let spender: Vec = records + .into_iter() + .filter(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .collect(); + assert_eq!(spender.len(), 1); + assert_eq!(spender[0].owned_inputs.len(), 1); + let redelivery = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + let replay = |transactions| RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions, + instant_locks: BTreeMap::new(), + }; + let finalize = |info: &mut ManagedWalletInfo| { + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty()); + }; + + let mut replayed = restored.clone(); + replay_recorded_history(&mut replayed, &mut wallet, replay(spender.clone())) + .await + .unwrap(); + assert!( + !replayed.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the staged input must not survive the replay" + ); + assert_eq!(replayed.balance.total(), 20_000); + finalize(&mut replayed); + replayed + .check_core_transaction(&funding, redelivery.clone(), &mut wallet, true, true) + .await; + assert!( + !replayed.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "a redelivered height-only funding must not re-credit a spent output" + ); + assert_eq!(replayed.balance.total(), 20_000); + } + + #[tokio::test] + async fn should_not_finalize_stored_history_by_checkpoint_height_alone() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::BlockHash; + + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let contexts: BTreeMap<_, _> = records + .iter() + .map(|record| (record.txid, record.context.clone())) + .collect(); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .extend(records.iter().map(|record| (record.txid, record.clone()))); + info.metadata.last_applied_chain_lock = Some(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: Default::default(), + }); + + replay_recorded_history(&mut info, &mut wallet, history(records, BTreeMap::new())) + .await + .unwrap(); + + for account in info.accounts.all_accounts() { + for (txid, context) in &contexts { + if let Some(record) = account.transactions().get(txid) { + assert_eq!( + &record.context, context, + "a checkpoint height does not prove this block is an ancestor" + ); + } + } + } + assert_eq!( + info.accounts.standard_bip44_accounts[&0] + .transactions() + .len(), + contexts.len() + ); + } + + #[tokio::test] + async fn should_fail_before_resend_without_persisted_lock_verification() { + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let mut spender = records[1].clone(); + spender.context = TransactionContext::Mempool; + let lock = InstantLock { + txid: spender.txid, + inputs: spender + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }; + let txid = spender.txid; + let outcome = replay_recorded_history( + &mut info, + &mut wallet, + history(vec![spender], BTreeMap::from([(lock.txid, lock)])), + ) + .await + .unwrap_err(); + assert!( + matches!(outcome, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&txid)) + ); + } + + #[tokio::test] + async fn should_retry_verified_history_before_unsafe_amounts_and_rebind_the_snapshot() { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(dashcore::Network::Testnet) + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + let (wallet, original, _, records, _) = confirmed_spend_fixture().await; + let mut locked = records[1].clone(); + locked.context = TransactionContext::Mempool; + locked.input_details[0].value = u64::MAX; + let mut competitor = records[1].clone(); + competitor.transaction.output[0].value -= 1; + competitor.txid = competitor.transaction.txid(); + let fixture = InstantLockFixture::new(Network::Testnet, &locked.transaction).unwrap(); + let raw = history( + vec![locked.clone(), competitor], + BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), + ); + + assert!(client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .is_none()); + let pending = replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + raw.clone(), + &ReplayEvidence::default(), + ) + .await + .unwrap_err(); + assert!( + matches!(pending, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); + + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + let evidence = ReplayEvidence { + instant_locks: BTreeMap::from([(proof.txid(), proof)]), + }; + let ready = replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + raw.clone(), + &evidence, + ) + .await + .unwrap(); + assert!( + !ready.defeated.contains(&locked.txid), + "unsafe stored arithmetic cannot let a plain block defeat verified InstantSend" + ); + + let mut observation = raw.clone(); + observation.required_instant_locks.insert(locked.txid); + observation.instant_locks.clear(); + assert!(matches!( + replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + observation.clone(), + &ReplayEvidence::default() + ) + .await, + Err(HistoryReplayError::MissingFinalityEvidence { .. }) + )); + let ready = replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + observation, + &evidence, + ) + .await + .unwrap(); + assert!(!ready.defeated.contains(&locked.txid)); + + for change_body in [false, true] { + let mut changed = raw.clone(); + if change_body { + changed.transactions[0].transaction.output[0].value -= 2; + } else { + changed + .instant_locks + .get_mut(&locked.txid) + .unwrap() + .signature = Default::default(); + } + let deferred = replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + changed, + &evidence, + ) + .await + .unwrap_err(); + assert!( + matches!(deferred, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); + } + } + + #[tokio::test] + async fn should_not_require_an_archived_instant_lock_for_a_chain_final_transaction() { + for exact_checkpoint in [false, true] { + let (mut wallet, mut info, _, mut records, _) = confirmed_spend_fixture().await; + let transaction = records[1].transaction.clone(); + let TransactionContext::InBlock(block) = records[1].context else { + unreachable!() + }; + if exact_checkpoint { + info.metadata.last_applied_chain_lock = Some(dashcore::ChainLock { + block_height: block.height(), + block_hash: block.block_hash(), + signature: Default::default(), + }); + } else { + records[1].context = TransactionContext::InChainLockedBlock(block); + } + let raw = InstantLock { + txid: transaction.txid(), + inputs: transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }; + let mut stored = history(records, [(transaction.txid(), raw)].into()); + stored.required_instant_locks.insert(transaction.txid()); + replay_recorded_history(&mut info, &mut wallet, stored) + .await + .unwrap(); + assert!( + !info.instant_send_locks().contains(&transaction.txid()), + "unused raw metadata must not mint authenticated IS state" + ); + } + } + + async fn assert_chain_final_rival_evidence(chain_final: bool, child_observation: u8) { + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let mut loser = records[1].clone(); + let raw = InstantLock { + txid: loser.txid, + inputs: loser + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }; + loser.context = TransactionContext::InstantSend(raw.clone()); + let mut winner = records[1].clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + if chain_final { + let TransactionContext::InBlock(block) = winner.context else { + unreachable!() + }; + winner.context = TransactionContext::InChainLockedBlock(block); + } + let mut child = loser.clone(); + child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); + child.txid = child.transaction.txid(); + let child_lock = InstantLock { + txid: child.txid, + inputs: vec![child.transaction.input[0].previous_output], + ..Default::default() + }; + child.context = TransactionContext::Mempool; + let mut stored = history( + vec![ + records[0].clone(), + loser.clone(), + winner.clone(), + child.clone(), + ], + [(loser.txid, raw), (child.txid, child_lock)].into(), + ); + if child_observation != 1 { + stored.instant_locks.remove(&child.txid); + } + if child_observation == 2 { + stored.required_instant_locks.insert(child.txid); + } + let result = replay_recorded_history(&mut info, &mut wallet, stored).await; + if chain_final && child_observation == 0 { + let outcome = result.unwrap(); + assert!(outcome.defeated.contains(&loser.txid)); + assert!(outcome.defeated.contains(&child.txid)); + assert!(!outcome.defeated.contains(&winner.txid)); + } else { + assert!(matches!( + result, + Err(HistoryReplayError::MissingFinalityEvidence { .. }) + )); + } + } + + #[tokio::test] + async fn should_remove_mempool_descendants_only_after_a_chain_final_rival_settles() { + assert_chain_final_rival_evidence(false, 0).await; + assert_chain_final_rival_evidence(true, 0).await; + } + + #[tokio::test] + async fn should_require_a_descendants_own_raw_instant_lock_after_parent_is_defeated() { + assert_chain_final_rival_evidence(true, 1).await; + } + + #[tokio::test] + async fn should_require_a_descendants_own_instant_send_marker_without_payload() { + assert_chain_final_rival_evidence(true, 2).await; + } + + #[tokio::test] + async fn should_reject_history_proven_on_another_network() { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::mainnet() + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let mut locked = records[1].clone(); + locked.context = TransactionContext::Mempool; + let fixture = InstantLockFixture::new(Network::Mainnet, &locked.transaction).unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + assert_eq!(proof.network(), dashcore::Network::Mainnet); + assert_eq!(info.network(), dashcore::Network::Testnet); + let outcome = replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history( + vec![locked.clone()], + BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), + ), + &ReplayEvidence { + instant_locks: BTreeMap::from([(locked.txid, proof)]), + }, + ) + .await + .unwrap_err(); + assert!( + matches!(outcome, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); + } + + #[tokio::test] + async fn should_retain_verified_mined_lock_when_history_is_rewound() { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let locked = records[1].clone(); + let fixture = InstantLockFixture::new(Network::Testnet, &locked.transaction).unwrap(); + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::testnet() + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history( + records, + BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), + ), + &ReplayEvidence { + instant_locks: BTreeMap::from([(locked.txid, proof)]), + }, + ) + .await + .unwrap(); + assert!(info.instant_send_locks().contains(&locked.txid)); + let before = info.accounts.standard_bip44_accounts[&0].transactions()[&locked.txid].clone(); + assert_eq!( + before.context, locked.context, + "InstantSend evidence must preserve the mined block context" + ); + info.truncate_above(100).unwrap(); + assert!( + info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&locked.txid), + "verified InstantSend survives removal of its mining block" + ); + } + + /// A persister that stores no per-input ownership leaves the replay + /// nothing to stage, so a spend whose funding is not in the history marks + /// no coin spent. A spend no fork can drop, in a chain-locked block or + /// under an InstantSend lock, must still keep its inputs from being + /// credited once finality has pruned the observed spend. A plain mempool + /// spend can still be evicted and guards nothing, and a coin no stored + /// record spends is credited as ever. + /// + /// A spend in a block above the restored chain lock gets no claim either: + /// a fork can still drop it, and nothing retires a claim. That leaves a + /// double credit open for a spend that is all of these at once: in a + /// block not yet chain-locked when the wallet loads, paying nothing to + /// the wallet, its funding not replayable, and its funding block + /// delivered again in the same session without its own block. The next + /// load claims it once its block is chain-locked. + #[tokio::test] + async fn should_guard_unreplayed_funding_against_irreversible_spends_only() { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let spender = records + .into_iter() + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let (lock, evidence) = checked_instant_lock(&spender.transaction).await; + let locked = TransactionContext::InstantSend(lock); + // A payment the stored history knows nothing about. + let unrelated = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([32; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 5_000, + script_pubkey: funding.output[0].script_pubkey.clone(), + }], + special_transaction_payload: None, + }; + + let chain_lock = |block_height| ChainLock { + block_height, + block_hash: BlockHash::from_byte_array([block_height as u8; 32]), + signature: BLSSignature::from([0; 96]), + }; + let in_block = spender.context.clone(); + assert_eq!(in_block.block_info().map(|block| block.height()), Some(101)); + + for (case, context, chain_locked, guarded) in [ + ("in a chain-locked block", in_block.clone(), Some(101), true), + ( + "in a block above the chain lock", + in_block, + Some(100), + false, + ), + ("InstantSend-locked", locked, None, true), + ("mempool", TransactionContext::Mempool, None, false), + ] { + let mut info = restored.clone(); + info.metadata.last_applied_chain_lock = chain_locked.map(chain_lock); + let without_ownership = StoredTransaction { + context, + owned_inputs: Vec::new(), + ..spender.clone() + }; + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![without_ownership], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history_with_evidence(&mut info, &mut wallet, history, &evidence) + .await + .unwrap(); + info.apply_chain_lock(chain_lock(300)); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty(), "{case}"); + + for (transaction, height) in [(&funding, 100), (&unrelated, 301)] { + info.check_core_transaction(transaction, block(height), &mut wallet, true, true) + .await; + } + + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert_eq!( + coins.contains_key(&spent), + !guarded, + "{case}: only a spend no fork can drop keeps its input from being credited" + ); + assert!( + coins.contains_key(&OutPoint::new(unrelated.txid(), 0)), + "{case}: a coin no stored record spends must be credited" + ); + } + } + + /// A block above the chain lock can be reorged out. When the fork drops a + /// restored spend, the coin it spent is unspent again, and a delivery of + /// its funding must credit it. + #[tokio::test] + async fn should_credit_funding_again_after_a_fork_drops_the_restored_spend() { + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let spender = records + .into_iter() + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + assert_eq!( + spender.context.block_info().map(|block| block.height()), + Some(101) + ); + let mut info = restored; + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + owned_inputs: Vec::new(), + ..spender + }], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut info, &mut wallet, history) + .await + .unwrap(); + + info.truncate_above(100).unwrap(); + let block = BlockInfo::new(100, BlockHash::from_byte_array([100; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the fork dropped the spend, so the coin is unspent again" + ); + } + + /// A host that keeps the coin of an unconfirmed spend as unspent, as the + /// SwiftData store does for mempool and InstantSend-locked spends, needs + /// no claim: the replay spends the restored coin and the account's own + /// mark keeps a later funding delivery from crediting it again. + #[tokio::test] + async fn should_guard_restored_coin_of_unconfirmed_spend_without_owned_inputs() { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let spender = records + .into_iter() + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + let mut info = restored; + let address = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .next() + .unwrap() + .address + .clone(); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + spent, + Utxo::new(spent, funding.output[0].clone(), address, 100, false), + ); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + context: TransactionContext::Mempool, + owned_inputs: Vec::new(), + ..spender + }], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut info, &mut wallet, history) + .await + .unwrap(); + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + + let block = BlockInfo::new(100, BlockHash::from_byte_array([100; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the unconfirmed spend still holds the coin" + ); + } + + #[tokio::test] + async fn should_rebuild_guards_for_raw_restored_records() { + for mempool in [false, true] { + let (mut wallet, mut restored, funding, mut records, spent) = + confirmed_spend_fixture().await; + if mempool { + records[1].context = TransactionContext::Mempool; + } + let mut held = records[1].clone(); + held.label = "retained label".into(); + held.fee = Some(1_000); + let txid = held.txid; + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(txid, held); + + let count = replay_recorded_history( + &mut restored, + &mut wallet, + history(records.clone(), BTreeMap::new()), + ) + .await + .unwrap() + .replayed; + restored + .check_core_transaction( + &funding, + records[0].context.clone(), + &mut wallet, + true, + true, + ) + .await; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.utxos.contains_key(&spent), "mempool={mempool}"); + assert_eq!(restored.balance.total(), 20_000); + assert_eq!(count, 2); + let held = &account.transactions()[&txid]; + assert_eq!(held.label, "retained label"); + assert_eq!(held.fee, Some(1_000)); + } + } + + #[tokio::test] + async fn should_retain_raw_record_without_matching_utxos() { + let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; + let mut held = records[1].clone(); + held.context = TransactionContext::Mempool; + held.label = "proof lookup".into(); + let txid = held.txid; + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(txid, held.clone()); + replay_recorded_history( + &mut restored, + &mut wallet, + history(vec![held], BTreeMap::new()), + ) + .await + .unwrap(); + assert_eq!( + restored.accounts.standard_bip44_accounts[&0].transactions()[&txid].label, + "proof lookup" + ); + } + + #[tokio::test] + async fn should_drop_raw_conflict_without_matching_utxos() { + let (mut wallet, restored, _, records, _) = confirmed_spend_fixture().await; + for instant in [false, true] { + let mut info = restored.clone(); + let mut loser = records[1].clone(); + loser.context = TransactionContext::Mempool; + let mut child = loser.clone(); + child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); + child.txid = child.transaction.txid(); + child.input_details.clear(); + let mut winner = loser.clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + let evidence = if instant { + let (lock, evidence) = checked_instant_lock(&winner.transaction).await; + winner.context = TransactionContext::InstantSend(lock); + evidence + } else { + winner.context = records[1].context.clone(); + ReplayEvidence::default() + }; + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(loser.txid, loser.clone()); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(child.txid, child.clone()); + replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + &evidence, + ) + .await + .unwrap(); + assert!( + !info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid), + "instant={instant}" + ); + assert!( + !info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&child.txid), + "instant={instant}" + ); + assert_eq!(info.balance.total(), 20_000); + } + } + + /// The replay takes an unconfirmed record already attached to the wallet + /// as unconfirmed, whether the history holds it or not: a stored spend in + /// a block retires it. The load therefore detaches the attached record of + /// a funding it keeps out of the replay. + #[tokio::test] + async fn should_settle_an_attached_unconfirmed_record_the_history_does_not_hold() { + let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; + let mut attached = records[1].clone(); + attached.context = TransactionContext::Mempool; + let mut winner = attached.clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + winner.context = records[1].context.clone(); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(attached.txid, attached.clone()); + + let outcome = replay_recorded_history( + &mut restored, + &mut wallet, + history(vec![winner], BTreeMap::new()), + ) + .await + .unwrap(); + + assert!(!restored.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&attached.txid)); + assert!(outcome.defeated.contains(&attached.txid)); + } + + /// A held, unattributable child of a conflict loser must go with its + /// parent even when the loser was attributable (its spent input staged + /// from the stored owned inputs) and a sweep already removed it before + /// the fallback restore. Covers both sibling replay orders. + #[tokio::test] + async fn should_drop_orphaned_raw_descendant_of_swept_conflict_in_either_order() { + let (mut wallet, restored, _, records, _) = confirmed_spend_fixture().await; + let mut loser = records[1].clone(); + loser.context = TransactionContext::Mempool; + let mut child = loser.clone(); + child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); + child.txid = child.transaction.txid(); + child.input_details.clear(); + let winner_with = |delta: u64| { + let mut winner = loser.clone(); + winner.transaction.output[0].value -= delta; + winner.txid = winner.transaction.txid(); + winner + }; + for winner_first in [true, false] { + let mut winner = (1..) + .map(winner_with) + .find(|winner| (winner.txid < loser.txid) == winner_first) + .unwrap(); + let (lock, evidence) = checked_instant_lock(&winner.transaction).await; + winner.context = TransactionContext::InstantSend(lock); + let mut info = restored.clone(); + let account = info.accounts.standard_bip44_accounts.get_mut(&0).unwrap(); + for raw in [&loser, &child] { + account.transactions_mut().insert(raw.txid, raw.clone()); + } + replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + &evidence, + ) + .await + .unwrap(); + let transactions = info.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions.contains_key(&loser.txid), + "winner_first={winner_first}: the loser must be swept" + ); + assert!( + !transactions.contains_key(&child.txid), + "winner_first={winner_first}: the loser's raw child must not come back" + ); + assert_eq!(info.balance.total(), 20_000, "winner_first={winner_first}"); + } + } + + /// A wallet-paying child of a conflict loser must go with its parent + /// wherever the InstantSend winner falls in the replay order. Between the + /// two, the loser is already swept when the child replays, and the checker + /// would rebuild the child from the output it pays the wallet. + #[tokio::test] + async fn should_drop_attributable_descendant_of_swept_conflict_for_any_replay_order() { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let pay = |previous_output: OutPoint, outputs: &[(u64, bool)]| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: outputs + .iter() + .map(|(value, ours)| TxOut { + value: *value, + script_pubkey: if *ours { + address.script_pubkey() + } else { + dashcore::ScriptBuf::new() + }, + }) + .collect(), + special_transaction_payload: None, + }, + ) + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([51; 32]), 0), + &[(100_000, true)], + ); + let coin = OutPoint::new(funding.txid(), 0); + let loser = pay(coin, &[(59_000, false), (40_000, true)]); + let loser_change = OutPoint::new(loser.txid(), 1); + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let funded = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + + for position in ["first", "between", "last"] { + // Unconfirmed records replay by txid, a child after its parent. + let (winner, child) = (1..64_u64) + .flat_map(|w| (1..64_u64).map(move |c| (w, c))) + .map(|(w, c)| { + ( + pay(coin, &[(68_000 - w, false), (30_000, true)]), + pay(loser_change, &[(39_000 - c, true)]), + ) + }) + .find(|(winner, child)| { + let (winner, loser, child) = (winner.txid(), loser.txid(), child.txid()); + match position { + "first" => winner < loser, + "between" => loser < winner && winner < child, + _ => loser < winner && child < winner, + } + }) + .unwrap(); + let child_output = OutPoint::new(child.txid(), 0); + let winner_change = OutPoint::new(winner.txid(), 1); + + // Every spend as stored: unconfirmed, the winner's lock kept apart. + let mut records = funded.clone(); + let mut losing = info.clone(); + for tx in [&loser, &child] { + records.extend( + losing + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + records.extend( + info.clone() + .check_core_transaction( + &winner, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 4, "{position}"); + // Persisted as unspent: the winner's change and the child's output. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (outpoint, txout) in [ + (winner_change, &winner.output[1]), + (child_output, &child.output[0]), + ] { + account.utxos.insert( + outpoint, + Utxo::new(outpoint, txout.clone(), address.clone(), 0, false), + ); + } + let (lock, evidence) = checked_instant_lock(&winner).await; + let locks = [(winner.txid(), lock)].into_iter().collect(); + + replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ) + .await + .unwrap(); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + for (name, txid) in [("loser", loser.txid()), ("child", child.txid())] { + assert!( + !account.transactions().contains_key(&txid), + "{position}: the {name} must be swept" + ); + } + assert!( + !account.utxos.contains_key(&child_output), + "{position}: the swept child's output must not stay selectable" + ); + assert!( + account.utxos.contains_key(&winner_change), + "{position}: the winner's change stays" + ); + assert_eq!(restored.balance.total(), 30_000, "{position}"); + } + } + + /// A confirmed winner must also take a loser's descendant that pays a + /// different account: the per-account conflict sweep reaches a descendant + /// only through a parent recorded in the same account. + #[tokio::test] + async fn should_remove_cross_account_descendant_of_conflicting_spend() { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let mut wallet = test_wallet(WalletAccountCreationOptions::BIP44AccountsOnly( + [0, 1].into_iter().collect(), + )); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses: Vec<_> = [0, 1] + .into_iter() + .map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }) + .collect(); + let pay = |previous_output: OutPoint, value: u64, script_pubkey| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([41; 32]), 0), + 100_000, + addresses[0].script_pubkey(), + ); + let coin = OutPoint::new(funding.txid(), 0); + let root = pay(coin, 99_000, addresses[0].script_pubkey()); + let child = pay( + OutPoint::new(root.txid(), 0), + 98_000, + addresses[1].script_pubkey(), + ); + let winner = pay(coin, 97_000, dashcore::ScriptBuf::new()); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([42; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + let mut winning = info.clone(); + for tx in [&root, &child] { + records.extend( + info.check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + records.extend( + winning + .check_core_transaction(&winner, block(101), &mut wallet, true, true) + .await + .new_records, + ); + crate::test_support::fold_wallet_records(&mut records); + assert_eq!(records.len(), 4); + + // Persisted as unspent: only the child's output, in the other account. + let child_output = OutPoint::new(child.txid(), 0); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&1) + .unwrap() + .utxos + .insert( + child_output, + Utxo::new( + child_output, + child.output[0].clone(), + addresses[1].clone(), + 0, + false, + ), + ); + + replay_recorded_history( + &mut restored, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await + .unwrap(); + + for (index, account) in &restored.accounts.standard_bip44_accounts { + for (name, txid) in [("root", root.txid()), ("child", child.txid())] { + assert!( + !account.transactions().contains_key(&txid), + "account {index}: the conflicting {name} must be swept" + ); + } + assert!( + !account.utxos.contains_key(&child_output), + "account {index}: a conflicting descendant must not stay selectable" + ); + } + assert_eq!(restored.balance.total(), 0); + } + + /// Stored amounts no valid transaction can carry must not reach the + /// checker's signed arithmetic: the record is left out of the replay, and + /// a restored coin it spends stays reserved instead of coming back + /// spendable. Only an unconfirmed record holding such a coin stays on + /// record, for a later conflict to find. + #[tokio::test] + async fn should_keep_unsafe_amounts_from_the_checker_and_reserve_the_coins_they_spend() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::managed_account::transaction_record::InputDetail; + use key_wallet::transaction_checking::BlockInfo; + + const HALF: u64 = 1 << 62; + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + for (case, outputs, staged, settled) in [ + ("single output", vec![2 * HALF], None, false), + ("summed outputs", vec![HALF, HALF], None, false), + ("staged input", vec![], Some(2 * HALF), false), + ("settled record", vec![2 * HALF], None, true), + ] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let pay = |previous_output: OutPoint, values: &[u64]| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: values + .iter() + .map(|value| TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let funding = pay(OutPoint::new(Txid::from_byte_array([61; 32]), 0), &[1]); + let coin = OutPoint::new(funding.txid(), 0); + let funded = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + // A staged input stands for a funding the store holds no record of. + let spent = if staged.is_some() { + OutPoint::new(Txid::from_byte_array([62; 32]), 0) + } else { + coin + }; + let unsafe_spend = authorize(&wallet, &info, &address, pay(spent, &outputs)); + let mut stored: Vec = + funded.into_iter().map(StoredTransaction::from).collect(); + stored.push(StoredTransaction { + txid: unsafe_spend.txid(), + transaction: unsafe_spend.clone(), + context: if settled { + block(101) + } else { + TransactionContext::Mempool + }, + stored_net_amount: None, + stored_direction: None, + owned_inputs: staged + .map(|value| InputDetail { + index: 0, + value, + address: address.clone(), + }) + .into_iter() + .collect(), + }); + // An unconfirmed spend never flips the coin's persisted spent flag. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + coin, + Utxo::new(coin, funding.output[0].clone(), address.clone(), 100, false), + ); + + let replayed = replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: stored, + instant_locks: BTreeMap::new(), + }, + ) + .await; + if settled { + assert!( + matches!(replayed, + Err(HistoryReplayError::UnsafeMinedAmounts { txids }) + if txids.contains(&unsafe_spend.txid()) + ), + "mined unsafe operands must fail load" + ); + continue; + } + let replayed = replayed.unwrap().replayed; + restored + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + + assert_eq!(replayed, 1, "{case}: only the funding is replayed"); + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert_eq!( + account.transactions().contains_key(&unsafe_spend.txid()), + !settled, + "{case}: an unconfirmed record with owned inputs stays on record" + ); + assert_eq!( + account.utxos.contains_key(&coin), + staged.is_some(), + "{case}: a coin the unsafe record spends stays reserved" + ); + assert_eq!( + restored.balance.total(), + u64::from(staged.is_some()), + "{case}" + ); + } + } + + /// An unconfirmed record with unsafe amounts that spends coins A and B, + /// the confirmed winner that beats it on A alone, and B's funding, which + /// the store holds no record of. The projection still holds both coins. + async fn unsafe_loser_fixture() -> ( + Wallet, + ManagedWalletInfo, + Vec, + StoredTransaction, + dashcore::Transaction, + [OutPoint; 2], + ) { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let spend = |spends: &[OutPoint], value: u64, script_pubkey| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + }; + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let fund = |byte, value| { + let unknown = OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + spend(&[unknown], value, address.script_pubkey()) + }; + let (funding_a, funding_b) = (fund(71, 10_000), fund(72, 20_000)); + let coins = [funding_a.txid(), funding_b.txid()].map(|txid| OutPoint::new(txid, 0)); + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let mut history: Vec = info + .check_core_transaction(&funding_a, block(100), &mut wallet, true, true) + .await + .new_records + .into_iter() + .map(StoredTransaction::from) + .collect(); + history.push(stored( + authorize( + &wallet, + &info, + &address, + spend(&coins, 1 << 63, address.script_pubkey()), + ), + TransactionContext::Mempool, + )); + let winner = stored( + spend(&coins[..1], 9_000, dashcore::ScriptBuf::new()), + block(101), + ); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (coin, funding) in coins.iter().zip([&funding_a, &funding_b]) { + account.utxos.insert( + *coin, + Utxo::new(*coin, funding.output[0].clone(), address.clone(), 99, false), + ); + } + (wallet, restored, history, winner, funding_b, coins) + } + + /// A stored confirmed winner settles an unsafe unconfirmed loser like any + /// other: the loser is gone and the coin only the loser spent is free. + #[tokio::test] + async fn should_release_other_input_of_unsafe_loser_beaten_by_stored_winner() { + let (mut wallet, mut restored, mut history, winner, _, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let loser = history[1].txid; + history.push(winner); + + replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: history, + instant_locks: BTreeMap::new(), + }, + ) + .await + .unwrap(); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser)); + assert!( + !account.utxos.contains_key(&coin_a), + "the winner spent this coin" + ); + assert!( + account.utxos.contains_key(&coin_b), + "the coin only the swept loser spent must be spendable again" + ); + assert_eq!(restored.balance.total(), 20_000); + } + + /// With no winner stored, the unsafe unconfirmed record keeps both coins + /// reserved and stays on record, so a winner delivered after the load + /// sweeps it and releases the coin it does not spend itself. + #[tokio::test] + async fn should_release_other_input_of_unsafe_loser_beaten_after_load() { + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut restored, history, winner, funding_b, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let loser = history[1].txid; + let funded_b = TransactionContext::InBlock(BlockInfo::new( + 99, + BlockHash::from_byte_array([99; 32]), + 99, + )); + + replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: history, + instant_locks: BTreeMap::new(), + }, + ) + .await + .unwrap(); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(account.transactions().contains_key(&loser)); + assert!(!account.utxos.contains_key(&coin_a) && !account.utxos.contains_key(&coin_b)); + assert_eq!(restored.balance.total(), 0); + // Control: while the record stands, its reservation holds. + let mut reserved = restored.clone(); + reserved + .check_core_transaction(&funding_b, funded_b.clone(), &mut wallet, true, true) + .await; + assert!( + !reserved.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coin_b), + "a coin the unsafe record spends must stay reserved" + ); + + let settled = restored + .check_core_transaction( + &winner.transaction, + winner.context.clone(), + &mut wallet, + true, + true, + ) + .await; + assert_eq!(settled.swept_transactions, vec![loser]); + assert_eq!(settled.released_outpoints, vec![coin_b]); + restored + .check_core_transaction(&funding_b, funded_b, &mut wallet, true, true) + .await; + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser)); + assert!( + !account.utxos.contains_key(&coin_a), + "the winner spent this coin" + ); + assert!( + account.utxos.contains_key(&coin_b), + "the released coin must be credited when its funding is seen" + ); + assert_eq!(restored.balance.total(), 20_000); + } + + /// Unsafe stored metadata must not erase ownership or spend reservations + /// when the store has already removed the funding coins from its projection. + #[tokio::test] + async fn should_reserve_absent_inputs_of_unsafe_stored_record_until_swept() { + let (mut wallet, mut restored, mut records, winner, funding_b, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + let address = account.utxos[&coin_b].address.clone(); + account.utxos.clear(); + let mut loser = records.pop().unwrap(); + loser.transaction.output[0].value = 25_000; + loser.transaction = authorize(&wallet, &restored, &address, loser.transaction); + loser.txid = loser.transaction.txid(); + loser.owned_inputs = vec![InputDetail { + index: 1, + value: 1 << 63, + address, + }]; + let loser_txid = loser.txid; + let outcome = replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![loser], + instant_locks: BTreeMap::new(), + }, + ) + .await + .unwrap(); + assert_eq!(outcome.replayed, 0); + assert!( + restored.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser_txid), + "the owned-input record must remain available for a later conflict sweep" + ); + let mut reserved = restored.clone(); + reserved + .check_core_transaction( + &funding_b, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + assert!( + !reserved.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coin_b), + "funding redelivery must not make an unsafe stored spender's input available" + ); + let settled = restored + .check_core_transaction(&winner.transaction, winner.context, &mut wallet, true, true) + .await; + assert_eq!(settled.swept_transactions, vec![loser_txid]); + assert_eq!(settled.released_outpoints, vec![coin_b]); + restored + .check_core_transaction( + &funding_b, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser_txid)); + assert!(!account.utxos.contains_key(&coin_a)); + assert!( + account.utxos.contains_key(&coin_b), + "the input unused by the winner becomes available after its claimant is swept" + ); + } + + /// Planning must not run one whole-history sweep per settled record that + /// merely shares an input with an unconfirmed one: a sweep is worth + /// running only while a competitor it could remove is still there, and it + /// looks only at the records it could remove. + #[test] + fn should_plan_sweeps_only_while_a_competitor_remains() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + const SPENDERS: u32 = 200; + let (_wallet, info, address) = wallet_with_receive_address(); + let outpoint = |index: u32| { + let mut bytes = [7_u8; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + OutPoint::new(Txid::from_byte_array(bytes), 0) + }; + let stored = |spends: Vec, lock_time, context| { + let transaction = Transaction { + version: 1, + lock_time, + input: spends + .into_iter() + .map(|index| TxIn { + previous_output: outpoint(index), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + } + }; + let confirmed: Vec = (0..SPENDERS) + .map(|index| { + let block = BlockInfo::new(100 + index, BlockHash::from_byte_array([1; 32]), 0); + stored(vec![index], index, TransactionContext::InBlock(block)) + }) + .collect(); + + // One unconfirmed record contests every confirmed spender at once. + let greedy = stored((0..SPENDERS).collect(), 0, TransactionContext::Mempool); + let mut records = confirmed.clone(); + records.push(greedy.clone()); + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); + assert_eq!(plan.removed, HashSet::from([greedy.txid])); + assert_eq!( + plan.sweeps, 1, + "the competitor is gone after the first sweep" + ); + + // Each confirmed spender has a competitor of its own, next to + // unconfirmed records nothing contests. + let competitors: Vec = (0..SPENDERS) + .map(|index| stored(vec![index], SPENDERS + index, TransactionContext::Mempool)) + .collect(); + let bystanders: Vec = (0..SPENDERS) + .map(|index| { + stored( + vec![SPENDERS + index], + 2 * SPENDERS + index, + TransactionContext::Mempool, + ) + }) + .collect(); + let records: Vec = + [&confirmed[..], &competitors[..], &bystanders[..]].concat(); + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); + assert_eq!( + plan.removed, + competitors.iter().map(|stored| stored.txid).collect() + ); + assert_eq!(plan.sweeps, SPENDERS as usize); + assert_eq!( + plan.examined, SPENDERS as usize, + "each sweep looks at its own competitor, never at the bystanders" + ); + } + + /// The host lists its unconfirmed sends for re-dispatch from its own rows, + /// so the list can name transactions the replay retired. Those must not + /// go out again; every other send must, stored or not. + #[tokio::test] + async fn should_withhold_retired_sends_from_re_dispatch() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, signing_info, address) = wallet_with_receive_address(); + let coin = |byte: u8| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let signing_wallet = wallet.clone(); + let spend = |spends: &[OutPoint], value: u64| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }, + ) + }; + // The winner took coin 1 in a block; the loser wanted coins 1 and 2. + let winner = spend(&[coin(1)], 9_000); + let loser = spend(&[coin(1), coin(2)], 29_000); + let descendant = spend(&[OutPoint::new(loser.txid(), 0)], 28_000); + let survivor = spend(&[coin(3)], 39_000); + let unsafe_send = spend(&[coin(4)], 1 << 63); + let unrecorded = spend(&[coin(5)], 59_000); + let stored = |transaction: &Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![ + stored(&winner, TransactionContext::InBlock(block)), + stored(&loser, TransactionContext::Mempool), + stored(&descendant, TransactionContext::Mempool), + stored(&survivor, TransactionContext::Mempool), + stored(&unsafe_send, TransactionContext::Mempool), + ], + instant_locks: BTreeMap::new(), + }; + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + // Persisted as unspent: every coin but the one the block spent. + for (byte, value) in [(2, 20_000), (3, 40_000), (4, 50_000), (5, 60_000)] { + let txout = TxOut { + value, + script_pubkey: address.script_pubkey(), + }; + account.utxos.insert( + coin(byte), + Utxo::new(coin(byte), txout, address.clone(), 99, false), + ); + } + + let outcome = replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + + for (retired, why) in [ + (&loser, "the loser of a settled conflict"), + (&descendant, "a descendant of that loser"), + ] { + assert!(outcome.retired.contains(&retired.txid()), "{why}"); + } + assert!( + outcome.retired.contains(&unsafe_send.txid()), + "a record with unsafe amounts must not reach the checker by re-dispatch either" + ); + assert_eq!( + outcome.defeated, + HashSet::from([loser.txid(), descendant.txid()]), + "a record left out for unsafe amounts lost no conflict" + ); + let sends = vec![ + loser.clone(), + descendant.clone(), + survivor.clone(), + unsafe_send.clone(), + unrecorded.clone(), + ]; + let resendable: Vec = outcome + .resendable(sends) + .iter() + .map(Transaction::txid) + .collect(); + assert_eq!( + resendable, + vec![survivor.txid(), unrecorded.txid()], + "a surviving stored send and one the history never held still go out" + ); + } + + /// Planning by whole-history sweeps, kept as the reference the narrowed + /// planner must agree with: every unconfirmed stored record sits in the + /// scratch account, and every settled record sweeps all of it. + fn plan_by_whole_history_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], + unsafe_txids: &HashSet, + ) -> HashSet { + let mut conflicts = wallet_info.clone(); + let account = conflicts + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + .unwrap(); + let owner = funds_account_type(account); + account.transactions_mut().extend( + records + .iter() + .filter(|stored| stored.context.block_info().is_none()) + .map(|stored| (stored.txid, raw_record(stored, owner))), + ); + let mut winners: Vec<&StoredTransaction> = records + .iter() + .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| !unsafe_txids.contains(&stored.txid)) + .collect(); + winners.sort_by_key(|stored| { + let priority = match stored.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, stored.txid) + }); + let mut removed = HashSet::new(); + for stored in winners { + if !removed.contains(&stored.txid) { + let sweep = conflicts.sweep_conflicts(&stored.transaction, &stored.context); + removed.extend(sweep.txids); + } + } + removed + } + + /// Handing each sweep only the records it could remove must not change + /// what planning decides. Compared against whole-history sweeps on a + /// fixed InstantSend shape and on generated histories mixing winners, + /// competitors, descendants, bystanders and locked competitors. + #[test] + fn should_plan_the_same_removals_as_whole_history_sweeps() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + let (_wallet, info, address) = wallet_with_receive_address(); + let coin = |index: u8| OutPoint::new(Txid::from_byte_array([index; 32]), 0); + let block = |height| BlockInfo::new(height, BlockHash::from_byte_array([1; 32]), 0); + let stored = |spends: Vec, lock_time: u32, settlement: u8| { + let transaction = Transaction { + version: 1, + lock_time, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let txid = transaction.txid(); + let context = match settlement { + 0 => TransactionContext::Mempool, + 1 => TransactionContext::InstantSend(InstantLock { + txid, + inputs: spends, + ..Default::default() + }), + 2 => TransactionContext::InBlock(block(100 + lock_time)), + _ => TransactionContext::InChainLockedBlock(block(100 + lock_time)), + }; + StoredTransaction { + txid, + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + } + }; + + // A plain block cannot overrule a lock; a chain lock can, and takes + // the locked competitor's unconfirmed child with it. + let competitor = stored(vec![coin(1)], 1, 1); + let child = stored(vec![OutPoint::new(competitor.txid, 0)], 2, 0); + for (settlement, expected) in [ + (2, HashSet::new()), + (3, HashSet::from([competitor.txid, child.txid])), + ] { + let records = [ + stored(vec![coin(1)], 0, settlement), + competitor.clone(), + child.clone(), + ]; + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); + assert_eq!(plan.removed, expected, "winner settlement {settlement}"); + assert_eq!( + plan_by_whole_history_sweeps(&info, &records, &HashSet::new()), + expected + ); + } + + // How many generated histories held each shape, so none goes untested. + let (mut swept, mut swept_descendant, mut locked_survivor, mut bystander) = (0, 0, 0, 0); + for seed in 0..500 { + let mut rng = StdRng::seed_from_u64(seed); + let mut records: Vec = Vec::new(); + for index in 0..rng.gen_range(2..=14) { + let spends: BTreeSet = (0..rng.gen_range(1..=2)) + .map(|_| { + if !records.is_empty() && rng.gen_bool(0.35) { + OutPoint::new(records[rng.gen_range(0..records.len())].txid, 0) + } else { + coin(rng.gen_range(0..6)) + } + }) + .collect(); + let settlement = match rng.gen_range(0..10) { + 0..=4 => 0, + 5..=6 => 1, + 7..=8 => 2, + _ => 3, + }; + records.push(stored(spends.into_iter().collect(), index, settlement)); + } + let unsafe_txids: HashSet = records + .iter() + .filter(|_| rng.gen_bool(0.1)) + .map(|stored| stored.txid) + .collect(); + + let expected = plan_by_whole_history_sweeps(&info, &records, &unsafe_txids); + let plan = plan_replay_sweeps(&info, &records, &unsafe_txids); + assert_eq!(plan.removed, expected, "seed {seed}"); + + let shares_input = |a: &StoredTransaction, b: &StoredTransaction| { + a.txid != b.txid + && a.transaction.input.iter().any(|input| { + b.transaction + .input + .iter() + .any(|other| other.previous_output == input.previous_output) + }) + }; + swept += usize::from(!expected.is_empty()); + swept_descendant += usize::from(records.iter().any(|stored| { + expected.contains(&stored.txid) + && stored + .transaction + .input + .iter() + .any(|input| expected.contains(&input.previous_output.txid)) + })); + locked_survivor += usize::from(records.iter().any(|locked| { + matches!(locked.context, TransactionContext::InstantSend(_)) + && !expected.contains(&locked.txid) + && records.iter().any(|winner| { + matches!(winner.context, TransactionContext::InBlock(_)) + && !unsafe_txids.contains(&winner.txid) + && shares_input(winner, locked) + }) + })); + bystander += usize::from(records.iter().any(|stored| { + stored.context.block_info().is_none() + && !expected.contains(&stored.txid) + && !records.iter().any(|other| shares_input(other, stored)) + })); + } + for (shape, histories) in [ + ("a swept competitor", swept), + ("a swept descendant", swept_descendant), + ( + "a locked competitor surviving a plain block", + locked_survivor, + ), + ("an uncontested bystander", bystander), + ] { + assert!(histories >= 25, "only {histories} histories held {shape}"); + } + } + + /// A wallet with BIP44 accounts 0 and 1 and a receive address of each. + fn two_account_wallet() -> (Wallet, ManagedWalletInfo, [dashcore::Address; 2]) { + let wallet = test_wallet(WalletAccountCreationOptions::BIP44AccountsOnly( + [0, 1].into_iter().collect(), + )); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses = [0, 1].map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }); + (wallet, info, addresses) + } + + /// The projection can park a coin in a fallback account while its funding, + /// replayed, credits it to the account that derives it. A stored spend of + /// that coin must still count it once: summed over both copies, the + /// checker's wallet total doubles and a large coin overflows it. + #[tokio::test] + async fn should_spend_coin_parked_in_fallback_account_once() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + for value in [1_u64 << 62, 100_000] { + let (mut wallet, restored, addresses) = two_account_wallet(); + let pay = |previous_output: OutPoint, value: u64| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: addresses[1].script_pubkey(), + }], + special_transaction_payload: None, + }; + let funding = pay(OutPoint::new(Txid::from_byte_array([81; 32]), 0), value); + let coin = OutPoint::new(funding.txid(), 0); + let child = authorize(&wallet, &restored, &addresses[1], pay(coin, 1)); + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let block = BlockInfo::new(100, BlockHash::from_byte_array([82; 32]), 100); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![ + stored(funding.clone(), TransactionContext::InBlock(block)), + stored(child.clone(), TransactionContext::Mempool), + ], + instant_locks: BTreeMap::new(), + }; + let mut restored = restored; + // Parked in account 0, though account 1 derives its address. + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + coin, + Utxo::new( + coin, + funding.output[0].clone(), + addresses[1].clone(), + 100, + false, + ), + ); + + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + + let [fallback, owner] = + [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); + assert!( + !fallback.utxos.contains_key(&coin) && !owner.utxos.contains_key(&coin), + "{value}: the stored spend took the coin" + ); + assert!( + !fallback.transactions().contains_key(&child.txid()), + "{value}: the fallback account never owned the coin the spend took" + ); + assert_eq!( + owner.transactions()[&child.txid()].net_amount, + 1 - value as i64, + "{value}: the spend counts the coin once" + ); + } + } + + /// Two unconfirmed records with unsafe amounts that both spend one shared + /// coin, each next to a coin of its own, replayed onto a projection + /// holding all three coins. A record is unsafe by its own output, or, + /// where `by_stored_amounts` says so, only by the input amount the store + /// recorded beside an ordinary transaction. + /// + /// Returns the wallet after the replay, the two records ordered by txid, + /// the shared coin with its funding, each record's own coin, and which + /// of the ordered records is unsafe by stored amounts only. + async fn overlapping_unsafe_spends_fixture( + by_stored_amounts: [bool; 2], + ) -> ( + Wallet, + ManagedWalletInfo, + [dashcore::Transaction; 2], + (OutPoint, dashcore::Transaction), + [OutPoint; 2], + [bool; 2], + ) { + use dashcore::{Transaction, TxIn}; + + let (mut wallet, _, address) = wallet_with_receive_address(); + let spend = |spends: &[OutPoint], value: u64| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let fundings = [(85, 10_000), (86, 20_000), (87, 30_000)].map(|(byte, value)| { + spend( + &[OutPoint::new(Txid::from_byte_array([byte; 32]), 0)], + value, + ) + }); + let [shared, one, other] = [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); + let value = |stored: bool| if stored { 25_000 } else { 1 << 63 }; + let mut spends = [ + ( + spend(&[shared, one], value(by_stored_amounts[0])), + one, + by_stored_amounts[0], + ), + ( + spend(&[shared, other], value(by_stored_amounts[1])), + other, + by_stored_amounts[1], + ), + ]; + let (_, signing_info, _) = wallet_with_receive_address(); + for (spend, _, _) in &mut spends { + *spend = authorize(&wallet, &signing_info, &address, spend.clone()); + } + spends.sort_by_key(|(spend, _, _)| spend.txid()); + let own = [spends[0].1, spends[1].1]; + let stored_only = [spends[0].2, spends[1].2]; + let unsafe_spends = spends.map(|(spend, _, _)| spend); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (coin, funding) in [shared, one, other].iter().zip(&fundings) { + account.utxos.insert( + *coin, + Utxo::new(*coin, funding.output[0].clone(), address.clone(), 99, false), + ); + } + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: unsafe_spends + .iter() + .zip(stored_only) + .map(|(transaction, stored_only)| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: if stored_only { + vec![InputDetail { + index: 0, + value: 1 << 63, + address: address.clone(), + }] + } else { + Vec::new() + }, + }) + .collect(), + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + assert_eq!( + restored.balance.total(), + 0, + "every coin an unsafe record spends is reserved" + ); + let [funding, _, _] = fundings; + ( + wallet, + restored, + unsafe_spends, + (shared, funding), + own, + stored_only, + ) + } + + /// A confirmed spend of `coins`, delivered to `info` after the load. + async fn deliver_confirmed_spend( + info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + coins: &[OutPoint], + ) -> key_wallet::transaction_checking::TransactionCheckResult { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let winner = Transaction { + version: 1, + lock_time: 0, + input: coins + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); + info.check_core_transaction( + &winner, + TransactionContext::InBlock(block), + wallet, + true, + true, + ) + .await + } + + /// Whether a redelivery of `funding` credits `coin` to the wallet. + async fn redelivery_credits( + info: &ManagedWalletInfo, + wallet: &mut Wallet, + funding: &dashcore::Transaction, + coin: &OutPoint, + ) -> bool { + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let mut info = info.clone(); + let block = BlockInfo::new(99, BlockHash::from_byte_array([99; 32]), 99); + info.check_core_transaction( + funding, + TransactionContext::InBlock(block), + wallet, + true, + true, + ) + .await; + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(coin) + } + + /// Two records left out for unsafe amounts can spend the same restored + /// coin. Once a settled transaction has swept both, nothing spends the + /// coin any more: it must be released and credited when its funding is + /// seen again. + #[tokio::test] + async fn should_release_coin_shared_by_two_unsafe_records_once_both_are_swept() { + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([false, false]).await; + assert!(!redelivery_credits(&restored, &mut wallet, &funding, &shared).await); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &own).await; + + let mut swept = settled.swept_transactions.clone(); + swept.sort_unstable(); + assert_eq!(swept, unsafe_spends.map(|spend| spend.txid())); + assert_eq!( + settled.released_outpoints, + vec![shared], + "the coin only the swept records spent must be released" + ); + assert!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + "the released coin must be credited when its funding is seen" + ); + } + + /// Stored unconfirmed parents pay the wallet `1 << 62` twice; two signed + /// spends consume both parent outputs and one genuine coin. The parents' bodies prove the spends take in more than + /// can exist, so neither spend can ever confirm, and once a settled + /// transaction has swept both, the genuine coin must be released. + #[tokio::test] + async fn should_release_coin_shared_by_spends_of_impossible_recorded_amounts() { + use dashcore::{Transaction, TxIn}; + + for one_parent in [false, true] { + let (mut wallet, signing_info, address) = wallet_with_receive_address(); + let pay = |spends: &[OutPoint], values: &[u64]| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: values + .iter() + .map(|value| TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let foreign = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + // One parent with both outputs is itself left out for its own + // outputs; its body still proves what its children take in. + let parents = if one_parent { + vec![pay(&[foreign(81)], &[1 << 62, 1 << 62])] + } else { + vec![ + pay(&[foreign(81)], &[1 << 62]), + pay(&[foreign(82)], &[1 << 62]), + ] + }; + let fakes: Vec = parents + .iter() + .flat_map(|parent| { + (0..parent.output.len() as u32).map(|vout| OutPoint::new(parent.txid(), vout)) + }) + .collect(); + let fundings = [ + pay(&[foreign(85)], &[10_000]), + pay(&[foreign(86)], &[20_000]), + pay(&[foreign(87)], &[30_000]), + ]; + let [genuine, one, other] = + [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); + let forged = [(one, 5_000), (other, 6_000)].map(|(own, value)| { + authorize( + &wallet, + &signing_info, + &address, + pay(&[fakes[0], fakes[1], genuine, own], &[value]), + ) + }); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for funding in parents.iter().chain(&fundings) { + for (vout, txout) in funding.output.iter().enumerate() { + let coin = OutPoint::new(funding.txid(), vout as u32); + account.utxos.insert( + coin, + Utxo::new(coin, txout.clone(), address.clone(), 0, false), + ); + } + } + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: parents + .iter() + .chain(&forged) + .map(|transaction| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }) + .collect(), + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + assert!(!redelivery_credits(&restored, &mut wallet, &fundings[0], &genuine).await); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &[one, other]).await; + + let mut swept = settled.swept_transactions.clone(); + swept.sort_unstable(); + let mut expected = forged.map(|spend| spend.txid()); + expected.sort_unstable(); + assert_eq!(swept, expected, "one parent: {one_parent}"); + assert!( + redelivery_credits(&restored, &mut wallet, &fundings[0], &genuine).await, + "one parent: {one_parent}: nothing spends the genuine coin any more" + ); + } + } + + /// The wallet can name one claimant per reserved coin, so a coin shared + /// by records unsafe by their own outputs is held for the one with the + /// smaller txid. Accepted consequence: + /// sweeping that record alone drops the guard although the other record + /// still names the coin, and sweeping the other one alone drops nothing. + /// Neither is reported as a release while a record on file spends the + /// coin. A record with unsafe amounts can never confirm, so neither + /// costs funds. + #[tokio::test] + async fn should_hold_shared_coin_for_the_unsafe_record_with_the_smaller_txid() { + for (swept, drops_guard) in [(0, true), (1, false)] { + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([false, false]).await; + + let settled = + deliver_confirmed_spend(&mut restored, &mut wallet, &own[swept..=swept]).await; + + assert_eq!( + settled.swept_transactions, + vec![unsafe_spends[swept].txid()] + ); + assert_eq!( + settled.released_outpoints, + vec![], + "record {swept} swept alone: the other record still spends the coin" + ); + assert_eq!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + drops_guard, + "record {swept} swept alone" + ); + } + } + + /// A coin the replay moves from its fallback account to the account that + /// derives it keeps what persistence stored on it: the spend lock and the + /// InstantSend status. + #[tokio::test] + async fn should_keep_restored_flags_of_coin_moved_from_fallback_account() { + use dashcore::{Transaction, TxIn}; + + let (mut wallet, mut restored, addresses) = two_account_wallet(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([88; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: addresses[1].script_pubkey(), + }], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let mut parked = Utxo::new( + coin, + funding.output[0].clone(), + addresses[1].clone(), + 0, + false, + ); + parked.is_locked = true; + parked.is_instantlocked = true; + // Parked in account 0, though account 1 derives its address. + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert(coin, parked); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }; + + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + + let [fallback, owner] = + [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); + assert!(!fallback.utxos.contains_key(&coin)); + let moved = &owner.utxos[&coin]; + assert!(moved.is_locked, "a locked coin must stay locked"); + assert!( + moved.is_instantlocked, + "an InstantSend-locked coin must stay so" + ); + } + + /// The replay rebuilds a coin from its stored funding record, which can + /// be older than the coin: a height-only confirmation never rewrites the + /// record. The confirmation persistence stored on the coin must survive, + /// nothing may be promoted beyond it, and where the replay itself + /// confirmed the coin, the record's block stands. + #[tokio::test] + async fn should_restore_only_the_confirmation_persistence_stored_on_a_coin() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + // (case, persisted confirmation, stored record, expected confirmation) + for (case, persisted, recorded, expected) in [ + ( + "stale mempool record", + Some(99), + TransactionContext::Mempool, + Some(99), + ), + ("unconfirmed coin", None, TransactionContext::Mempool, None), + ( + "record names another block", + Some(90), + in_block(100), + Some(100), + ), + ] { + let (mut wallet, _, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([89; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let mut stored_coin = Utxo::new( + coin, + funding.output[0].clone(), + address.clone(), + persisted.unwrap_or(0), + false, + ); + stored_coin.is_confirmed = persisted.is_some(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert(coin, stored_coin); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding, + context: recorded, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }; + + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); + + let kept = &restored.accounts.standard_bip44_accounts[&0].utxos[&coin]; + assert_eq!(kept.is_confirmed.then_some(kept.height), expected, "{case}"); + assert!(!kept.is_locked && !kept.is_instantlocked, "{case}"); + } + } + + /// A record unsafe only by amounts the store recorded beside it holds a + /// valid transaction that may still confirm, so the coin it spends must + /// stay guarded while it stands, whichever other record is swept. Two + /// such records sharing a coin leave no single claimant to name: the + /// coin then stays guarded for the rest of the run, even once both are + /// swept. + #[tokio::test] + async fn should_hold_shared_coin_while_a_record_that_may_confirm_spends_it() { + for swept in [&[0][..], &[1], &[0, 1]] { + let (mut wallet, mut restored, _, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([true, true]).await; + let coins: Vec = swept.iter().map(|index| own[*index]).collect(); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &coins).await; + + assert_eq!(settled.swept_transactions.len(), swept.len()); + assert_eq!(settled.released_outpoints, vec![], "swept {swept:?}"); + assert!( + !redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + "swept {swept:?}: the coin stays guarded" + ); + } + + // Next to a record that can never confirm, the coin is held for the + // one that may, whichever of the two has the smaller txid. + for by_stored_amounts in [[true, false], [false, true]] { + for swept in [0, 1] { + let (mut wallet, mut restored, _, (shared, funding), own, stored_only) = + overlapping_unsafe_spends_fixture(by_stored_amounts).await; + + deliver_confirmed_spend(&mut restored, &mut wallet, &own[swept..=swept]).await; + + assert_eq!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + stored_only[swept], + "{by_stored_amounts:?}, record {swept} swept: only removing the record \ + that may confirm drops the guard" + ); + } + } + } + + /// A transaction recorded in two accounts is one spender of its inputs, + /// not a competitor of itself: with no other spender it is uncontested. + #[tokio::test] + async fn should_not_count_multi_account_record_as_its_own_competitor() { + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, addresses) = two_account_wallet(); + let pay = |previous_output: OutPoint, value: u64, to: usize| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: addresses[to].script_pubkey(), + }], + special_transaction_payload: None, + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([83; 32]), 0), + 100_000, + 0, + ); + let coin = OutPoint::new(funding.txid(), 0); + let transfer = pay(coin, 99_000, 1); + let locked = TransactionContext::InstantSend(InstantLock { + txid: transfer.txid(), + inputs: vec![coin], + ..Default::default() + }); + let block = BlockInfo::new(100, BlockHash::from_byte_array([84; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + info.check_core_transaction(&transfer, locked.clone(), &mut wallet, true, true) + .await; + for index in [0, 1] { + assert!( + info.accounts.standard_bip44_accounts[&index] + .transactions() + .contains_key(&transfer.txid()), + "the transfer must be on record in account {index}" + ); + } + let stored = StoredTransaction { + txid: transfer.txid(), + transaction: transfer, + context: locked, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + assert!(!is_contested(&stored, &unconfirmed_spends(&info))); + } + + /// An empty history leaves the restored projection untouched. + #[tokio::test] + async fn should_leave_projection_untouched_without_history() { + let (mut wallet, restored, _, _, _) = confirmed_spend_fixture().await; + let mut info = restored.clone(); + let count = replay_recorded_history(&mut info, &mut wallet, RecordedHistory::default()) + .await + .unwrap() + .replayed; + assert_eq!(count, 0); + assert_eq!(info.balance.total(), restored.balance.total()); + assert_eq!( + info.accounts.standard_bip44_accounts[&0].utxos.len(), + restored.accounts.standard_bip44_accounts[&0].utxos.len() + ); + } + + /// Same-block funding and spend, with and without in-block positions: an + /// output the load projection still parks as unspent must end up excluded, + /// and recorded as observed spent, whichever of the two is stored first. + #[tokio::test] + async fn should_exclude_spent_output_for_either_same_height_replay_order() { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for (spend_first, positioned) in + [(false, false), (true, false), (false, true), (true, true)] + { + let case = format!("spend_first={spend_first} positioned={positioned}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let (spent, available) = ( + OutPoint::new(funding.txid(), 0), + OutPoint::new(funding.txid(), 1), + ); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let context = |position: u32| { + let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); + TransactionContext::InBlock(if positioned { + block.with_position(position) + } else { + block + }) + }; + let mut records = info + .check_core_transaction(&funding, context(1), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, context(2), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|r| r + .block_info() + .is_some_and(|b| b.position().is_some() == positioned))); + if spend_first { + records.reverse(); + } + + // A stale projection that still parks the spent output as unspent. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for outpoint in [spent, available] { + account.utxos.insert( + outpoint, + Utxo { + outpoint, + txout: funding.output[outpoint.vout as usize].clone(), + address: address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + replay_recorded_history( + &mut restored, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await + .unwrap(); + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&spent), "{case}"); + assert!(coins.contains_key(&available), "{case}"); + assert!( + restored.observed_spent_outpoints().contains_key(&spent), + "{case}" + ); + assert_eq!(restored.balance.total(), 20_000, "{case}"); + } + } + + /// A lock that arrived after its transaction was stored lives only in + /// `core_instant_locks`; the stored record still says mempool. Replay must + /// restore the InstantSend context and run its conflict sweep, since the + /// already-marked lock deduplicates any later lock event. + #[tokio::test] + async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let spend = |value| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }, + ) + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + // Both double spends as stored: unconfirmed, recorded independently. + let (mut winner, mut loser) = (spend(99_000), spend(98_000)); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + // Unconfirmed siblings replay by txid: lock the later one so the + // loser is already recorded when the winner's sweep runs. + if winner.txid() < loser.txid() { + std::mem::swap(&mut winner, &mut loser); + } + let (lock, evidence) = checked_instant_lock(&winner).await; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); + + // Alone, the locked spend comes back InstantSend. + let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); + let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; + replay_recorded_history_with_evidence( + &mut alone, + &mut wallet, + history(pair, locks.clone()), + &evidence, + ) + .await + .unwrap(); + assert!( + alone.accounts.standard_bip44_accounts[&0] + .transactions() + .get(&winner.txid()) + .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "the locked record must come back InstantSend, not mempool" + ); + + // Replayed after a conflicting spend, its lock sweeps that spend. + let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + contested + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(loser.txid(), record_of(loser.txid())); + contested + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(winner.txid(), record_of(winner.txid())); + replay_recorded_history_with_evidence( + &mut contested, + &mut wallet, + history(records.clone(), locks.clone()), + &evidence, + ) + .await + .unwrap(); + assert!( + !contested.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid()), + "the lock's conflict sweep must drop the competing spend" + ); + } + + /// Settled spends must sweep persisted competing change after all siblings + /// replay, and a raw restored loser must not come back as a fallback. Only a + /// chainlock overrules a competitor that holds its own InstantSend lock. + #[tokio::test] + async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let settlements = [ + ("instant_send", false), + ("block", false), + ("chainlock", false), + ("block", true), + ("chainlock", true), + ("persisted_chainlock", true), + ("persisted_chainlock_below", true), + ]; + let orders = [(false, false), (true, false), (false, true), (true, true)]; + for (settlement, locked_competitor) in settlements { + for (winner_later_txid, raw_restored) in orders { + let case = format!( + "{settlement}, locked_competitor={locked_competitor}, \ + winner_later_txid={winner_later_txid}, raw_restored={raw_restored}" + ); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let spend = |change| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }, + ) + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + let (mut winner, mut loser) = (spend(40_000), spend(30_000)); + if (winner.txid() > loser.txid()) != winner_later_txid { + std::mem::swap(&mut winner, &mut loser); + } + // Both siblings as stored: unconfirmed, each credited its change. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + let change = OutPoint::new(tx.txid(), 1); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + change, + Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + ); + } + assert_eq!(records.len(), 3, "{case}"); + let mut evidence = ReplayEvidence::default(); + let mut locks = BTreeMap::new(); + if settlement == "instant_send" { + let (lock, checked) = checked_instant_lock(&winner).await; + locks.insert(winner.txid(), lock); + evidence.instant_locks.extend(checked.instant_locks); + } else { + let block = BlockInfo::new(101, BlockHash::from_byte_array([10; 32]), 101); + let record = records + .iter_mut() + .find(|record| record.txid == winner.txid()) + .unwrap(); + record.context = if settlement == "chainlock" { + TransactionContext::InChainLockedBlock(block) + } else { + TransactionContext::InBlock(block) + }; + } + if settlement.starts_with("persisted_chainlock") { + restored.metadata.last_applied_chain_lock = Some(ChainLock { + block_height: if settlement == "persisted_chainlock" { + 101 + } else { + 100 + }, + block_hash: BlockHash::from_byte_array([10; 32]), + signature: [0; 96].into(), + }); + } + if locked_competitor { + let (lock, checked) = checked_instant_lock(&loser).await; + locks.insert(loser.txid(), lock); + evidence.instant_locks.extend(checked.instant_locks); + } + if raw_restored { + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for record in &records { + account + .transactions_mut() + .insert(record.txid, record.clone()); + } + } + + replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ) + .await + .unwrap(); + + let account = &restored.accounts.standard_bip44_accounts[&0]; + let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") + && locked_competitor; + assert_eq!( + account.transactions().contains_key(&loser.txid()), + keep_competitor, + "{case}: only a chainlock can overrule an InstantSend lock" + ); + assert_eq!( + account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), + keep_competitor, + "{case}: the swept spend's change must not stay selectable" + ); + assert!( + account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), + "{case}: the winner's change stays" + ); + let expected_balance = winner.output[1].value + + if keep_competitor { + loser.output[1].value + } else { + 0 + }; + assert_eq!(restored.balance.total(), expected_balance, "{case}"); + } + } + } + + /// Mismatched persisted finality evidence fails without upgrading or replaying records. + #[tokio::test] + async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction( + &spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + let mut foreign_transaction = spend.clone(); + foreign_transaction.output[0].value -= 1; + let foreign = foreign_transaction.txid(); + let (foreign_lock, evidence) = checked_instant_lock(&foreign_transaction).await; + let mut forged_record = records.clone(); + forged_record + .iter_mut() + .find(|record| record.txid == spend.txid()) + .unwrap() + .txid = foreign; + let cases = [ + // The lock row is keyed under the record but locks another txid. + ( + "lock txid", + records.clone(), + spend.txid(), + foreign_lock.clone(), + ), + // Record and lock agree, but the record's transaction is another one. + ("record txid", forged_record, foreign, foreign_lock.clone()), + ]; + for (case, records, key, lock) in cases { + let locks: BTreeMap = [(key, lock)].into_iter().collect(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let before = restored.clone(); + let result = replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ); + assert!( + result.await.is_err(), + "{case}: mismatched finality evidence must fail" + ); + let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions + .values() + .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "{case}: a mismatched lock must not upgrade any record" + ); + assert!( + transactions.is_empty(), + "{case}: failed replay must not attach transaction records" + ); + assert_eq!(restored.balance, before.balance, "{case}"); + assert_eq!( + restored.accounts.standard_bip44_accounts[&0].utxos, + before.accounts.standard_bip44_accounts[&0].utxos, + "{case}" + ); + } + } + + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. + fn replay_record( + parents: &[Txid], + value: u64, + context: TransactionContext, + ) -> TransactionRecord { + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + + let transaction = Transaction { + version: 1, + lock_time: 0, + input: parents + .iter() + .map(|parent| TxIn { + previous_output: OutPoint::new(*parent, 0), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn in_block(height: u32, position: Option) -> TransactionContext { + use key_wallet::transaction_checking::BlockInfo; + + let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); + TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) + } + + fn replayed_txids(records: Vec) -> Vec { + replay_order(records.into_iter().map(Into::into).collect()) + .into_iter() + .map(|r| r.txid) + .collect() + } + + /// An unconfirmed child whose txid sorts ahead of its parent's still + /// replays after it, so its input reserves the parent's output. + #[test] + fn should_replay_unconfirmed_parent_before_its_child() { + let parent = replay_record( + &[Txid::from_byte_array([1; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = (0..) + .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) + .find(|child| child.txid < parent.txid) + .unwrap(); + let expected = vec![parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, parent]), expected); + } + + /// A parent whose stored record is still mempool replays ahead of a child + /// already recorded as confirmed. + #[test] + fn should_replay_mempool_parent_before_its_confirmed_child() { + let parent = replay_record( + &[Txid::from_byte_array([2; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); + let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); + let expected = vec![unrelated.txid, parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); + } + + /// Independent records follow chain order: height, then in-block + /// position, then unconfirmed. + #[test] + fn should_order_independent_records_by_height_then_block_position() { + let funding = |marker| [Txid::from_byte_array([marker; 32])]; + let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); + let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); + let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); + let early = replay_record(&funding(7), 4, in_block(9, Some(5))); + let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; + + assert_eq!( + replayed_txids(vec![pending, late_second, late_first, early]), + expected + ); + } + + /// Within one block, in-block position decides: a spend follows the + /// funding transaction it spends, and unrelated transactions keep their + /// place around the pair. + #[test] + fn should_keep_block_position_order_for_same_block_spends() { + let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); + let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); + let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); + let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); + let expected = vec![before.txid, parent.txid, child.txid, after.txid]; + + assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); + } + + /// Records that name each other as parents (impossible for real txids) + /// still all replay, after everything that is ready. + #[test] + fn should_keep_every_record_of_a_dependency_cycle() { + let (a, b) = ( + Txid::from_byte_array([0xAA; 32]), + Txid::from_byte_array([0xBB; 32]), + ); + let mut first = replay_record(&[b], 1, TransactionContext::Mempool); + first.txid = a; + let mut second = replay_record(&[a], 2, TransactionContext::Mempool); + second.txid = b; + let ready = replay_record( + &[Txid::from_byte_array([8; 32])], + 3, + TransactionContext::Mempool, + ); + let expected = vec![ready.txid, a, b]; + + assert_eq!(replayed_txids(vec![second, first, ready]), expected); + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1b612536087..f36778b7bbe 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,10 +1,14 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +use crate::changeset::{ + funding_txids, history_accounting, ClientStartState, ClientWalletStartState, + PersistenceCapabilities, PlatformWalletPersistence, +}; use crate::error::PlatformWalletError; +use crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -17,9 +21,8 @@ use dpp::prelude::Identifier; use key_wallet::account::derivation::AccountDerivation; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; -use key_wallet::transaction_checking::transaction_context::TransactionContext; -use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; +use super::history_replay::HistoryReplayError; use super::{run_blocking_load, PlatformWalletManager}; /// How long the load-time re-dispatch waits for the SPV transport before @@ -63,26 +66,29 @@ impl PlatformWalletManager

{ /// /// Persister errors surface after one attempt so the caller controls retry /// policy. The synchronous read runs on the blocking pool. + /// Start SPV before loading history that requires InstantLock verification. + /// If its quorum evidence is unavailable, load fails without registering + /// that history; the caller may retry after SPV synchronization progresses. /// /// Any `Err` rolls back partial inserts and leaves the manager usable: fix /// the store and call again, or reconstruct. Reconstructing over the same /// path needs every strong persister reference released first. Dropping /// the manager releases its own references; wallet handles, workers and - /// in-flight operations can retain others. [`shutdown`](Self::shutdown) + /// in-flight operations can retain others. [`shutdown`](PlatformWalletManager::shutdown) /// takes `&self`, so it cannot release the manager's own `Arc

`. /// /// [`WalletManager`]: key_wallet_manager::WalletManager pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { let persister = Arc::clone(&self.persister); - let start_state = match run_blocking_load(move || persister.load()).await { - Ok(state) => state, - Err(e) => { - // Debug, not Display: it carries the real cause (e.g. a - // bincode decode failure) rather than flattening the chain. - tracing::debug!(error = ?e, "persister load failed during rehydration"); - return Err(PlatformWalletError::from_load_failure(e)); - } - }; + let candidates = run_blocking_load(move || persister.replay_lock_candidates()) + .await + .map_err(PlatformWalletError::from_load_failure)?; + let evidence = self.spv_manager.verify_history_locks(candidates).await; + let persister = Arc::clone(&self.persister); + let start_state = run_blocking_load(move || persister.load_read_snapshot()) + .await + .map_err(PlatformWalletError::from_load_failure)?; + let mut outcomes = BTreeMap::new(); let ClientStartState { mut platform_addresses, wallets, @@ -92,9 +98,21 @@ impl PlatformWalletManager

{ shielded: _, } = start_state; - // Tracked (wallet-independent) masternodes ride the same startup - // hydration; a failure logs and starts empty rather than failing - // wallet restore. + // Without stored history the load replay has nothing to rebuild + // confirmed-spend guards from; say so rather than degrade silently. + if !wallets.is_empty() + && !self + .persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_HISTORY_RESTORE) + { + tracing::warn!( + wallets = wallets.len(), + "load: persister does not restore transaction history; confirmed-spend \ + guards are not rebuilt until the spends are observed again" + ); + } + self.load_tracked_masternodes_from_persistence(); let persister_dyn: Arc = Arc::clone(&self.persister) as _; @@ -125,6 +143,10 @@ impl PlatformWalletManager

{ let mut inserted_in_wallets: Vec<(WalletId, Arc)> = Vec::new(); let mut load_error: Option = None; + // Wallets this load found already registered and skipped. Their + // accounting backfill still runs below: one that did not complete at + // an earlier load is retried by this one. + let mut already_registered: Vec = Vec::new(); // Each wallet's start state, and whether it was re-read under the // durable-cursor lock that `held` still holds (see the publication @@ -140,72 +162,87 @@ impl PlatformWalletManager

{ let mut held = None; 'load: while let Some((expected_wallet_id, wallet_state, reread)) = pending.pop_front() { + if !reread { + let cursors = Arc::clone(&self.durable_cursors.cursors).lock_owned().await; + if self + .wallet_manager + .read() + .await + .get_wallet(&expected_wallet_id) + .is_some() + { + already_registered.push(expected_wallet_id); + continue 'load; + } + let persister = Arc::clone(&self.persister); + let prefetched = evidence.clone(); + match run_blocking_load(move || { + let loaded = + persister.load_wallet_with_replay(expected_wallet_id, &prefetched)?; + // Keep the existing publication fence through an admitted backend transaction. + Ok((loaded, cursors)) + }) + .await + { + Ok((mut loaded, cursors)) => { + outcomes.extend(loaded.outcomes); + platform_addresses.extend(loaded.state.platform_addresses); + if let Some(state) = loaded.state.wallets.remove(&expected_wallet_id) { + pending.push_front((expected_wallet_id, state, true)); + held = Some(cursors); + } + } + Err(error) => { + load_error = Some(PlatformWalletError::from_load_failure(error)); + break 'load; + } + } + continue 'load; + } + let mut wallet_state = wallet_state; + let outcome = match outcomes.remove(&expected_wallet_id) { + Some(outcome) => outcome, + None => { + match super::history_replay::replay_wallet_history(&mut wallet_state, &evidence) + .await + { + Ok(outcome) + if outcome.sweeps.is_empty() + && outcome.defeated.is_empty() + && outcome.withheld_asset_lock_inputs.is_empty() + && outcome.authorization.rejected.is_empty() => + { + outcome + } + Ok(_) => { + load_error = Some(PlatformWalletError::from_load_failure( + crate::changeset::PersistenceError::backend( + HistoryReplayError::AtomicPersistenceRequired, + ), + )); + break 'load; + } + Err(error) => { + load_error = Some(PlatformWalletError::from_load_failure( + crate::changeset::PersistenceError::backend(error), + )); + break 'load; + } + } + } + }; let ClientWalletStartState { - mut wallet, - mut wallet_info, + wallet, + wallet_info, identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, dashpay_backfill, + .. } = wallet_state; - - // Replay the sends the host still holds as unconfirmed, before - // anything reads the restored balance. - // - // Their spend effect is never persisted: `isSpent` stays `false` - // on the input row until the spending transaction reaches a - // block, because a mempool-only sighting is reversible by - // eviction. So the UTXO restore above has just handed those - // inputs back as spendable. A live process was still correct — - // it held the effect in memory — and until now a restart - // recovered it only by re-observing the transaction on the - // network. A transaction that never reached the network cannot - // be re-observed, so its input stayed spendable for good and the - // balance re-counted the coin. - // - // Routing each record through the ordinary mempool check (rather - // than inserting it into `transactions_mut()` raw, the way the - // asset-lock record restore does) is the whole point: it runs - // `update_utxos`, which drops the input from `utxos` and records - // it in `spent_outpoints`, reproducing exactly the state the - // live process held. A raw insert would leave `spent_outpoints` - // empty AND make every later re-dispatch a no-op, because - // `has_transaction` would then report the record as not new. - // - // `update_state` and `update_balance` are both on: the balance - // this produces is what `generation.set(..)` mirrors a few lines - // below, and the UI reads that. - if !unconfirmed_outgoing_txs.is_empty() { - let mut replayed = 0usize; - for tx in &unconfirmed_outgoing_txs { - let result = wallet_info - .check_core_transaction( - tx, - TransactionContext::Mempool, - &mut wallet, - true, - true, - ) - .await; - if result.is_relevant { - replayed += 1; - } - } - tracing::info!( - wallet_id = %hex::encode(expected_wallet_id), - offered = unconfirmed_outgoing_txs.len(), - replayed, - "load: replayed unconfirmed outgoing sends" - ); - } - - // Flatten the (account → outpoint → lock) map into the flat - // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` - // holds today. - let mut tracked_asset_locks = BTreeMap::new(); - for (_account_index, account_locks) in unused_asset_locks { - tracked_asset_locks.extend(account_locks); - } + let unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); + let recorded_conflicts = outcome.recorded_conflicts; + let tracked_asset_locks = unused_asset_locks.into_values().flatten().collect(); // Canonical id recomputed from the wallet's own key material. // Computed up front — before `insert_wallet` consumes `wallet` — @@ -241,7 +278,7 @@ impl PlatformWalletManager

{ core_balance.locked(), ); let mut platform_info = PlatformWalletInfo { - observed_input_conflicts: Default::default(), + observed_input_conflicts: Mutex::new(recorded_conflicts), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), @@ -293,9 +330,7 @@ impl PlatformWalletManager

{ // freshly restored state: it closes the race where SPV's // chainlock dispatcher promotion-evicts a restored spender // before the first catch-up resume ever reads it. - crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts( - &platform_info, - ); + seed_observed_input_conflicts(&platform_info); // The cursor the host just handed back is the durable one; it // seeds the shared record of it as the wallet is published below. @@ -375,7 +410,7 @@ impl PlatformWalletManager

{ { let mut durable_cursors = match held.take() { Some(guard) => guard, - None => self.durable_cursors.lock().await, + None => Arc::clone(&self.durable_cursors.cursors).lock_owned().await, }; if self .wallet_manager @@ -384,28 +419,7 @@ impl PlatformWalletManager

{ .get_wallet(&wallet_id) .is_some() { - continue 'load; - } - if !reread && durable_cursors.contains_key(&wallet_id) { - let persister = Arc::clone(&self.persister); - let current = match run_blocking_load(move || persister.load()).await { - Ok(mut state) => state.wallets.remove(&expected_wallet_id), - Err(e) => { - load_error = Some(PlatformWalletError::from_load_failure(e)); - break 'load; - } - }; - match current { - Some(state) => { - pending.push_front((expected_wallet_id, state, true)); - held = Some(durable_cursors); - } - None => tracing::warn!( - wallet_id = %hex::encode(expected_wallet_id), - "load: the wallet is no longer on the host; not publishing its \ - stale snapshot" - ), - } + already_registered.push(wallet_id); continue 'load; } // Coverage for a receival account this wallet did not restore @@ -506,7 +520,7 @@ impl PlatformWalletManager

{ )); } - let platform_wallet = PlatformWallet::new( + let platform_wallet = Arc::new(PlatformWallet::new( Arc::clone(&self.sdk), wallet_id, Arc::clone(&self.wallet_manager), @@ -516,7 +530,7 @@ impl PlatformWalletManager

{ broadcaster, Arc::clone(&self.sync_fault), Arc::clone(&self.durable_cursors), - ); + )); // Initialize the platform-address provider. If the snapshot // carried a slice for this wallet, restore it directly; @@ -538,7 +552,6 @@ impl PlatformWalletManager

{ platform_wallet.platform().initialize().await; } - let platform_wallet = Arc::new(platform_wallet); self.wallets.rcu(|wallets| { let mut wallets = std::collections::BTreeMap::clone(wallets); wallets.insert(wallet_id, Arc::clone(&platform_wallet)); @@ -656,6 +669,19 @@ impl PlatformWalletManager

{ return Err(err); } + // Past the rollback point. Accounting is display-only, so a failure + // here is logged and never fails the load. Every wallet this load saw + // is offered the backfill, so an unfinished one is retried at the + // next load; a finished one costs the host one marker read. + let seen: std::collections::BTreeSet = inserted_in_wallets + .iter() + .map(|(wallet_id, _)| *wallet_id) + .chain(already_registered) + .collect(); + for wallet_id in seen { + self.backfill_history_accounting(wallet_id).await; + } + // Past the rollback point: every registration here is one this load // actually committed, so the transactions now have a wallet to belong // to for as long as it stays registered. @@ -735,6 +761,95 @@ impl PlatformWalletManager

{ Ok(()) } + + /// Backfill per-wallet accounting for the stored transactions the + /// persister holds without one (see + /// [`PlatformWalletPersistence::load_core_history_for_accounting`]). + /// Nothing is written unless every step succeeds, so a failed backfill + /// simply runs again at the next load. A transaction whose wallet-signed + /// input cannot be resolved yet gets no row and keeps the backfill open + /// (no completion marker), so the next load retries it. + async fn backfill_history_accounting(&self, wallet_id: WalletId) { + let persister = Arc::clone(&self.persister); + let unaccounted = + match run_blocking_load(move || persister.load_core_history_for_accounting(wallet_id)) + .await + { + Ok(Some(unaccounted)) => unaccounted, + Ok(None) => return, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = ?e, + "load: reading stored history for the accounting backfill failed" + ); + return; + } + }; + let needed = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&wallet_id) else { + return; + }; + funding_txids(&info.core_wallet, &unaccounted) + }; + // Fetched without the manager lock held: each lookup calls the host. + // A stored funding transaction the host cannot hand over fails the + // round: read as missing, a wallet input could pass for a foreign one. + let persister = Arc::clone(&self.persister); + let funding = match run_blocking_load(move || { + let mut funding = Vec::new(); + for txid in &needed { + if let Some(transaction) = persister.get_core_transaction(wallet_id, txid)? { + funding.push(transaction); + } + } + Ok(funding) + }) + .await + { + Ok(funding) => funding, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = ?e, + "load: reading funding transactions for the accounting backfill failed" + ); + return; + } + }; + let accounted = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&wallet_id) else { + return; + }; + history_accounting(&info.core_wallet, &unaccounted, &funding) + }; + let complete = accounted.is_complete(); + if !complete { + tracing::info!( + wallet_id = %hex::encode(wallet_id), + unresolved = accounted.unresolved.len(), + "load: some stored transactions spend wallet coins whose funding \ + transaction is unavailable; leaving them unaccounted for the next load" + ); + } + let rows = accounted.rows; + let persister = Arc::clone(&self.persister); + let count = rows.len(); + if let Err(e) = run_blocking_load(move || { + persister.store_history_accounting(wallet_id, &rows, complete) + }) + .await + { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + rows = count, + error = ?e, + "load: persisting backfilled transaction accounting failed" + ); + } + } } /// What one re-dispatch attempt did, so a caller — and a test — can tell the @@ -853,16 +968,29 @@ mod idempotent_load_tests { use crate::wallet::core::WalletGeneration; use key_wallet::test_utils::TestWalletContext; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; + use std::sync::Mutex; + + use dashcore::{Network, OutPoint, Transaction, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + use crate::changeset::{ ClientStartState, ClientWalletStartState, IdentityManagerStartState, PersistenceError, - PlatformWalletChangeSet, PlatformWalletPersistence, + PlatformWalletChangeSet, PlatformWalletPersistence, RecordedHistory, StoredTransaction, + WalletAccounting, }; use crate::events::PlatformEventHandler; use crate::test_support::NoopTestEventHandler; - use crate::wallet::platform_wallet::WalletId; + use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; + use crate::wallet::platform_wallet::{ObservedInputConflict, WalletId}; use crate::PlatformWalletManager; /// Persister that hands back one wallet plus the outgoing sends the @@ -897,6 +1025,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: self.pending.clone(), + recorded_history: Default::default(), dashpay_backfill: Default::default(), }, ); @@ -964,6 +1093,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), dashpay_backfill: Default::default(), }, ); @@ -1004,6 +1134,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), dashpay_backfill: Default::default(), }; let mut wallets = BTreeMap::new(); @@ -1286,7 +1417,17 @@ mod idempotent_load_tests { // Without a replay this is what the restore alone would leave // standing, so it is also the failure the assertion below catches. - let spend = spend_to(funded_outpoint, 74_000); + let address = dashcore::Address::from_script( + &funding.output[0].script_pubkey, + dashcore::Network::Testnet, + ) + .unwrap(); + let spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + &address, + spend_to(funded_outpoint, 74_000), + ); let manager = make_pending_manager(PendingSendPersister { wallet: ctx.wallet, managed: ctx.managed_wallet, @@ -1312,6 +1453,1827 @@ mod idempotent_load_tests { ); } + /// Persister that hands back a projection holding only the persisted + /// UTXO set (no in-memory transactions) plus the stored history — the + /// shape of a persister that leaves the replay to the shared load. + struct RecordedHistoryPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + history: RecordedHistory, + asset_locks: BTreeMap, + } + + impl PlatformWalletPersistence for RecordedHistoryPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let mut wallets = BTreeMap::new(); + wallets.insert( + self.wallet.compute_wallet_id(), + ClientWalletStartState { + wallet: self.wallet.clone(), + wallet_info: self.managed.clone(), + identity_manager: IdentityManagerStartState::default(), + unused_asset_locks: [(0, self.asset_locks.clone())].into_iter().collect(), + unconfirmed_outgoing_txs: Vec::new(), + recorded_history: self.history.clone(), + dashpay_backfill: Default::default(), + }, + ); + Ok(ClientStartState { + wallets, + ..Default::default() + }) + } + } + + #[tokio::test] + async fn should_gate_nonterminal_tracked_only_instant_send_and_leave_consumed_archival() { + use dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + for (status, has_proof, needs_evidence) in [ + (AssetLockStatus::InstantSendLocked, false, true), + (AssetLockStatus::Broadcast, true, true), + (AssetLockStatus::Consumed, true, false), + ] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &spend).unwrap(); + let lock = TrackedAssetLock { + operation_id: None, + out_point: OutPoint::new(spend.txid(), 0), + transaction: spend.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status, + proof: has_proof.then(|| { + AssetLockProof::Instant(InstantAssetLockProof::new( + fixture.instant_lock().clone(), + spend, + 0, + )) + }), + }; + let persister = RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history: Default::default(), + asset_locks: [(lock.out_point, lock)].into(), + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + let result = super::super::history_replay::replay_wallet_history_blocking( + wallet, + &Default::default(), + ); + if needs_evidence { + assert!(matches!( + result, + Err( + super::super::history_replay::HistoryReplayError::MissingFinalityEvidence { .. } + ) + )); + } else { + let outcome = result.unwrap(); + assert!(!outcome.withheld_asset_lock_inputs.is_empty()); + assert!( + wallet.recorded_history.transactions.is_empty(), + "consumed history stays archival" + ); + drop(loaded); + let id = persister.wallet.compute_wallet_id(); + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(persister), + Arc::new(NoopTestEventHandler), + ); + let error = manager.load_from_persistor().await.unwrap_err(); + assert!(format!("{error:?}").contains("AtomicPersistenceRequired")); + assert!(manager.get_wallet(&id).await.is_none()); + assert!(manager.list_wallet_ids_blocking().is_empty()); + } + } + } + + #[tokio::test] + async fn should_reject_legacy_load_when_history_requires_durable_repair() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let id = ctx.wallet.compute_wallet_id(); + let invalid = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let stored = |transaction: Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let persister = RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history: RecordedHistory { + transactions: vec![stored(funding), stored(invalid)], + ..Default::default() + }, + asset_locks: BTreeMap::new(), + }; + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(persister), + Arc::new(NoopTestEventHandler), + ); + let error = manager + .load_from_persistor() + .await + .expect_err("a legacy backend cannot commit replay repair"); + assert!( + format!("{error:?}").contains("AtomicPersistenceRequired"), + "{error:?}" + ); + assert!(manager.get_wallet(&id).await.is_none()); + assert!(manager.list_wallet_ids_blocking().is_empty()); + } + + #[tokio::test] + async fn should_authorize_additional_outgoing_history_before_replay_or_resend() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let invalid = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let persister = PendingSendPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + pending: vec![invalid.clone()], + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + let outcome = + super::super::history_replay::replay_wallet_history(wallet, &Default::default()) + .await + .unwrap(); + assert!(outcome.authorization.rejected.contains(&invalid.txid())); + assert!(wallet.unconfirmed_outgoing_txs.is_empty()); + let effects = outcome + .storage_effects(&wallet.recorded_history.transactions, &[]) + .unwrap(); + assert!(effects.removed_records.contains(&invalid.txid())); + } + + #[tokio::test] + async fn should_require_matching_evidence_for_additional_outgoing_instant_lock() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &spend).unwrap(); + let persister = PendingSendPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + pending: vec![spend.clone()], + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + wallet + .recorded_history + .instant_locks + .insert(spend.txid(), fixture.instant_lock().clone()); + let error = super::super::history_replay::replay_wallet_history_blocking( + wallet, + &Default::default(), + ) + .unwrap_err(); + assert!(matches!( + error, + super::super::history_replay::HistoryReplayError::MissingFinalityEvidence { .. } + )); + assert_eq!(wallet.unconfirmed_outgoing_txs, vec![spend]); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_fail_load_without_proof_then_allow_an_explicit_verified_retry() { + use dash_spv::test_utils::InstantLockFixture; + + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let fixture = InstantLockFixture::new(Network::Testnet, &funding).unwrap(); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::from([(funding.txid(), fixture.instant_lock().clone())]), + }; + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history, + asset_locks: BTreeMap::new(), + }), + Arc::new(NoopTestEventHandler), + ); + assert!( + manager.load_from_persistor().await.is_err(), + "missing proof must fail the load" + ); + assert!(manager.list_wallet_ids_blocking().is_empty()); + assert!(manager.get_wallet(&wallet_id).await.is_none()); + assert!(manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_none()); + let storage = tempfile::TempDir::new().unwrap(); + let offline = dash_spv::ClientConfig::testnet() + .with_storage_path(storage.path()) + .with_restrict_to_configured_peers(true); + assert!(offline.peers.is_empty() && offline.restrict_to_configured_peers); + assert!(manager + .spv_manager + .start_and_spawn(offline, || true) + .await + .unwrap()); + assert!(manager.spv_manager.is_started()); + let revision_before = manager.wallet_manager.read().await.monitor_revision(); + manager.spv_manager.install_history_fixture(&fixture).await; + manager.load_from_persistor().await.unwrap(); + let visible = manager.get_wallet(&wallet_id).await.unwrap(); + assert_eq!(visible.balance().total(), 100_000); + assert!(visible.core().is_current_generation().await); + assert!( + manager.wallet_manager.read().await.monitor_revision() > revision_before, + "successful explicit retry must register scripts with the running SPV client" + ); + assert_eq!(manager.list_wallet_ids_blocking(), vec![wallet_id]); + assert!(Arc::ptr_eq( + &visible, + &manager.get_wallet(&wallet_id).await.unwrap() + )); + assert!(manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_some()); + assert!(manager.shutdown().await.all_clean()); + } + + fn authorize( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + mut tx: dashcore::Transaction, + ) -> dashcore::Transaction { + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let path = info + .accounts + .all_funding_accounts() + .into_iter() + .find_map(|account| account.address_derivation_path(address)) + .unwrap(); + let key = wallet.derive_private_key(&path).unwrap(); + for index in 0..tx.input.len() { + let hash = SighashCache::new(&tx) + .legacy_signature_hash(index, &address.script_pubkey(), 1) + .unwrap(); + let mut sig = key.sign(&hash.to_byte_array()).with_der(|der| der.to_vec()); + sig.push(1); + tx.input[index].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(sig).unwrap()) + .push_slice(key.public_key().to_compressed()) + .into_script(); + } + tx + } + + /// `load_from_persistor` replays the stored history the persister hands + /// back, whatever the persister: a stored spend of the only coin must + /// leave nothing spendable even though the persisted UTXO set still + /// lists that coin (an unconfirmed spend never flips `isSpent`). + #[tokio::test] + async fn load_replays_the_recorded_history_of_any_persister() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let funded_outpoint = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let address = &ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos + [&funded_outpoint] + .address; + let spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + address, + spend_to(funded_outpoint, 74_000), + ); + + // The persisted projection: the coin as an unspent row, no history. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + let coin = + ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos[&funded_outpoint].clone(); + projection + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .utxos + .insert(funded_outpoint, coin); + projection.update_balance(); + assert_eq!(projection.balance.total(), 100_000); + + let stored = |transaction: dashcore::Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![stored(spend), stored(funding)], + instant_locks: BTreeMap::new(), + }, + asset_locks: BTreeMap::new(), + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("the loaded wallet must be registered"); + let balance = wallet.balance(); + let total = balance.confirmed() + balance.unconfirmed(); + assert_eq!( + total, 0, + "the replayed stored spend consumes the only coin; {} duffs left \ + means the history was not replayed", + total + ); + } + + /// A stored confirmed spend of a pending asset lock's input must reach + /// the double-spend screen's memory although the replayed wallet history + /// no longer shows it. With the funding recorded, the restored chain lock + /// buries the spender and finalization evicts its record (unless + /// `keep-finalized-transactions` is on); without it, the spender is not + /// attributable and never gets a record at all. + #[tokio::test] + async fn should_remember_stored_spender_of_pending_asset_lock_input_on_load() { + use key_wallet::transaction_checking::BlockInfo; + + for funding_recorded in [true, false] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let contested = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let asset_lock = spend_to(contested, 74_000); + let spender = spend_to(contested, 73_000); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let stored = |transaction: dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let mut transactions = vec![stored(spender.clone(), in_block(100))]; + if funding_recorded { + transactions.push(stored(funding, in_block(90))); + } + // The persisted projection: the coin is spent, the chain lock is + // past the spender's block. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 150, + block_hash: dashcore::BlockHash::from_byte_array([150; 32]), + signature: [0; 96].into(), + }); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions, + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status: AssetLockStatus::Broadcast, + proof: None, + }, + )] + .into_iter() + .collect(), + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wm = manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered"); + let remembered = info + .observed_input_conflicts + .lock() + .expect("conflict memory") + .get(&contested) + .copied(); + assert_eq!( + remembered, + Some(ObservedInputConflict { + spender: spender.txid(), + height: 100, + }), + "funding_recorded={funding_recorded}: the stored confirmed spender must be \ + remembered after the load replay" + ); + } + } + + /// A host drops the inputs of an asset lock from the projection once the + /// lock is InstantSend-locked or beyond, while the funding's own record + /// can still read as unconfirmed and, paying nothing to the wallet, + /// guards nothing. Such an input is consumed for good and must not be + /// credited when the transaction that created it is delivered again. A + /// lock that is only built or broadcast claims nothing, and a funding + /// with change keeps its change. + #[tokio::test] + async fn should_not_credit_inputs_of_a_final_asset_lock_again() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + for (status, with_change, consumed) in [ + (AssetLockStatus::Built, false, false), + (AssetLockStatus::Broadcast, false, false), + (AssetLockStatus::InstantSendLocked, false, true), + (AssetLockStatus::ChainLocked, false, true), + (AssetLockStatus::Consumed, false, true), + (AssetLockStatus::RecoveredFromChain, false, true), + (AssetLockStatus::InstantSendLocked, true, true), + ] { + let case = format!("{status:?}, change: {with_change}"); + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let coin = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + // An asset-lock funding: the value is burnt into the payload. + let mut asset_lock = dashcore::Transaction { + version: 3, + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: spend_to(coin, 74_000).output, + }, + )), + ..spend_to(coin, 74_000) + }; + asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); + if with_change { + asset_lock.output.push(dashcore::TxOut { + value: 20_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }); + } + let change = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 1, + }; + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + if with_change { + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .utxos + .insert( + change, + Utxo::new( + change, + asset_lock.output[1].clone(), + ctx.receive_address.clone(), + 0, + false, + ), + ); + } + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock) + .unwrap(); + let proof = (status == AssetLockStatus::InstantSendLocked).then(|| { + dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( + fixture.instant_lock().clone(), asset_lock.clone(), 0, + ), + ) + }); + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + // The funding's row never left the mempool context. + transactions: vec![StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status, + proof, + }, + )] + .into_iter() + .collect(), + }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + let holds = |core: &ManagedWalletInfo, outpoint: dashcore::OutPoint| { + core.utxos().iter().any(|utxo| utxo.outpoint == outpoint) + }; + assert_eq!(holds(&core, change), with_change, "{case}"); + + core.check_core_transaction(&funding, in_block(90), &mut wallet, true, true) + .await; + assert_eq!( + holds(&core, coin), + !consumed, + "{case}: only a final lock keeps its input from being credited" + ); + + // The funding's own confirmation changes neither. + core.check_core_transaction(&asset_lock, in_block(95), &mut wallet, true, true) + .await; + assert!(!holds(&core, coin), "{case}: the lock spends its input"); + assert_eq!(holds(&core, change), with_change, "{case}"); + } + } + + /// What a tracked asset lock proves about its funding outranks the + /// funding's stored context, and the replay's verdict outranks the lock's + /// status. Each row restores a funding stored as unconfirmed, with change, + /// against one competitor for its contested input: + /// + /// - an Instant proof makes the funding replay as InstantSend-locked: it + /// sweeps an unconfirmed competitor, stands beside a plain block, and + /// falls only to a chain-locked spend, after which its other input is + /// free again; + /// - a chain-final lock proves a height and no block, so the funding is + /// kept out of the replay: its coins stay as stored whatever the + /// competitor, and its inputs stay claimed; + /// - a chain-final lock whose funding a lock in the history settles + /// replays like any locked funding, while a lock filed under the + /// funding that names another transaction settles nothing and leaves + /// it out of the replay; + /// - a chain-final lock keeps its funding out of the replay in every + /// form: a sibling credit output still at `Broadcast` has the host + /// attach the funding's record to the wallet, and that record sits the + /// replay out too and is back afterwards; + /// - a lock that names another transaction settles nothing, and the + /// record replays as stored. + #[tokio::test] + async fn should_settle_stored_conflicts_by_what_a_tracked_lock_proves() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::{BlockInfo, TransactionType}; + use key_wallet::Utxo; + + #[derive(Debug, Clone, Copy)] + enum Evidence { + Instant, + ChainFinal, + ConsumedWithArchivedInstant, + ChainFinalWithForeignHistoryLock, + ChainFinalWithPendingSibling, + InstantOfAnotherTransaction, + } + #[derive(Debug, Clone, Copy)] + enum Competitor { + Unconfirmed, + PlainBlock, + ChainLocked, + } + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let mut failures = Vec::new(); + // (the funding's change stands, the competitor's change stands, the + // funding's other input stays guarded) + for (evidence, competing, expected) in [ + ( + Evidence::Instant, + Competitor::Unconfirmed, + (true, false, true), + ), + ( + Evidence::Instant, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::Instant, + Competitor::ChainLocked, + (false, true, false), + ), + ( + Evidence::ChainFinal, + Competitor::Unconfirmed, + (true, true, true), + ), + ( + Evidence::ChainFinal, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::ChainFinal, + Competitor::ChainLocked, + (true, true, true), + ), + ( + // Archival proof preserves the consumed hold but cannot settle a rival. + Evidence::ConsumedWithArchivedInstant, + Competitor::Unconfirmed, + (true, true, true), + ), + ( + Evidence::ChainFinalWithForeignHistoryLock, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::ChainFinalWithPendingSibling, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::InstantOfAnotherTransaction, + Competitor::PlainBlock, + (false, true, false), + ), + ] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let contested = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let to_wallet = |value| dashcore::TxOut { + value, + script_pubkey: ctx.receive_address.script_pubkey(), + }; + let mut other_funding = spend_to( + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7; 32]), + vout: 0, + }, + 50_000, + ); + other_funding.output[0] = to_wallet(50_000); + let other = dashcore::OutPoint { + txid: other_funding.txid(), + vout: 0, + }; + let mut asset_lock = dashcore::Transaction { + version: 3, + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: spend_to(contested, 120_000).output, + }, + )), + ..spend_to(contested, 120_000) + }; + asset_lock.input.push(dashcore::TxIn { + previous_output: other, + ..asset_lock.input[0].clone() + }); + asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); + let pending_sibling = matches!(evidence, Evidence::ChainFinalWithPendingSibling); + if pending_sibling { + // One funding transaction, two credit outputs. + if let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &mut asset_lock.special_transaction_payload + { + payload.credit_outputs[0].value = 60_000; + let second = payload.credit_outputs[0].clone(); + payload.credit_outputs.push(second); + } + } + asset_lock.output.push(to_wallet(20_000)); + let mut competitor = spend_to(contested, 60_000); + competitor.output.push(to_wallet(30_000)); + let change_of = |transaction: &dashcore::Transaction| dashcore::OutPoint { + txid: transaction.txid(), + vout: 1, + }; + + // The persisted projection: both inputs of the lock are gone, + // both change coins are on file, the chain lock stands at 110. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 110, + block_hash: dashcore::BlockHash::from_byte_array([110; 32]), + signature: [0; 96].into(), + }); + for transaction in [&asset_lock, &competitor] { + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .utxos + .insert( + change_of(transaction), + Utxo::new( + change_of(transaction), + transaction.output[1].clone(), + ctx.receive_address.clone(), + 0, + false, + ), + ); + } + if pending_sibling { + // What the host attaches for a lock still at `Broadcast`: + // the funding's record, raw, in its stored context. + let record = TransactionRecord::new( + asset_lock.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .transactions_mut() + .insert(asset_lock.txid(), record); + } + let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + // A lock filed under the funding that names another transaction. + let history_locks = match evidence { + Evidence::ChainFinalWithForeignHistoryLock => BTreeMap::from([( + asset_lock.txid(), + InstantLock { + txid: competitor.txid(), + ..Default::default() + }, + )]), + _ => BTreeMap::new(), + }; + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock) + .unwrap(); + let instant = |txid| { + let mut lock = fixture.instant_lock().clone(); + lock.txid = txid; + AssetLockProof::Instant(InstantAssetLockProof::new(lock, asset_lock.clone(), 0)) + }; + let (status, proof) = match evidence { + Evidence::Instant => ( + AssetLockStatus::InstantSendLocked, + instant(asset_lock.txid()), + ), + Evidence::ChainFinal + | Evidence::ChainFinalWithForeignHistoryLock + | Evidence::ChainFinalWithPendingSibling => ( + AssetLockStatus::ChainLocked, + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 105, + out_point, + }), + ), + Evidence::ConsumedWithArchivedInstant => { + (AssetLockStatus::Consumed, instant(asset_lock.txid())) + } + Evidence::InstantOfAnotherTransaction => ( + AssetLockStatus::InstantSendLocked, + instant(competitor.txid()), + ), + }; + let competitor_context = match competing { + Competitor::Unconfirmed => TransactionContext::Mempool, + Competitor::PlainBlock => in_block(120), + Competitor::ChainLocked => match in_block(100) { + TransactionContext::InBlock(block) => { + TransactionContext::InChainLockedBlock(block) + } + _ => unreachable!(), + }, + }; + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![ + stored(&asset_lock, TransactionContext::Mempool), + stored(&competitor, competitor_context), + ], + instant_locks: history_locks, + }, + asset_locks: { + let lock = TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 120_000, + status, + proof: Some(proof), + }; + // The sibling credit output, still waiting for its proof. + let sibling = TrackedAssetLock { + out_point: dashcore::OutPoint { + txid: out_point.txid, + vout: 1, + }, + status: AssetLockStatus::Broadcast, + proof: None, + ..lock.clone() + }; + [lock] + .into_iter() + .chain(pending_sibling.then_some(sibling)) + .map(|lock| (lock.out_point, lock)) + .collect() + }, + }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; + let loaded = manager.load_from_persistor().await; + if matches!(evidence, Evidence::InstantOfAnotherTransaction) + && !matches!(competing, Competitor::ChainLocked) + { + assert!( + loaded.is_err(), + "mismatched required proof cannot authorize load" + ); + assert!(manager.list_wallet_ids_blocking().is_empty()); + continue; + } + loaded.expect("the wallet must load"); + + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + let holds = |core: &ManagedWalletInfo, outpoint: dashcore::OutPoint| { + core.utxos().iter().any(|utxo| utxo.outpoint == outpoint) + }; + let changes = ( + holds(&core, change_of(&asset_lock)), + holds(&core, change_of(&competitor)), + ); + core.check_core_transaction(&other_funding, in_block(91), &mut wallet, true, true) + .await; + let outcome = (changes.0, changes.1, !holds(&core, other)); + let record_kept = core + .first_bip44_managed_account() + .expect("bip44 account") + .transactions() + .contains_key(&asset_lock.txid()); + if pending_sibling && !record_kept { + failures.push(format!( + "{evidence:?} against {competing:?}: the funding's attached record is gone" + )); + } + if outcome != expected { + failures.push(format!( + "{evidence:?} against {competing:?}: {outcome:?}, expected {expected:?}" + )); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Only a ChainLock overrules an InstantSend lock. When a stored + /// chain-locked spend defeats a tracked lock's funding, the funding's + /// other inputs are unspent again: the load must not claim them for a + /// transaction that no longer stands. + #[tokio::test] + async fn should_not_claim_inputs_of_a_tracked_funding_the_replay_defeated() { + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let contested = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut other_funding = spend_to( + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7; 32]), + vout: 0, + }, + 50_000, + ); + other_funding.output[0].script_pubkey = ctx.receive_address.script_pubkey(); + let other = dashcore::OutPoint { + txid: other_funding.txid(), + vout: 0, + }; + let mut asset_lock = spend_to(contested, 120_000); + asset_lock.input.push(dashcore::TxIn { + previous_output: other, + ..asset_lock.input[0].clone() + }); + let winner = spend_to(contested, 60_000); + let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + // The persisted projection: the winner's block is chain-locked, and + // the host withholds both inputs of the lock. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 150, + block_hash: dashcore::BlockHash::from_byte_array([150; 32]), + signature: [0; 96].into(), + }); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock).unwrap(); + let instant_lock = fixture.instant_lock().clone(); + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![ + stored(&asset_lock, TransactionContext::Mempool), + stored( + &winner, + match in_block(100) { + TransactionContext::InBlock(block) => { + TransactionContext::InChainLockedBlock(block) + } + _ => unreachable!(), + }, + ), + ], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 120_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + asset_lock.clone(), + 0, + ))), + }, + )] + .into_iter() + .collect(), + }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + + for (transaction, height) in [(&funding, 90), (&other_funding, 91)] { + core.check_core_transaction(transaction, in_block(height), &mut wallet, true, true) + .await; + } + + let holds = |outpoint| core.utxos().iter().any(|utxo| utxo.outpoint == outpoint); + assert!(!holds(contested), "the winner spent the contested coin"); + assert!( + holds(other), + "the coin only the defeated funding spent is unspent again" + ); + } + + /// A funding left out of the replay for unsafe amounts was not defeated: + /// nothing proves its inputs free, so a final lock still claims them. + #[tokio::test] + async fn should_keep_the_claim_of_a_final_lock_whose_funding_was_only_left_out() { + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let coin = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let asset_lock = spend_to(coin, 1 << 63); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock).unwrap(); + let proof = dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( + fixture.instant_lock().clone(), + asset_lock.clone(), + 0, + ), + ); + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + managed: ManagedWalletInfo::from_wallet(&ctx.wallet, 0), + wallet: ctx.wallet, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(proof), + }, + )] + .into_iter() + .collect(), + }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + + let block = BlockInfo::new(90, dashcore::BlockHash::from_byte_array([90; 32]), 90); + core.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + !core.utxos().iter().any(|utxo| utxo.outpoint == coin), + "the lock's input stays claimed" + ); + } + + /// A stored record whose amounts overflow the checker's signed arithmetic + /// must not stop the wallet from loading: it is left out, and the coin it + /// spends stays reserved. + #[tokio::test] + async fn should_load_wallet_whose_stored_history_holds_unsafe_amounts() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let funded_outpoint = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let coin = + ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos[&funded_outpoint].clone(); + let mut unsafe_spend = spend_to(funded_outpoint, 1 << 63); + unsafe_spend.output[0].script_pubkey = coin.txout.script_pubkey.clone(); + + let unsafe_spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + &coin.address, + unsafe_spend, + ); + + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .utxos + .insert(funded_outpoint, coin); + projection.update_balance(); + let stored = |transaction: dashcore::Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![stored(unsafe_spend), stored(funding)], + instant_locks: BTreeMap::new(), + }, + asset_locks: BTreeMap::new(), + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("the loaded wallet must be registered"); + let balance = wallet.balance(); + assert_eq!( + balance.confirmed() + balance.unconfirmed(), + 0, + "the coin the unsafe record spends must stay reserved" + ); + } + + /// Loader unit fixture: supplies owner-prepared outcomes; real backend transaction + /// durability is covered by SQLite/FFI integration tests. + struct PreparedHistoryPersister(RecordedHistoryPersister); + + impl PlatformWalletPersistence for PreparedHistoryPersister { + fn store( + &self, + wallet_id: WalletId, + changes: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.store(wallet_id, changes) + } + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.0.flush(wallet_id) + } + fn load(&self) -> Result { + self.0.load() + } + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &crate::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let mut loaded = self.load_with_replay(evidence)?; + loaded.state.wallets.retain(|id, _| *id == wallet_id); + loaded.outcomes.retain(|id, _| *id == wallet_id); + Ok(loaded) + } + fn load_with_replay( + &self, + evidence: &crate::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let mut state = self.load()?; + let mut outcomes = BTreeMap::new(); + for (id, wallet) in &mut state.wallets { + let outcome = + super::super::history_replay::replay_wallet_history_blocking(wallet, evidence) + .map_err(PersistenceError::backend)?; + outcomes.insert(*id, outcome); + } + Ok(super::super::history_replay::HistoryReplayLoad { state, outcomes }) + } + } + + fn make_history_manager( + persister: RecordedHistoryPersister, + ) -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(PreparedHistoryPersister(persister)), + event_handler, + )) + } + + /// Hands back the stored transactions lacking accounting (`None` once + /// backfilled), serves funding transactions by txid, and records what + /// the manager persists. Like a host, a stored row takes its + /// transaction out of the unaccounted set and a complete backfill sets + /// the marker. + struct HistoryPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + unaccounted: Mutex>>, + stored_transactions: Mutex>, + failing_lookup: bool, + /// Every stored transaction is found but cannot be handed over: the + /// lenient record read reports a miss, the strict body read fails. + unusable_funding: bool, + lookups: Mutex>, + stored: Mutex, bool)>>, + } + + impl PlatformWalletPersistence for HistoryPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + SingleWalletPersister { + wallet: self.wallet.clone(), + managed: self.managed.clone(), + } + .load() + } + + fn get_core_tx_record( + &self, + _wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + if self.failing_lookup { + return Err(PersistenceError::backend("store unreadable")); + } + if self.unusable_funding { + return Ok(None); + } + Ok(self + .stored_transactions + .lock() + .unwrap() + .get(txid) + .map(|tx| { + TransactionRecord::new( + tx.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + })) + } + + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + self.lookups.lock().unwrap().push(*txid); + if self.unusable_funding && self.stored_transactions.lock().unwrap().contains_key(txid) + { + return Err(PersistenceError::backend("stored but unreadable")); + } + Ok(self + .get_core_tx_record(wallet_id, txid)? + .map(|record| record.transaction)) + } + + fn load_core_history_for_accounting( + &self, + _wallet_id: WalletId, + ) -> Result>, PersistenceError> { + Ok(self.unaccounted.lock().unwrap().clone()) + } + + fn store_history_accounting( + &self, + wallet_id: WalletId, + rows: &[WalletAccounting], + complete: bool, + ) -> Result<(), PersistenceError> { + self.stored + .lock() + .unwrap() + .push((wallet_id, rows.to_vec(), complete)); + let mut unaccounted = self.unaccounted.lock().unwrap(); + if complete { + *unaccounted = None; + } else if let Some(unaccounted) = unaccounted.as_mut() { + unaccounted.retain(|tx| rows.iter().all(|row| row.txid != tx.txid())); + } + Ok(()) + } + } + + /// A wallet funded by `funding` (100_000 duffs, output 0), and a spend of + /// that coin paying 74_000 to a foreign address. + async fn funded_wallet() -> (Wallet, ManagedWalletInfo, Transaction, Transaction) { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + (ctx.wallet, ctx.managed_wallet, funding, spend) + } + + async fn backfill(persister: HistoryPersister) -> Arc { + let persister = Arc::new(persister); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + let manager = PlatformWalletManager::new(sdk, Arc::clone(&persister), event_handler); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + persister + } + + fn history_persister( + wallet: Wallet, + managed: ManagedWalletInfo, + unaccounted: Option>, + stored: &[&Transaction], + ) -> HistoryPersister { + HistoryPersister { + wallet, + managed, + unaccounted: Mutex::new(unaccounted), + stored_transactions: Mutex::new( + stored.iter().map(|tx| (tx.txid(), (*tx).clone())).collect(), + ), + failing_lookup: false, + unusable_funding: false, + lookups: Mutex::new(Vec::new()), + stored: Mutex::new(Vec::new()), + } + } + + fn without_utxos(mut managed: ManagedWalletInfo) -> ManagedWalletInfo { + for account in managed.accounts.standard_bip44_accounts.values_mut() { + account.utxos.clear(); + } + managed + } + + /// Rows stored before per-wallet accounting are backfilled at load. A + /// spend whose coin is among the unaccounted transactions resolves + /// without a lookup; only the funding transaction's foreign prevout is + /// asked for (and not found). + #[tokio::test] + async fn load_backfills_accounting_for_unaccounted_history() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let persister = backfill(history_persister( + wallet, + without_utxos(managed), + Some(vec![funding.clone(), spend.clone()]), + &[], + )) + .await; + + assert_eq!( + *persister.stored.lock().unwrap(), + vec![( + wallet_id, + vec![ + WalletAccounting { + txid: funding.txid(), + net_amount: 100_000, + direction: TransactionDirection::Incoming, + fee: None, + }, + WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), + }, + ], + true + )] + ); + assert!( + !persister.lookups.lock().unwrap().contains(&funding.txid()), + "the spend's coin resolves from the unaccounted funding transaction" + ); + } + + /// A spend whose coin is neither among the unaccounted transactions nor + /// a UTXO any more resolves through the funding transaction the manager + /// fetches itself; the host is never asked to send its whole history. + #[tokio::test] + async fn load_fetches_the_funding_transaction_a_backfilled_spend_needs() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let persister = backfill(history_persister( + wallet, + without_utxos(managed), + Some(vec![spend.clone()]), + &[&funding], + )) + .await; + + assert_eq!(*persister.lookups.lock().unwrap(), vec![funding.txid()]); + let stored = persister.stored.lock().unwrap().clone(); + assert_eq!(stored.len(), 1); + assert!(stored[0].2, "every transaction was accounted"); + assert_eq!( + stored[0].1, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), + }] + ); + } + + /// A failed funding lookup writes nothing, so the backfill (and the + /// completion it records) runs again at the next load. + #[tokio::test] + async fn load_writes_no_backfill_when_a_funding_lookup_fails() { + let (wallet, managed, _, spend) = funded_wallet().await; + let mut persister = + history_persister(wallet, without_utxos(managed), Some(vec![spend]), &[]); + persister.failing_lookup = true; + let persister = backfill(persister).await; + + assert_eq!(persister.lookups.lock().unwrap().len(), 1); + assert!(persister.stored.lock().unwrap().is_empty()); + } + + /// A funding transaction the host stores but cannot hand over (an + /// InstantSend-only row the lenient record read drops, a failing read) + /// fails the round: nothing is written, not even the rows that did + /// resolve, so the next load runs the whole backfill again. + #[tokio::test] + async fn load_writes_no_backfill_when_a_stored_funding_transaction_is_unusable() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let mut persister = history_persister( + wallet, + without_utxos(managed), + Some(vec![spend]), + &[&funding], + ); + persister.unusable_funding = true; + let persister = backfill(persister).await; + + assert_eq!(*persister.lookups.lock().unwrap(), vec![funding.txid()]); + assert!(persister.stored.lock().unwrap().is_empty()); + } + + /// `spend` with its input redeemed by the key behind `funding`'s output + /// 0, the wallet's own receive address: what the wallet itself signed. + fn signed_by_the_wallet( + managed: &ManagedWalletInfo, + funding: &Transaction, + mut spend: Transaction, + ) -> Transaction { + use key_wallet::managed_account::address_pool::PublicKeyType; + use key_wallet::managed_account::ManagedAccountRef; + + let address = + dashcore::Address::from_script(&funding.output[0].script_pubkey, managed.network) + .expect("the funding pays a P2PKH address"); + let Some(PublicKeyType::ECDSA(pubkey)) = + ManagedAccountRef::Funds(&managed.accounts.standard_bip44_accounts[&0]) + .get_address_info(&address) + .and_then(|info| info.public_key) + else { + panic!("the wallet derived the funded address from an ECDSA key"); + }; + spend.input[0].script_sig = dashcore::script::Builder::new() + .push_slice(<&dashcore::script::PushBytes>::try_from(&[0x30; 71][..]).unwrap()) + .push_slice(<&dashcore::script::PushBytes>::try_from(&pubkey[..]).unwrap()) + .into_script(); + spend + } + + /// A spend the wallet signed whose funding transaction the host does not + /// have yet is left without a row and keeps the backfill open: no + /// completion marker. A later load of the same, already registered, + /// wallet retries it and writes the row once the funding transaction is + /// there, and only then completes. A load after that reads nothing. + #[tokio::test] + async fn load_leaves_an_unresolvable_wallet_spend_open_then_fills_it_at_a_later_load() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let managed = without_utxos(managed); + let spend = signed_by_the_wallet(&managed, &funding, spend); + let persister = Arc::new(history_persister( + wallet, + managed, + Some(vec![spend.clone()]), + &[], + )); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + let manager = PlatformWalletManager::new(sdk, Arc::clone(&persister), event_handler); + + manager.load_from_persistor().await.expect("first load"); + assert_eq!( + *persister.stored.lock().unwrap(), + vec![(wallet_id, Vec::new(), false)], + "skipped: no row, and the backfill stays open" + ); + + persister + .stored_transactions + .lock() + .unwrap() + .insert(funding.txid(), funding.clone()); + manager.load_from_persistor().await.expect("second load"); + assert_eq!( + persister.stored.lock().unwrap()[1..], + [( + wallet_id, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), + }], + true + )], + "filled by the retry on the already registered wallet" + ); + + manager.load_from_persistor().await.expect("third load"); + assert_eq!( + persister.stored.lock().unwrap().len(), + 2, + "a completed backfill is not run again" + ); + } + + /// Nothing unaccounted still completes the backfill, so the host stops + /// being asked; an already backfilled wallet is left alone. + #[tokio::test] + async fn load_completes_an_empty_backfill_and_skips_a_finished_one() { + let (wallet, managed, _, _) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let empty = backfill(history_persister( + wallet.clone(), + managed.clone(), + Some(Vec::new()), + &[], + )) + .await; + assert_eq!( + *empty.stored.lock().unwrap(), + vec![(wallet_id, Vec::new(), true)] + ); + + let finished = backfill(history_persister(wallet, managed, None, &[])).await; + assert!(finished.stored.lock().unwrap().is_empty()); + } + fn make_pending_manager( persister: PendingSendPersister, ) -> Arc> { diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 63de4e4d49b..98f072ee971 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -3,6 +3,9 @@ pub mod accessors; pub mod dashpay_sync; pub mod dpns_sync; +mod history_authorization; +mod history_claims; +pub mod history_replay; pub mod identity_sync; mod load; #[cfg(test)] @@ -600,7 +603,7 @@ impl PlatformWalletManager

{ ); // Build handler list: app handler + internal handlers. - // BalanceUpdateHandler holds a clone of the wallets map (a + // BalanceUpdateHandler weakly references the wallets map (a // wait-free `ArcSwap`, separate from the wallet_manager lock) // so it can look up PlatformWallets and write to their // lock-free balance atomics from broadcast-handler context diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index dbc38ff2c63..4204af07481 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -2189,6 +2189,7 @@ mod tests { identity_manager: crate::changeset::IdentityManagerStartState::default(), unused_asset_locks: std::collections::BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), dashpay_backfill: Default::default(), }, ); diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 2f4cb348f2e..2a9f20ee06b 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -323,6 +323,42 @@ impl SpvRuntime { self.task.lock().unwrap_or_else(PoisonError::into_inner) } + /// Prefetch checked history evidence without holding a wallet or persistence lock. + pub(crate) async fn verify_history_locks( + &self, + candidates: Vec, + ) -> crate::changeset::recorded_history::ReplayEvidence { + let mut evidence = crate::changeset::recorded_history::ReplayEvidence::default(); + let client = self.client.read().await; + let Some(client) = client.as_ref() else { + return evidence; + }; + for lock in candidates { + match client.verify_instant_lock(&lock).await { + Ok(Some(proof)) => { + evidence.instant_locks.insert(proof.txid(), proof); + } + Ok(None) => {} + Err(error) => { + tracing::debug!(%error, txid = %lock.txid, "stored InstantLock failed SPV verification; history load will fail") + } + } + } + evidence + } + + #[cfg(test)] + pub(crate) async fn install_history_fixture( + &self, + fixture: &dash_spv::test_utils::InstantLockFixture, + ) { + let client = self.client.read().await; + fixture + .install(client.as_ref().expect("SPV started")) + .await + .unwrap(); + } + /// Check whether the SPV client has been started. pub fn is_started(&self) -> bool { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index ac46e6fa3c9..aa6285af648 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -1969,6 +1969,7 @@ mod tests { state.wallets.insert( wallet.compute_wallet_id(), ClientWalletStartState { + recorded_history: Default::default(), dashpay_backfill: Default::default(), wallet: wallet.clone(), wallet_info: info.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index bbc92c1aa05..0700db8b0a8 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -5,23 +5,28 @@ //! and re-deriving private keys. use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use dashcore::Address as DashAddress; use dashcore::{OutPoint, Txid}; -use dpp::prelude::CoreBlockHeight; +use dpp::prelude::{AssetLockProof, CoreBlockHeight}; +use key_wallet::account::AccountType; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::ManagedWalletInfo; use crate::changeset::changeset::AssetLockChangeSet; +use crate::changeset::RecordedHistory; use crate::error::PlatformWalletError; -use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::manager::history_replay::lock_matches_record; +use crate::wallet::platform_wallet::{ObservedInputConflict, PlatformWalletInfo}; use super::super::input_cap_violation; use super::super::manager::AssetLockManager; @@ -494,6 +499,215 @@ pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { } } +/// Spend claims for the inputs of every asset lock whose funding is final. +/// +/// A host may drop those inputs from the load projection on the lock's status +/// alone, while the funding's own record still reads as unconfirmed and, with +/// no output paying the wallet, marks nothing spent. From `InstantSendLocked` +/// on the funding cannot be replaced: its inputs are consumed for good, and a +/// later delivery of the transaction that created one must not credit it. +/// +/// The claim names the funding transaction, as a store that keeps each coin's +/// spender does, so both restore the same claim. It is released only if that +/// transaction is swept or abandoned, and becomes permanent once it confirms. +pub(crate) fn final_lock_input_claims<'a>( + locks: impl IntoIterator, +) -> Vec<(OutPoint, Option)> { + locks + .into_iter() + .filter(|lock| match lock.status { + AssetLockStatus::Built | AssetLockStatus::Broadcast => false, + AssetLockStatus::InstantSendLocked + | AssetLockStatus::ChainLocked + | AssetLockStatus::Consumed + | AssetLockStatus::DrainCompleted + | AssetLockStatus::RecoveredFromChain => true, + }) + .flat_map(|lock| { + let funding = lock.transaction.txid(); + lock.transaction + .input + .iter() + .map(move |input| (input.previous_output, Some(funding))) + }) + .collect() +} + +/// Hand `history` the InstantSend lock each tracked asset lock holds for its +/// funding, so the replay settles that funding's stored conflicts by it. +/// +/// A store may keep the funding's record as unconfirmed and no lock beside it, +/// while the lock's proof carries the real one. A lock is taken only when it, +/// the proof's transaction and the tracked funding name one transaction the +/// history holds (`recorded`, its txids), and a lock the store supplied itself +/// stands. These bytes only nominate evidence: the common replay requires a +/// matching SPV-verified token before using their finality. +pub(crate) fn add_tracked_instant_locks<'a>( + history: &mut RecordedHistory, + recorded: &HashSet, + locks: impl IntoIterator, +) { + for lock in locks { + // Terminal proofs are archival; durable claims remain restored separately. + if lock.status.is_consumed() { + continue; + } + + let Some(AssetLockProof::Instant(proof)) = &lock.proof else { + continue; + }; + let funding = lock.transaction.txid(); + if proof.instant_lock.txid == funding + && proof.transaction.txid() == funding + && recorded.contains(&funding) + { + history + .instant_locks + .entry(funding) + .or_insert_with(|| proof.instant_lock.clone()); + } + } +} + +/// Take out of `history` the unconfirmed record of every funding a tracked +/// asset lock holds as chain-final and no lock in `history` settles; a lock +/// settles it only if the replay accepts it for that record. Returns the +/// fundings kept out, whether `history` held a record of them or not. +/// +/// What a tracked asset lock proves about its funding outranks the funding's +/// stored context, and the replay's verdict outranks the lock's status. A lock +/// with an Instant proof makes its funding replay as InstantSend-locked; a +/// chain-final lock proves a height and no block, so its funding stays out of +/// the replay, with its coins as stored and its inputs under +/// [`final_lock_input_claims`]. A funding the replay still defeats gets no +/// claim. +pub(crate) fn withhold_chain_final_fundings<'a>( + history: &mut RecordedHistory, + locks: impl IntoIterator, +) -> HashSet { + let mut withheld: HashSet = locks + .into_iter() + .filter(|lock| match lock.status { + AssetLockStatus::Built + | AssetLockStatus::Broadcast + | AssetLockStatus::InstantSendLocked => false, + AssetLockStatus::ChainLocked + | AssetLockStatus::Consumed + | AssetLockStatus::DrainCompleted + | AssetLockStatus::RecoveredFromChain => true, + }) + .map(|lock| lock.transaction.txid()) + .collect(); + // A funding the history places in a block, or settles with a lock the + // replay accepts, replays like any other record. + let locked = &history.instant_locks; + for stored in &history.transactions { + if withheld.contains(&stored.txid) + && (!matches!(stored.context, TransactionContext::Mempool) + || locked + .get(&stored.txid) + .is_some_and(|lock| lock_matches_record(lock, stored))) + { + withheld.remove(&stored.txid); + } + } + history + .transactions + .retain(|stored| !withheld.contains(&stored.txid)); + withheld +} + +/// Detach from `wallet_info` the unconfirmed records of `fundings`, for +/// [`reattach_records`] to put back once the replay is over. +/// +/// Left attached, the record of a withheld funding would enter the replay's +/// conflict plan as unconfirmed and fall like the history row. A confirmed +/// record stays: no stored competitor can take it. +pub(crate) fn detach_unconfirmed_records( + wallet_info: &mut ManagedWalletInfo, + fundings: &HashSet, +) -> Vec<(AccountType, TransactionRecord)> { + let mut detached = Vec::new(); + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + account.transactions_mut().retain(|txid, record| { + let detach = fundings.contains(txid) && !record.is_confirmed(); + if detach { + detached.push((owner, record.clone())); + } + !detach + }); + } + detached +} + +/// Put back what [`detach_unconfirmed_records`] took, each record in its +/// account. +pub(crate) fn reattach_records( + wallet_info: &mut ManagedWalletInfo, + records: Vec<(AccountType, TransactionRecord)>, +) { + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + for (_, record) in records.iter().filter(|(held, _)| *held == owner) { + account + .transactions_mut() + .entry(record.txid) + .or_insert_with(|| record.clone()); + } + } +} + +/// Confirmed spends of pending asset-lock inputs, read from the stored history. +/// +/// The load replay keeps two kinds of spender out of the wallet history that +/// [`seed_observed_input_conflicts`] reads: one buried under the restored +/// chain lock (finalization evicts its record) and one the checker cannot +/// attribute (it never gets a record). Call before the replay. +pub(crate) fn recorded_input_conflicts<'a>( + locks: impl IntoIterator, + history: &RecordedHistory, +) -> BTreeMap { + // The pending lock transactions that spend each outpoint. + let mut owners: BTreeMap> = BTreeMap::new(); + for lock in locks.into_iter().filter(|lock| { + matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) + }) { + let txid = lock.transaction.txid(); + for input in &lock.transaction.input { + owners.entry(input.previous_output).or_default().push(txid); + } + } + let mut conflicts = BTreeMap::new(); + if owners.is_empty() { + return conflicts; + } + for stored in &history.transactions { + let Some(height) = stored.context.block_info().map(|block| block.height()) else { + continue; + }; + for input in stored.transaction.input.iter().map(|i| i.previous_output) { + // A lock's own confirmation is not a conflict with itself. + let contested = owners + .get(&input) + .is_some_and(|owners| owners.iter().any(|owner| *owner != stored.txid)); + if contested { + conflicts.insert( + input, + ObservedInputConflict { + spender: stored.txid, + height, + }, + ); + } + } + } + conflicts +} + impl AssetLockManager { /// Wait for the broadcaster's transport to come up before a resume /// broadcasts, and return the finality budget left over. @@ -1985,6 +2199,7 @@ mod tests { use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + RecordedHistory, StoredTransaction, }; use crate::error::PlatformWalletError; use crate::test_support::{ @@ -1995,7 +2210,7 @@ mod tests { use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::WalletPersister; - use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; + use crate::wallet::platform_wallet::{ObservedInputConflict, PlatformWalletInfo, WalletId}; use crate::AssetLockFundingType; /// Persistence stub that records every stored changeset so the test @@ -3788,6 +4003,386 @@ mod tests { } } + /// The stored-history evidence names only another transaction's confirmed + /// spend of an input a still-pending lock needs. + #[test] + fn should_report_only_foreign_confirmed_spends_of_pending_lock_inputs() { + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let spending = |spends, lock_time| Transaction { + lock_time, + ..transaction_spending(spends) + }; + let lock = |transaction: Transaction, status| TrackedAssetLock { + operation_id: None, + out_point: OutPoint::new(transaction.txid(), 0), + transaction, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: None, + }; + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + let (contested, own, unconfirmed, consumed, shared) = ( + outpoint(1), + outpoint(2), + outpoint(3), + outpoint(4), + outpoint(5), + ); + let confirmed_lock = spending(own, 0); + // Two pending locks on one input: the confirmation of one is a + // conflict for the other. + let confirmed_rival = spending(shared, 0); + let locks = [ + lock(spending(contested, 0), AssetLockStatus::Broadcast), + lock(confirmed_lock.clone(), AssetLockStatus::Built), + lock(spending(unconfirmed, 0), AssetLockStatus::Broadcast), + lock(spending(consumed, 0), AssetLockStatus::Consumed), + lock(confirmed_rival.clone(), AssetLockStatus::Broadcast), + lock(spending(shared, 2), AssetLockStatus::Built), + ]; + let spender = spending(contested, 1); + let history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: vec![ + stored(spender.clone(), chain_locked_at(100)), + stored(confirmed_lock, confirmed_at(101)), + stored(spending(unconfirmed, 1), TransactionContext::Mempool), + stored(spending(consumed, 1), confirmed_at(102)), + stored(confirmed_rival.clone(), confirmed_at(103)), + ], + instant_locks: BTreeMap::new(), + }; + + assert_eq!( + super::recorded_input_conflicts(&locks, &history), + BTreeMap::from([ + ( + contested, + ObservedInputConflict { + spender: spender.txid(), + height: 100, + }, + ), + ( + shared, + ObservedInputConflict { + spender: confirmed_rival.txid(), + height: 103, + }, + ), + ]) + ); + } + + /// Only a lock that names the tracked funding, backed by a proof of that + /// same transaction and by a stored record of it, reaches the history. A + /// lock the store supplied itself is kept, and a chain proof holds no + /// lock to hand over. + #[test] + fn should_hand_history_only_matching_tracked_instant_locks() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let instant_lock = |txid, input| InstantLock { + txid, + inputs: vec![input], + ..Default::default() + }; + let instant = |lock: InstantLock, transaction: &Transaction| { + AssetLockProof::Instant(InstantAssetLockProof::new(lock, transaction.clone(), 0)) + }; + let tracked = |funding: &Transaction, proof| TrackedAssetLock { + operation_id: None, + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status: AssetLockStatus::InstantSendLocked, + proof: Some(proof), + }; + let stored = |transaction: &Transaction| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + let [matching, foreign_lock, foreign_proof, unrecorded, supplied, chain_proven, other] = + [1, 2, 3, 4, 5, 6, 9].map(|byte| transaction_spending(outpoint(byte))); + let kept = instant_lock(supplied.txid(), outpoint(50)); + let locks = [ + tracked( + &matching, + instant(instant_lock(matching.txid(), outpoint(1)), &matching), + ), + tracked( + &foreign_lock, + instant(instant_lock(other.txid(), outpoint(2)), &foreign_lock), + ), + tracked( + &foreign_proof, + instant(instant_lock(foreign_proof.txid(), outpoint(3)), &other), + ), + tracked( + &unrecorded, + instant(instant_lock(unrecorded.txid(), outpoint(4)), &unrecorded), + ), + tracked( + &supplied, + instant(instant_lock(supplied.txid(), outpoint(5)), &supplied), + ), + tracked( + &chain_proven, + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 150, + out_point: OutPoint::new(chain_proven.txid(), 0), + }), + ), + ]; + let mut history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: [ + &matching, + &foreign_lock, + &foreign_proof, + &supplied, + &chain_proven, + ] + .map(stored) + .to_vec(), + instant_locks: BTreeMap::from([(supplied.txid(), kept.clone())]), + }; + + let recorded = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + super::add_tracked_instant_locks(&mut history, &recorded, &locks); + + assert_eq!( + history.instant_locks, + BTreeMap::from([ + (matching.txid(), instant_lock(matching.txid(), outpoint(1))), + (supplied.txid(), kept), + ]) + ); + } + + #[test] + fn should_not_require_archival_instant_proof_from_consumed_operations() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use std::collections::HashSet; + + let funding = transaction_spending(OutPoint::new(Txid::from_byte_array([71; 32]), 0)); + for status in [AssetLockStatus::Consumed, AssetLockStatus::DrainCompleted] { + let lock = TrackedAssetLock { + operation_id: Some([72; 16]), + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock { + txid: funding.txid(), + ..Default::default() + }, + funding.clone(), + 0, + ))), + }; + let mut history = RecordedHistory::default(); + super::add_tracked_instant_locks( + &mut history, + &HashSet::from([funding.txid()]), + [&lock], + ); + assert!(history.instant_locks.is_empty()); + assert_eq!( + super::final_lock_input_claims([&lock]), + vec![(funding.input[0].previous_output, Some(funding.txid()))] + ); + } + } + + /// Only the unconfirmed record of a funding that a tracked lock holds as + /// chain-final leaves the history, and only when no lock in the history + /// settles it; a lock filed under it that names another transaction + /// settles nothing. A pending or InstantSend-locked lock, a record already + /// in a block and a record no lock tracks all stay. + /// + /// The fundings kept out are returned, one the history does not hold + /// included: a record attached to the wallet for it is kept out as well. + #[test] + fn should_withhold_only_unsettled_chain_final_fundings() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let tracked = |funding: &Transaction, status| TrackedAssetLock { + operation_id: None, + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: None, + }; + let stored = |transaction: &Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked, foreign, unrecorded, drained] = + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map(|byte| transaction_spending(outpoint(byte))); + let locks = [ + tracked(&chain_locked, AssetLockStatus::ChainLocked), + tracked(&consumed, AssetLockStatus::Consumed), + tracked(&recovered, AssetLockStatus::RecoveredFromChain), + tracked(&built, AssetLockStatus::Built), + tracked(&broadcast, AssetLockStatus::Broadcast), + tracked(&instant, AssetLockStatus::InstantSendLocked), + tracked(&in_block, AssetLockStatus::ChainLocked), + tracked(&settled, AssetLockStatus::Consumed), + tracked(&foreign, AssetLockStatus::ChainLocked), + tracked(&unrecorded, AssetLockStatus::Consumed), + tracked(&drained, AssetLockStatus::DrainCompleted), + ]; + let unconfirmed = [ + &drained, + &chain_locked, + &consumed, + &recovered, + &built, + &broadcast, + &instant, + &settled, + &untracked, + &foreign, + ]; + let mut history = RecordedHistory { + required_instant_locks: Default::default(), + owned_prevouts: Default::default(), + transactions: unconfirmed + .map(|transaction| stored(transaction, TransactionContext::Mempool)) + .into_iter() + .chain([stored(&in_block, confirmed_at(101))]) + .collect(), + instant_locks: [&settled, &foreign] + .map(|filed_under| { + let lock = InstantLock { + txid: settled.txid(), + ..Default::default() + }; + (filed_under.txid(), lock) + }) + .into(), + }; + + let withheld = super::withhold_chain_final_fundings(&mut history, &locks); + + assert_eq!( + withheld, + [ + &chain_locked, + &consumed, + &recovered, + &foreign, + &unrecorded, + &drained + ] + .map(|transaction| transaction.txid()) + .into() + ); + let kept: Vec = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + assert_eq!( + kept, + [&built, &broadcast, &instant, &settled, &untracked, &in_block] + .map(|transaction| transaction.txid()) + ); + } + + /// Only the unconfirmed records of the named fundings leave the wallet, + /// and they come back as they were. A confirmed record of a named funding + /// stays: no stored competitor can take it. + #[tokio::test] + async fn should_detach_unconfirmed_records_of_withheld_fundings_and_put_them_back() { + let (ctx, _) = key_wallet::test_utils::TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let mut info = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let [withheld, confirmed, other] = + [1, 2, 3].map(|byte| transaction_spending(outpoint(byte))); + let records = |info: &ManagedWalletInfo| -> Vec<(Txid, bool)> { + info.accounts.standard_bip44_accounts[&0] + .transactions() + .values() + .map(|record| (record.txid, record.is_confirmed())) + .collect() + }; + for record in [ + record_for(withheld.clone(), TransactionContext::Mempool), + record_for(confirmed.clone(), confirmed_at(101)), + record_for(other.clone(), TransactionContext::Mempool), + ] { + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .transactions_mut() + .insert(record.txid, record); + } + let before = records(&info); + + let detached = super::detach_unconfirmed_records( + &mut info, + &[withheld.txid(), confirmed.txid()].into(), + ); + + let attached: Vec = records(&info).into_iter().map(|(txid, _)| txid).collect(); + assert!(!attached.contains(&withheld.txid())); + assert!(attached.contains(&confirmed.txid()) && attached.contains(&other.txid())); + assert_eq!(detached.len(), 1); + + super::reattach_records(&mut info, detached); + + assert_eq!(records(&info), before); + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history. The screen's /// session memory must carry the conflict across that disappearance diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index b6bda3d9645..1205332bc3f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -2,7 +2,7 @@ //! when an upstream `WalletEvent` carries a fresh balance snapshot. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use arc_swap::ArcSwap; use dash_spv::EventHandler; @@ -22,7 +22,7 @@ use crate::wallet::PlatformWallet; /// `WalletBalance` atomics so SwiftUI subscribers see the new totals. /// /// Registered in `PlatformWalletManager`'s handler list. The handler -/// holds an `Arc` clone of the manager's `wallets` map (a *separate* +/// holds a `Weak` reference to the manager's `wallets` map (a *separate* /// structure from the heavily-contended `wallet_manager` SPV write /// lock). SPV holds the wallet-manager write lock for the entire /// duration of block processing — looking the balance up through *that* @@ -41,12 +41,16 @@ use crate::wallet::PlatformWallet; /// exactly that snapshot when it raced a lifecycle write, leaving /// removed funds on display indefinitely. pub struct BalanceUpdateHandler { - wallets: Arc>>>, + wallets: Weak>>>, } impl BalanceUpdateHandler { + /// Observe the manager-owned map without extending its lifetime. pub fn new(wallets: Arc>>>) -> Self { - Self { wallets } + Self { + // Wallet broadcasters reach this handler through the SPV runtime. + wallets: Arc::downgrade(&wallets), + } } } @@ -94,7 +98,10 @@ impl EventHandler for BalanceUpdateHandler { // wallet immediately after publishing — `register_wallet` and // `load_from_persistor` alike — so the window costs a snapshot, not // the balance. - let wallets = self.wallets.load(); + let Some(wallets) = self.wallets.upgrade() else { + return; + }; + let wallets = wallets.load(); if let Some(pw) = wallets.get(wallet_id) { pw.balance().set( balance.confirmed(), diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index b4f4813facf..2026dfc9eea 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -855,7 +855,8 @@ mod tests { "the fixture event must report at least one spend, or the test \ would pass without observing anything" ); - let handler = SpendObservationHandler::new(wallets_map(&[core])); + let map = wallets_map(&[core]); + let handler = SpendObservationHandler::new(Arc::clone(&map)); dash_spv::EventHandler::on_wallet_event(&handler, &event); } @@ -1278,7 +1279,8 @@ mod tests { ); } - let handler = SpendObservationHandler::new(wallets_map(&[&core_a, &core_b])); + let map = wallets_map(&[&core_a, &core_b]); + let handler = SpendObservationHandler::new(Arc::clone(&map)); // An event naming a wallet id registered NOWHERE: the lookup misses, // nothing panics, and neither fence moves — the fail-safe direction. diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index e57e57c759b..ff353287264 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -8,7 +8,7 @@ //! outpoints are spent. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use arc_swap::ArcSwap; use dash_spv::EventHandler; @@ -91,12 +91,16 @@ use crate::wallet::PlatformWallet; /// nothing to retry against. /// pub struct SpendObservationHandler { - wallets: Arc>>>, + wallets: Weak>>>, } impl SpendObservationHandler { + /// Observe the manager-owned map without extending its lifetime. pub fn new(wallets: Arc>>>) -> Self { - Self { wallets } + Self { + // Wallet broadcasters reach this handler through the SPV runtime. + wallets: Arc::downgrade(&wallets), + } } /// Apply `observation` to its wallet's generation. @@ -107,7 +111,10 @@ impl SpendObservationHandler { fn observe(&self, wallet_id: WalletId, outpoints: Vec) { // The wallets map, NOT the SPV-contended wallet_manager lock — see // the type docs. - if let Some(wallet) = self.wallets.load().get(&wallet_id) { + let Some(wallets) = self.wallets.upgrade() else { + return; + }; + if let Some(wallet) = wallets.load().get(&wallet_id) { wallet.generation().observe_spent(outpoints); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 757b71290a6..004468d2573 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -4423,6 +4423,7 @@ mod tests { unused_asset_locks: std::collections::BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), dashpay_backfill: self.backfill.clone(), + recorded_history: Default::default(), }, ); Ok(ClientStartState { @@ -6056,6 +6057,11 @@ mod tests { } impl PlatformWalletPersistence for SequencedHost { + fn replay_lock_candidates(&self) -> Result, PersistenceError> { + // Advisory proof discovery must not advance the authoritative snapshot sequence. + self.current.replay_lock_candidates() + } + fn store_commits_inline(&self) -> bool { self.current.store_commits_inline() } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs index 28d71460ad7..f78b1a803df 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -155,6 +155,7 @@ impl PlatformWalletPersistence for RecoveryPersistence { wallets: BTreeMap::from([( self.wallet.compute_wallet_id(), ClientWalletStartState { + recorded_history: Default::default(), dashpay_backfill: Default::default(), wallet: self.wallet.clone(), wallet_info: self.info.clone(), diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b3b3b55d40d..023cc7ae518 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2872,6 +2872,13 @@ fn build_wallet_restore_entry( // inert here, exactly as it was before the field existed. unconfirmed_outgoing_tx_records: ptr::null(), unconfirmed_outgoing_tx_records_count: 0, + // TODO(android-core-history-restore): the Kotlin host does not hand + // back its stored transaction history yet, so confirmed-spend guards + // are not rebuilt on Android load (the persister does not attest + // CORE_HISTORY_RESTORE and load warns). Null/0 keeps the + // pre-history behaviour. + recorded_transactions: ptr::null(), + recorded_transactions_count: 0, core_address_pools: ptr::null(), core_address_pools_count: 0, last_applied_chain_lock_bytes: ptr::null(), diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index d9b6170b7c7..c23dcdb2700 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -5,7 +5,8 @@ distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the model checksum match the observed App Store 9.0.2 database. Active models are -V3. Other intermediate development shapes remain unsupported. +V5. The merged V4 graph is preserved by `DashSchemaSnapshotV4`. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other +intermediate development shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 properties added on August 28, after the August 27 App Store release. The @@ -15,25 +16,42 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3. Accepted V1 has a separate -V1 → V3 plan: V1 already contains the 13 properties missing from historical -V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata +The main migration plan is historical V2 → V3 → V4 → V5. Accepted V1 has a separate +V1 → V3 → V4 → V5 plan: V1 already contains the 13 properties missing from historical +V2, so a chain through V2 could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches -current V3 exactly, entity hashes and checksum alike. That alias therefore -lasts only until the next live-graph change: after it, every store still -labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails -closed. Check internal devices still carrying that label before the next -shape change and migrate or deliberately reset them then, rather than -discovering them afterwards as failed opens. +frozen V3 exactly, entity hashes and checksum alike. The alias remains supported +when the live graph changes; other stores labelled `2.0.0` fail closed. + +V5 adds the `PersistentTransactionAccounting` and `PersistentCoreInstantLock` +entities and the `PersistentWallet.transactionAccountingBackfilled` attribute +(default `false`) through a lightweight migration. Both entities belong to the same unreleased +V5 graph; V4 remains frozen. `PersistentCoreInstantLock` stores raw consensus +bytes per `(networkRaw, walletId, txid)` for Rust's load-time SPV verification +after restore; storing the bytes does not establish finality. + +Each accounting row, one per +`(walletId, txid)`, holds the net amount, direction and fee `rs-platform-wallet` +emitted for that wallet. Migrated stores start with no rows; at the first load +of each wallet Rust reads back the wallet's transactions that lack a row +(`on_load_core_history_for_accounting_fn`), fetches any funding transaction it +needs through `on_get_core_tx_record_fn`, and writes the rows +(`on_persist_transaction_accounting_fn`) and the marker +(`on_persist_core_history_accounted_fn`) in one round. A transaction whose +wallet-signed input Rust cannot resolve yet gets no row, and the round then +writes no marker, so the next load reads the transactions still without a +row again. A missing row means "not yet accounted", never zero. Once the +marker is set, later loads skip the read. Deleting a wallet deletes its rows; +deleting a transaction cascades to them. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, -`accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`accepted-v1-to-v5`, `historical-v2-to-v5`, `previous-live-v2-v3-shape`, +`published-v3-to-v5`, `published-v4-to-v5`, `unsupported-v2`, `labelled-current-v5`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0`, `4.0.0` or `5.0.0` label takes the default plan without any probe, and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. @@ -137,9 +155,9 @@ The run failed before upload, so this provenance establishes a tested source layout rather than proof of publication. Regression tests exercise the public factory, data/default preservation, writes, reopen, and failure recovery. -Keep the bridge for installations that skip the V3 app release. When advancing -to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 -step before the normal V3-to-current plan. The bridge must never automatically +Keep the bridge for installations that skip the V3 app release. `DashSchemaV3` +stays bound to its released snapshot, retaining the legacy-to-V3 step before +the normal V3-to-current plan. The bridge must never automatically follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ba455874d35..7702a85c56e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -94,13 +94,25 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentTransactionAccounting.self, + PersistentCoreInstantLock.self ] } + /// The live versioned schema. `schema` and every diagnostic's + /// `target_version` derive from it, so a schema bump is one edit. + typealias LiveSchema = DashSchemaV5 + /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: LiveSchema.self) + } + + /// `LiveSchema`'s version as logged in `target_version` (`major.minor.patch`). + static var targetVersion: String { + let version = LiveSchema.versionIdentifier + return "\(version.major).\(version.minor).\(version.patch)" } /// Create a persistent model container for storing data. @@ -172,7 +184,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText(targetVersion)]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +197,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText(targetVersion)]) return container } catch { logMigrationFailure(error) @@ -201,7 +213,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("4.0.0")]) + "target_version": .publicText(targetVersion)]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +230,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText(targetVersion)]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +248,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("4.0.0")]) + "route": .publicText(route), "target_version": .publicText(targetVersion)]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -247,7 +259,7 @@ public enum DashModelContainer { // probe stays fatal here: the open fails with the probe's own error // instead of an inapplicable plan, and the store is untouched. if versions == ["1.0.0"], try matches(DashSchemaV1.self) { - logRoute("accepted-v1-to-v3") + logRoute("accepted-v1-to-v5") return DashAcceptedV1MigrationPlan.self } if versions == ["2.0.0"] { @@ -261,14 +273,16 @@ public enum DashModelContainer { throw DashLegacyStoreSQLite.Failure.unsupported( "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") } - logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") + logRoute(historical ? "historical-v2-to-v5" : "previous-live-v2-v3-shape") } else if versions == ["3.0.0"] { + logRoute("published-v3-to-v5") + } else if versions == ["4.0.0"] { + logRoute("published-v4-to-v5") + } else if versions == ["5.0.0"] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("published-v3-to-v4") - } else if versions == ["4.0.0"] { - logRoute("labelled-current-v4") + logRoute("labelled-current-v5") } else { logRoute("ordinary-current-plan") } @@ -289,13 +303,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -303,10 +318,11 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] } } @@ -382,8 +398,15 @@ public enum DashSchemaV3: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } } -/// Current schema: optional asset-lock operation IDs correlate drain recovery. +/// Frozen base schema with optional drain operation IDs. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } +} + +/// Current schema: per-wallet transaction accounting (`PersistentTransactionAccounting`) +/// the wallet's accounting-backfill marker, and raw InstantLock sidecars. +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift new file mode 100644 index 00000000000..bfe422e0d52 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftData + +/// Raw consensus bytes for Rust's load-time SPV verification; storage grants no finality. +@Model +public final class PersistentCoreInstantLock { + #Unique([\.networkRaw, \.walletId, \.txid]) + #Index([\.walletId]) + + public var networkRaw: UInt32 + public var walletId: Data + public var txid: Data + public var consensusBytes: Data + + public init(networkRaw: UInt32, walletId: Data, txid: Data, consensusBytes: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.txid = txid + self.consensusBytes = consensusBytes + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..fde28b02c95 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -84,9 +84,12 @@ public final class PersistentTransaction { /// replaces it with the real discriminant on touch. Accessors /// treat the sentinel as unknown (no branch fires). public var transactionTypeKind: UInt8 = 0xFF - /// Net amount in duffs (signed: positive=received, negative=sent). + /// Net amount in duffs (signed: positive=received, negative=sent) as the + /// last recording wallet's `TransactionRecordFFI` carried it. Shared by + /// every wallet in the transaction, so only unscoped views (the storage + /// explorer) show it; per-wallet values live in [`walletAccounting`]. public var netAmount: Int64 - /// Fee in duffs (nil if unknown). + /// Fee in duffs (nil if unknown); same last-writer caveat as `netAmount`. public var fee: UInt64? /// User-assigned label. public var label: String @@ -180,6 +183,11 @@ public final class PersistentTransaction { @Relationship(inverse: \PersistentAccount.involvedTransactions) public var involvedAccounts: [PersistentAccount] = [] + /// Rust-emitted accounting per wallet, one row per `(wallet, txid)`. + /// Cascade-deletes with the transaction. + @Relationship(deleteRule: .cascade, inverse: \PersistentTransactionAccounting.transaction) + public var walletAccounting: [PersistentTransactionAccounting] = [] + public init( txid: Data, transactionData: Data, @@ -228,12 +236,68 @@ public final class PersistentTransaction { } } + /// The accounting Rust emitted for `walletId`, if any. + public func accounting(for walletId: Data) -> PersistentTransactionAccounting? { + walletAccounting.first { $0.walletId == walletId } + } + + /// Core value movement for one wallet; `nil` when Rust has emitted no + /// accounting for this wallet and transaction yet. `nil` means "not yet + /// resolved, will be filled", not zero (see + /// `PersistentTransactionAccounting`). + public func netAmount(for walletId: Data) -> Int64? { + accounting(for: walletId)?.netAmount + } + + /// `CoreDirectionCode` for one wallet; `nil` like `netAmount(for:)`. + public func direction(for walletId: Data) -> UInt32? { + accounting(for: walletId)?.direction + } + + /// Fee for one wallet; `nil` without a row, or when it funded only some inputs. + public func fee(for walletId: Data) -> UInt64? { + accounting(for: walletId)?.fee + } + + /// The wallet's Core value movement in DASH, or "—" without accounting. + public func formattedAmount(for walletId: Data) -> String { + netAmount(for: walletId).map(Self.format(duffs:)) ?? Self.noAccounting + } + + /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. + public func displayNetAmount(for walletId: Data?) -> Int64? { + walletId.map { netAmount(for: $0) } ?? netAmount + } + + /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. + public func displayDirectionCode(for walletId: Data?) -> UInt32? { + walletId.map { direction(for: $0) } ?? direction + } + + /// Fee for `walletId`, or the stored fee when no wallet scope is given. + public func displayFee(for walletId: Data?) -> UInt64? { + walletId.map { fee(for: $0) } ?? fee + } + + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. + public func displayFormattedAmount(for walletId: Data?) -> String { + walletId.map { formattedAmount(for: $0) } ?? formattedAmount + } + + /// Shown in place of an amount Rust has not accounted for this wallet. + public static let noAccounting = "—" + + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. + public static func format(duffs: Int64) -> String { + String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) + } + public var directionName: String { switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" + case CoreDirectionCode.incoming: return "Incoming" + case CoreDirectionCode.outgoing: return "Outgoing" + case CoreDirectionCode.internalTransfer: return "Internal" + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Unknown" } } @@ -337,9 +401,7 @@ public final class PersistentTransaction { } public var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) + Self.format(duffs: netAmount) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift new file mode 100644 index 00000000000..28df6c50ddd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift @@ -0,0 +1,52 @@ +import Foundation +import SwiftData + +/// One transaction's Core accounting for ONE wallet, exactly as +/// `rs-platform-wallet` emitted it (`TransactionAccountingFFI`, through +/// `on_persist_transaction_accounting_fn`). +/// +/// A transaction shared by several local wallets has one row per wallet, +/// so each wallet shows its own side of it. A missing row means "not yet +/// accounted, will be filled", never "accounted as zero": a payload-only +/// provider transaction, a stored row the load-time backfill has not +/// reached, or one whose wallet-signed input Rust cannot resolve yet (its +/// funding transaction is unavailable). Rust writes the row once it can, +/// at a later backfill or a live update, and never writes a guessed one. +/// Pure storage: nothing on the Swift side derives, corrects or vetoes +/// these values. +@Model +public final class PersistentTransactionAccounting { + #Unique([\.walletId, \.txid]) + #Index([\.walletId], [\.txid]) + + /// The wallet this accounting belongs to (32 bytes). + public var walletId: Data + /// Transaction id, raw wire bytes like `PersistentTransaction.txid`. + public var txid: Data + /// Net Core amount in duffs for this wallet (positive = received). + public var netAmount: Int64 + /// `CoreDirectionCode` wire value for this wallet. + public var direction: UInt32 + /// Fee in duffs; `nil` unless this wallet funded every input. + public var fee: UInt64? + /// Inverse of `PersistentTransaction.walletAccounting`, which cascades. + public var transaction: PersistentTransaction? + public var lastUpdated: Date + + public init( + walletId: Data, + txid: Data, + netAmount: Int64, + direction: UInt32, + fee: UInt64?, + transaction: PersistentTransaction? + ) { + self.walletId = walletId + self.txid = txid + self.netAmount = netAmount + self.direction = direction + self.fee = fee + self.transaction = transaction + self.lastUpdated = Date() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 52365db3006..2d98dac84be 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -118,6 +118,11 @@ public final class PersistentWallet { /// column, or never verified) means the full check runs at the next /// unlock. public var seedBindingVerifiedMarker: String? + /// Set once Rust's load-time accounting backfill has evaluated this + /// wallet's stored transactions (`on_persist_core_history_accounted_fn`). + /// Opaque to Swift: it only stops the history from being offered again. + /// `false` for rows predating the column, so they are backfilled once. + public var transactionAccountingBackfilled: Bool = false /// Record timestamps. public var createdAt: Date public var lastUpdated: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift new file mode 100644 index 00000000000..27f3c9abf4f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift @@ -0,0 +1,11 @@ +import DashSDKFFI + +/// Swift names for the `direction` wire values of `PersistentTransaction` +/// and `PersistentTransactionAccounting`, bound to the constants the Rust +/// FFI exports (`PLATFORM_WALLET_TRANSACTION_DIRECTION_*`), never re-declared. +public enum CoreDirectionCode { + public static let incoming = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING) + public static let outgoing = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING) + public static let internalTransfer = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL) + public static let coinJoin = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN) +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 63b20689d2c..dcfc10889a2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -107,6 +107,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Rust only honours the declaration when the payments callback is /// actually wired. public static let dashpayPayments: UInt64 = 1 << 12 + /// Wallet load hands back every stored transaction + /// (`WalletRestoreEntryFFI.recorded_transactions`), so Rust's shared load + /// replay rebuilds the spend guards of confirmed spends and a redelivered + /// funding transaction cannot resurrect a spent output. Mirrors + /// `PersistenceCapabilities::CORE_HISTORY_RESTORE`; Rust only honours the + /// declaration when the wallet-list load callbacks are wired. + public static let coreHistoryRestore: UInt64 = 1 << 14 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 @@ -1643,6 +1650,10 @@ public class PlatformWalletManager: ObservableObject { /// Rehydrate wallets from SwiftData on app launch. /// + /// Start SPV before restoring history with InstantLocks. Missing verified + /// quorum evidence throws; callers may retry after synchronization while + /// SPV continues running. A started client does not imply evidence readiness. + /// /// Calls `platform_wallet_manager_load_from_persistor` which fires /// the Swift-side `on_load_wallet_list_fn` callback. For each /// persisted wallet, Rust reconstructs an **external-signable** diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f1d499682d7..94efab86f3c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -18,6 +18,11 @@ protocol ModelFetching: Sendable { ) throws -> [T] } +private enum HistoryReplayPersistenceError: Error { + case operationFailed(Int32) + case invalidEffects +} + private enum AssetLockPersistenceError: Error { case malformedRecord case duplicateOperationId @@ -172,6 +177,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// entry points — both the FFI callback shims and the /// app-facing accessors — funnel through `onQueue` so the /// context is only ever touched on this queue. + private let serialQueueKey = DispatchSpecificKey() + private var replayWalletId: Data? private let serialQueue = DispatchQueue( label: "org.dash.platform-wallet.persistence", qos: .userInitiated @@ -351,6 +358,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) self.trackedMasternodeContext.autosaveEnabled = false + self.serialQueue.setSpecific(key: serialQueueKey, value: 1) } /// Synchronously run `body` on `serialQueue`. @@ -362,11 +370,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// turns the queue into the handler's de-facto actor: only /// one thread runs SwiftData operations at a time. /// - /// Do not call `onQueue` from another method that already - /// runs on the queue; `DispatchQueue.sync` will deadlock on - /// recursive entry. The internal helpers in this file all - /// assume they are already on the queue and call - /// `backgroundContext` directly. + /// A synchronous Rust replay thunk re-enters through callbacks on this queue. + /// Queue-specific dispatch keeps those nested calls inside the same transaction. /// Every caller arrives from a Rust-owned tokio worker thread, and such a /// thread has no autorelease pool: nothing on it ever drains, because the /// drain is normally done by the run loop or by GCD's own worker wrapper, @@ -384,7 +389,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// The pool goes inside the `sync` so it wraps exactly one unit of work /// and is drained before the Rust caller is resumed. private func onQueue(_ body: () throws -> T) rethrows -> T { - try serialQueue.sync { + if DispatchQueue.getSpecific(key: serialQueueKey) != nil { + return try body() + } + return try serialQueue.sync { try autoreleasepool { try body() } } } @@ -2656,6 +2664,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.providerVotingKeyHash = tx.has_provider_voting_key_hash ? withUnsafeBytes(of: tx.provider_voting_key_hash) { Data($0) } : nil + // The shared last-writer scalars, kept for unscoped display. The + // per-wallet values arrive through `persistTransactionAccounting`. record.netAmount = tx.net_amount record.fee = tx.has_fee ? tx.fee : nil if let labelPtr = tx.label { @@ -3256,6 +3266,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.coreHistoryRestore | PlatformWalletPersistenceCapabilities.assetLockOperationIds ) } @@ -3296,6 +3307,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + // Per-wallet transaction accounting exactly as Rust computes it: + // after each round's changeset, and once per wallet at load for + // stored rows that have none yet (Rust reads them back, computes, + // and writes the rows plus the completion marker in one round). + extensionCallbacks.on_persist_transaction_accounting_fn = persistTransactionAccountingCallback + extensionCallbacks.on_load_core_history_for_accounting_fn = loadCoreHistoryForAccountingCallback + extensionCallbacks.on_load_core_history_for_accounting_free_fn = + loadCoreHistoryForAccountingFreeCallback + extensionCallbacks.on_persist_core_history_accounted_fn = persistCoreHistoryAccountedCallback + extensionCallbacks.on_persist_core_instant_lock_fn = persistCoreInstantLockCallback + extensionCallbacks.on_load_core_instant_lock_fn = loadCoreInstantLockCallback + extensionCallbacks.on_load_core_instant_lock_free_fn = freeCoreInstantLockCallback + extensionCallbacks.on_replay_wallet_fn = replayWalletCallback + extensionCallbacks.on_load_replay_claims_fn = loadReplayClaimsCallback + extensionCallbacks.on_load_replay_claims_free_fn = freeReplayClaimsCallback + extensionCallbacks.on_apply_replay_effects_fn = applyReplayEffectsCallback return extensionCallbacks } @@ -5065,6 +5092,275 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (written, skipped, failed) } + /// The Rust thunk reads, decides, applies and rereads synchronously in this scope. + // TODO(macOS-validation): run AtomicHistoryReplayTests against the generated FFI header and SwiftData store. + func replayWallet(walletId: Data, operation: () -> Int32) -> Int32 { + onQueue { + guard !inChangeset, replayWalletId == nil, !backgroundContext.hasChanges else { + return 1 + } + replayWalletId = walletId + inChangeset = true + roundIndex = nil + defer { + roundIndex = nil + inChangeset = false + replayWalletId = nil + } + do { + try backgroundContext.transaction { + try healIdentityIsLocalFlags(walletId: walletId) + let result = operation() + guard result == 0 else { + throw HistoryReplayPersistenceError.operationFailed(result) + } + } + return 0 + } catch { + backgroundContext.rollback() + return 1 + } + } + } + + /// Raw material only; Rust validates ownership and chooses claim provenance. + func loadReplayClaims(walletId: Data) throws -> [ReplayClaimRestoreFFI] { + try onQueue { + guard replayWalletId == walletId else { throw HistoryReplayPersistenceError.invalidEffects } + let empty = Data() + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId || $0.walletId == empty } + )).filter { Self.resolvedWalletId(of: $0) == walletId } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + // Validate before allocating any nested buffers, so failure cannot leak them. + guard coins.allSatisfy({ $0.outpoint.count == 36 + && $0.outpoint == PersistentTxo.makeOutpoint(txid: Data($0.outpoint.prefix(32)), vout: $0.vout) + && ($0.supersededByTxid ?? $0.spendingTransaction?.txid).map { $0.count == 32 } != false }), + pending.allSatisfy({ $0.outpoint.count == 36 && $0.spendingTxid.count == 32 }) + else { throw HistoryReplayPersistenceError.invalidEffects } + let pendingByOutpoint = Dictionary(grouping: pending, by: \.outpoint) + var result: [ReplayClaimRestoreFFI] = [] + for coin in coins { + var row = ReplayClaimRestoreFFI() + copyBytes(Data(coin.outpoint.prefix(32)), into: &row.txid) + row.vout = coin.vout + row.has_output = true + row.value = coin.amount + if !coin.scriptPubKey.isEmpty { + let script = UnsafeMutablePointer.allocate(capacity: coin.scriptPubKey.count) + coin.scriptPubKey.copyBytes(to: script, count: coin.scriptPubKey.count) + row.script = UnsafePointer(script) + row.script_len = UInt(coin.scriptPubKey.count) + } + let claimant = coin.supersededByTxid ?? coin.spendingTransaction?.txid + // A matching sidecar carries the lifetime absent from the TXO schema. + let hasSidecar = pendingByOutpoint[coin.outpoint]?.contains { + $0.spendingTxid == claimant + } ?? false + row.is_held = coin.isSpent && !hasSidecar + if let claimant { + row.has_claimant = true + copyBytes(claimant, into: &row.claimant) + } + result.append(row) + } + for claim in pending { + var row = ReplayClaimRestoreFFI() + copyBytes(Data(claim.outpoint.prefix(32)), into: &row.txid) + row.vout = claim.outpoint.suffix(4).enumerated().reduce(UInt32(0)) { + $0 | (UInt32($1.element) << (8 * $1.offset)) + } + row.is_held = true + row.has_claimant = true + copyBytes(claim.spendingTxid, into: &row.claimant) + row.has_mined_height = claim.winnerMinedHeight != nil + row.mined_height = claim.winnerMinedHeight ?? 0 + result.append(row) + } + return result + } + } + + /// Apply the owner's exact verdicts. No save: the enclosing replay owns commit/rollback. + func applyReplayEffects(walletId: Data, effects: HistoryReplayEffectsFFI) throws { + try onQueue { + guard replayWalletId == walletId, inChangeset else { + throw HistoryReplayPersistenceError.invalidEffects + } + let records = try replayRows(effects.removed_records, count: effects.removed_records_count) + .map { dataFromTuple32($0) } + let evidence = try replayRows(effects.removed_evidence, count: effects.removed_evidence_count) + .map { dataFromTuple32($0) } + let claims = try replayRows(effects.claims, count: effects.claims_count) + let locks = try replayRows(effects.removed_asset_locks, count: effects.removed_asset_locks_count) + let payments = try replayRows(effects.payments, count: effects.payments_count) + guard claims.allSatisfy({ $0.action <= 2 }) else { + throw HistoryReplayPersistenceError.invalidEffects + } + for txid in records { + let matches = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + )) + for transaction in matches { + // History rows are shared: detach only this wallet's membership. + for output in transaction.outputs.filter({ Self.resolvedWalletId(of: $0) == walletId }) { + output.transaction = nil + backgroundContext.delete(output) + } + for input in transaction.inputs.filter({ Self.resolvedWalletId(of: $0) == walletId }) { + input.spendingTransaction = nil + } + for input in transaction.pendingInputs.filter({ $0.walletId == walletId }) { + input.spendingTransaction = nil + backgroundContext.delete(input) + } + for accounting in transaction.walletAccounting.filter({ $0.walletId == walletId }) { + accounting.transaction = nil + backgroundContext.delete(accounting) + } + transaction.involvedAccounts.removeAll { $0.wallet.walletId == walletId } + if transaction.outputs.isEmpty && transaction.inputs.isEmpty + && transaction.pendingInputs.isEmpty && transaction.walletAccounting.isEmpty + && transaction.involvedAccounts.isEmpty { + backgroundContext.delete(transaction) + } + } + } + for txid in evidence { + for row in try backgroundContext.fetch(coreInstantLockDescriptor(walletId: walletId, txid: txid)) { + backgroundContext.delete(row) + } + } + if !evidence.isEmpty { + let removed = Set(evidence) + let empty = Data() + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId || $0.walletId == empty } + )) + for coin in coins where Self.resolvedWalletId(of: coin) == walletId + && removed.contains(Data(coin.outpoint.prefix(32))) { + backgroundContext.delete(coin) + } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + for row in pending where removed.contains(Data(row.outpoint.prefix(32))) { + backgroundContext.delete(row) + } + } + for effect in claims { + let outpoint = PersistentTxo.makeOutpoint(txid: dataFromTuple32(effect.txid), vout: effect.vout) + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + )).filter { Self.resolvedWalletId(of: $0) == walletId } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.outpoint == outpoint } + )) + for row in pending { backgroundContext.delete(row) } + switch effect.action { + case 0: + for coin in coins { backgroundContext.delete(coin) } + case 1: + for coin in coins { + coin.isSpent = false + coin.supersededByTxid = nil + coin.spendingTransaction = nil + } + case 2: + let claimant = effect.has_claimant ? dataFromTuple32(effect.claimant) : nil + for coin in coins { + coin.isSpent = true + coin.supersededByTxid = claimant + coin.spendingTransaction = nil + } + // Keep the exact lifetime separately: TXOs have no winner-height column. + if let claimant, coins.isEmpty || effect.has_mined_height { + let row = PersistentPendingInput(outpoint: outpoint, inputIndex: 0, + spendingTxid: claimant, spendingTransaction: nil, walletId: walletId) + row.isSweptTombstone = true + row.winnerMinedHeight = effect.has_mined_height ? effect.mined_height : nil + backgroundContext.insert(row) + } else if coins.isEmpty || (claimant == nil && effect.has_mined_height) { + // This schema cannot represent anonymous placeholder/stamp metadata. + throw HistoryReplayPersistenceError.invalidEffects + } + default: + throw HistoryReplayPersistenceError.invalidEffects + } + } + for outpoint in locks { + let bytes = PersistentTxo.makeOutpoint(txid: dataFromTuple32(outpoint.txid), vout: outpoint.vout) + let encoded = PersistentAssetLock.encodeOutPoint(rawBytes: bytes) + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.outPointHex == encoded } + )) { backgroundContext.delete(row) } + } + for entry in payments { + let ownerId = dataFromTuple32(entry.owner_identity_id) + guard let txidPointer = entry.txid, + let direction = DashPayPaymentDirection(rawValue: entry.direction_raw), + let status = DashPayPaymentStatus(rawValue: entry.status_raw), + let owner = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId } + )).first, owner.wallet?.walletId == walletId + else { throw HistoryReplayPersistenceError.invalidEffects } + let txid = String(cString: txidPointer) + let networkRaw = owner.networkRaw + guard let row = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerId && $0.txid == txid + } + )).first else { throw HistoryReplayPersistenceError.invalidEffects } + row.counterpartyIdentityId = dataFromTuple32(entry.counterparty_id) + row.amountDuffs = entry.amount_duffs + row.directionRaw = direction.rawValue + row.statusRaw = status.rawValue + row.memo = entry.memo.map { String(cString: $0) } + row.lastUpdated = Date() + } + } + } + + // MARK: - Raw InstantLock storage + + private func coreInstantLockDescriptor(walletId: Data, txid: Data) throws + -> FetchDescriptor { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires the wallet network") + } + let networkRaw = network.rawValue + return FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.txid == txid + }) + } + + func persistCoreInstantLock(walletId: Data, txid: Data, bytes: Data) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires a changeset") + } + let descriptor = try coreInstantLockDescriptor(walletId: walletId, txid: txid) + if let row = try backgroundContext.fetch(descriptor).first { + row.consensusBytes = bytes + } else { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires the wallet network") + } + backgroundContext.insert(PersistentCoreInstantLock( + networkRaw: network.rawValue, walletId: walletId, txid: txid, consensusBytes: bytes)) + } + } + } + + func loadCoreInstantLock(walletId: Data, txid: Data) throws -> Data? { + try onQueue { + try backgroundContext.fetch(coreInstantLockDescriptor(walletId: walletId, txid: txid)) + .first?.consensusBytes + } + } + // MARK: - Identity balance freshness (additive persistence extension) private func balanceMetadataDescriptor(walletId: Data, identityId: Data) @@ -6654,7 +6950,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(row) } + let rawLocks: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + rawLocks = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + rawLocks = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + for row in try backgroundContext.fetch(rawLocks) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } + if let walletRow = walletRow { + // The departing wallet's accounting rows go with it; a + // shared transaction keeps the other wallets' rows. + let accountingRows = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + for row in try backgroundContext.fetch(accountingRows) { + backgroundContext.delete(row) + } // Wallet → identities is `.nullify`; this delete // path cascades them explicitly. let identitiesToDelete = Array(walletRow.identities) @@ -7005,75 +7321,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// wallet_id, accounts)`; accounts come directly from the spec /// array, wallet id from the top-level struct. /// - /// One-shot upgrade heal: promote `isLocal` on wallet-linked rows - /// still carrying `false` — the persister used to write a - /// constant `false`, so a wallet's own identities (which are - /// always local) were mis-marked on stores from that era. - /// Promote-only and idempotent; a `true` on an unlinked row - /// (manual add) is never touched. Runs here because load is the - /// one guaranteed per-launch pass over the store, outside any - /// changeset round. - private func healIdentityIsLocalFlags() { - guard !inChangeset else { return } - guard let rows = try? backgroundContext.fetch( - FetchDescriptor() - ) else { return } - var healed = 0 - for row in rows where row.wallet != nil && !row.isLocal { + /// Stage wallet-linked identity metadata only inside the atomic replay transaction. + private func healIdentityIsLocalFlags(walletId: Data) throws { + let rows = try backgroundContext.fetch(FetchDescriptor()) + for row in rows where row.wallet?.walletId == walletId && !row.isLocal { + guard network == nil || row.networkRaw == network?.rawValue else { continue } row.isLocal = true - healed += 1 - } - guard healed > 0 else { return } - do { - try backgroundContext.save() - SDKLogger.event( - "persistence_identity_flags_healed", - category: .persistence, - fields: ["row_count": .integer(Int64(healed))] - ) - } catch { - // Non-fatal: the next launch retries. Roll back so the - // failed heal can't bleed into the restore fetches below. - backgroundContext.rollback() - SDKLogger.event( - "persistence_identity_flags_heal_failed", - category: .persistence, - severity: .error, - fields: ["row_count": .integer(Int64(healed))], - error: error - ) - } - } - - /// Wire-order funding txids of every persisted asset lock whose - /// status is `InstantSendLocked` or beyond — the locks whose - /// funding TXOs are provably gone. - /// - /// Not scoped to a wallet: InstantSend / chain finality is a - /// property of the spending transaction, and that transaction can - /// consume inputs tracked by more than one wallet. Scoping would - /// leave a sibling wallet's input of the same lock stale. - /// - /// Throws rather than returning an empty set on a fetch failure. An - /// empty set is a positive claim — "no lock has finalized" — and the - /// caller acts on it by restoring every `isSpent == false` row, - /// exactly the phantom UTXOs this guard exists to withhold. A read - /// fault must reject the snapshot instead. - private func finalizedAssetLockFundingTxids() throws -> Set { - let finalized = Self.assetLockStatusInstantSendLocked - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.statusRaw >= finalized } - ) - let rows = try modelFetcher.fetch(descriptor, in: backgroundContext) - var txids = Set() - txids.reserveCapacity(rows.count) - for row in rows { - guard let txid = Self.assetLockFundingTxid(outPointHex: row.outPointHex) else { - continue - } - txids.insert(txid) } - return txids } /// Returns `(nil, 0)` if nothing is restorable. @@ -7086,7 +7340,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ] ) return onQueue { - healIdentityIsLocalFlags() // Scope the fetch to the handler's bound network so a // per-network manager only sees its own wallets. If // `network` is `nil` (legacy callers that haven't threaded @@ -7122,7 +7375,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (nil, 0, true) } let restorable = wallets.filter { wallet in - wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } + (replayWalletId == nil || wallet.walletId == replayWalletId) + && wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } if restorable.isEmpty { SDKLogger.event( @@ -7153,8 +7407,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) unspentDescriptor.relationshipKeyPathsForPrefetching = [ \.account, - // Read once per row by the asset-lock spend guard below. - \.spendingTransaction, // Read once per row for the txid and the creating // transaction's context in `buildUtxoRestoreBuffer`. \.transaction, @@ -7178,84 +7430,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } - // Finalized-asset-lock guard, and the one-shot heal for - // rows a missed callback left behind (mirror of Kotlin - // `buildUtxoRestoreData`). An asset-lock tx burns its value - // into the special-tx payload and often has no wallet-owned - // standard output, so SPV block matching can miss it: the - // spender's transaction row never leaves mempool context and - // the in-block flip in `resolveInputOutpoint` never runs, - // leaving its funding TXOs at `isSpent == false` — which this - // fetch hands straight back to Rust as spendable, on every - // launch. The lock's own status is the finality signal that - // did arrive; from `InstantSendLocked` on, the output is gone. - // - // The callback-time reconcile in `persistAssetLocks` cannot - // cover these: `Consumed` is terminal and never re-upserts, so - // a wallet whose lock finalized before that reconcile existed - // has no future callback to repair it. Load is the one - // guaranteed per-launch pass, so it repairs them here. - // - // Exclusion does not depend on the repair being durable: rows - // are dropped from the restore set first, and the heal is - // saved opportunistically afterwards. - let finalizedLockTxids: Set - do { - finalizedLockTxids = try finalizedAssetLockFundingTxids() - } catch { - // Same contract as the unspent fetch above: bail with - // `errored = true` rather than degrade. Treating an - // unreadable lock table as "no lock has finalized" - // rehydrates the phantom inputs the guard exists to - // withhold — the exact inverse of its safety claim. - NSLog( - "[persistor-load:swift] PersistentAssetLock finalized fetch failed: %@", - String(describing: error) - ) - return (nil, 0, true) - } - var liveUnspent = unspent - if !finalizedLockTxids.isEmpty { - var kept: [PersistentTxo] = [] - kept.reserveCapacity(unspent.count) - var healed = 0 - for row in unspent { - if let spendingTxid = row.spendingTransaction?.txid, - finalizedLockTxids.contains(spendingTxid) { - if !row.isSpent { - row.isSpent = true - row.lastUpdated = Date() - healed += 1 - } - continue - } - kept.append(row) - } - liveUnspent = kept - // Skip the write mid-round: saving inside a changeset - // bracket would commit the round's staged writes early. - // The exclusion above already holds for this launch. - if healed > 0 && !self.inChangeset { - do { - try backgroundContext.save() - NSLog( - "[persistor-load:swift] healed %d asset-lock-consumed UTXO row(s)", - healed - ) - } catch { - // Non-fatal: the next launch retries. Roll back so - // the failed heal can't bleed into the restore - // marshalling below. - backgroundContext.rollback() - NSLog( - "[persistor-load:swift] asset-lock UTXO heal save failed: %@", - String(describing: error) - ) - } - } - } + // This is raw storage input; Rust verifies proofs and decides which coins remain held. unspentBuckets.reserveCapacity(restorable.count) - for row in liveUnspent { + for row in unspent { guard row.account != nil else { continue } let key: Data if !row.walletId.isEmpty { @@ -7275,6 +7452,39 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Every stored transaction of each restorable wallet, replayed + // Rust-side at load so the spend guards of confirmed spends are + // rebuilt: without them a funding transaction redelivered after the + // restart (rescan, gap-limit rediscovery) re-credits an output a + // confirmed spend already consumed. Same fail-closed contract as the + // unspent fetch above: an unreadable table rejects the snapshot + // instead of claiming "no history". + var historyBuckets: [Data: [PersistentTransaction]] = [:] + do { + var historyDescriptor = FetchDescriptor() + historyDescriptor.relationshipKeyPathsForPrefetching = [ + \.involvedAccounts, \.inputs, \.outputs, \.pendingInputs, + ] + let transactions = try modelFetcher.fetch( + historyDescriptor, in: backgroundContext + ) + for w in restorable { + historyBuckets[w.walletId] = transactions.filter { + !$0.isDeleted + && Self.walletOwnsTransaction(walletId: w.walletId, transaction: $0) + } + } + } catch { + SDKLogger.event( + "persistence_wallet_load_failed", + category: .persistence, + severity: .error, + fields: ["phase": .publicText("transaction_history_fetch")], + error: error + ) + return (nil, 0, true) + } + // Allocate `entriesPtr` and the `LoadAllocation` here — past // the fallible SwiftData fetch above — so an early-error path // doesn't leak the entries buffer (LoadAllocation only gets @@ -7578,6 +7788,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { entry.unconfirmed_outgoing_tx_records = unconfirmedBuf.map { UnsafePointer($0) } entry.unconfirmed_outgoing_tx_records_count = UInt(unconfirmedCount) + // The wallet's stored history; see `historyBuckets` above. + let (historyBuf, historyCount) = buildRecordedTransactionBuffer( + rows: historyBuckets[w.walletId] ?? [], + allocation: allocation + ) + entry.recorded_transactions = historyBuf.map { UnsafePointer($0) } + entry.recorded_transactions_count = UInt(historyCount) + // Provider special transactions (ProRegTx / ProUpServTx / // ProUpRegTx / ProUpRevTx) re-staged onto the provider-key // accounts so #876 retention keeps them and the masternode @@ -8205,6 +8423,70 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (buf, entries.count) } + /// Marshal a wallet's stored transactions for the Rust load replay. + /// + /// Rows without a 32-byte txid or transaction bytes are skipped, as are + /// confirmed rows without a 32-byte block hash (a zero hash is not + /// fabricated). Rust re-checks that each body hashes to its txid. + private func buildRecordedTransactionBuffer( + rows: [PersistentTransaction], + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + guard !rows.isEmpty else { return (nil, 0) } + var entries: [RecordedTransactionRestoreFFI] = [] + entries.reserveCapacity(rows.count) + var skipped = 0 + for row in rows { + let txBytes = row.transactionData + guard row.txid.count == 32, !txBytes.isEmpty else { + skipped += 1 + continue + } + let confirmed = row.context >= TransactionContextType.inBlock.rawValue + if confirmed, row.blockHash?.count != 32 { + skipped += 1 + continue + } + let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) + txBytes.copyBytes(to: txBuf, count: txBytes.count) + allocation.scalarBuffers.append((txBuf, txBytes.count)) + var entry = RecordedTransactionRestoreFFI() + withUnsafeMutableBytes(of: &entry.txid) { raw in + raw.copyBytes(from: row.txid) + } + entry.tx_bytes = txBuf + entry.tx_bytes_len = UInt(txBytes.count) + entry.context = row.context + if confirmed, let hash = row.blockHash { + entry.block_height = row.blockHeight + withUnsafeMutableBytes(of: &entry.block_hash) { raw in + raw.copyBytes(from: hash) + } + entry.block_timestamp = row.blockTimestamp + entry.block_position = row.blockPosition + entry.has_block_position = row.hasBlockPosition + } + entry.net_amount = row.netAmount + entry.direction = row.direction + entries.append(entry) + } + if skipped > 0 { + SDKLogger.event( + "persistence_transaction_history_rows_skipped", + category: .persistence, + severity: .warning, + fields: ["skipped_count": .integer(Int64(skipped))] + ) + } + guard !entries.isEmpty else { return (nil, 0) } + let buf = UnsafeMutablePointer.allocate( + capacity: entries.count + ) + buf.initialize(from: entries, count: entries.count) + allocation.recordedTransactionArrays.append((buf, entries.count)) + return (buf, entries.count) + } + private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, allocation: LoadAllocation @@ -9061,11 +9343,187 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Upsert one wallet's Rust-emitted accounting rows, keyed by + /// `(walletId, txid)`. Always fired inside a round (a sync round, or the + /// load-time backfill's own), so they stage with it and commit at + /// `endChangeset`. Returns `false` to fail the round, like the other + /// per-kind callbacks, including when a transaction row cannot be read: + /// read as a miss, the backfill would record completion over a row it + /// never wrote. + func persistTransactionAccounting( + walletId: Data, + rows: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "transaction_accounting") { + case .failed: return false + case .absent: return true + case .found: break + } + guard count > 0, let rows else { return true } + do { + for i in 0.. Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "core_history_accounted") { + case .failed: return false + case .absent: return true + case .found(let wallet): + wallet.transactionAccountingBackfilled = true + return true + } + } + } + + private func upsertTransactionAccounting(walletId: Data, row: TransactionAccountingFFI) throws { + // Rust only accounts for transactions it also persisted; a row this + // store lacks has nothing to attach to. A failed read throws. + guard let transaction = try fetchTransactionRow(txid: hashData(row.txid), prefetching: []) + else { return } + let fee: UInt64? = row.has_fee ? row.fee : nil + if let existing = transaction.accounting(for: walletId) { + existing.netAmount = row.net_amount + existing.direction = row.direction + existing.fee = fee + existing.lastUpdated = Date() + } else { + let accounting = PersistentTransactionAccounting( + walletId: walletId, + txid: transaction.txid, + netAmount: row.net_amount, + direction: row.direction, + fee: fee, + transaction: nil + ) + backgroundContext.insert(accounting) + transaction.walletAccounting.append(accounting) + } + } + + /// Rust's accounting-backfill read. `backfilled` once the wallet's + /// marker is set (or the wallet row is gone): one indexed fetch, no + /// history scan. Otherwise the wallet's stored transactions lacking its + /// accounting row, gathered from the wallet's own relationships and + /// `walletId`-indexed rows rather than the whole store. Rust fetches any + /// funding transaction it needs itself, so a transaction missed here + /// only stays without a row. + func loadCoreHistoryForAccounting( + walletId: Data + ) -> (backfilled: Bool, rows: UnsafePointer?, count: Int, errored: Bool) { + onQueue { + let unaccounted: [PersistentTransaction] + do { + guard let wallet = try fetchWalletRecord(walletId: walletId), + !wallet.transactionAccountingBackfilled else { + return (true, nil, 0, false) + } + unaccounted = try walletTransactions(wallet: wallet) + .filter { $0.accounting(for: walletId) == nil } + } catch { + SDKLogger.event( + "persistence_core_history_load_failed", category: .persistence, severity: .error, + fields: ["wallet_reference": .reference(walletId)], error: error + ) + return (false, nil, 0, true) + } + guard !unaccounted.isEmpty else { return (false, nil, 0, false) } + let allocation = CoreHistoryLoadAllocation() + let buf = UnsafeMutablePointer.allocate(capacity: unaccounted.count) + allocation.entries = buf + for (index, transaction) in unaccounted.enumerated() { + let body = transaction.transactionData + let bytes = UnsafeMutablePointer.allocate(capacity: max(body.count, 1)) + body.copyBytes(to: bytes, count: body.count) + allocation.buffers.append(bytes) + buf[index] = CoreHistoryTransactionFFI( + tx_bytes: UnsafePointer(bytes), + tx_bytes_len: UInt(body.count) + ) + } + coreHistoryLoadAllocations[UnsafeRawPointer(buf)] = allocation + return (false, UnsafePointer(buf), unaccounted.count, false) + } + } + + /// Every stored transaction touching `wallet`, deduplicated by txid: + /// its accounts' `involvedTransactions`, plus the creating and spending + /// transactions of its TXOs and pending inputs. TXOs migrated with an + /// empty `walletId` are matched through their account, like + /// `walletOwnsTransaction`. + private func walletTransactions(wallet: PersistentWallet) throws -> [PersistentTransaction] { + let walletId = wallet.walletId + var byTxid: [Data: PersistentTransaction] = [:] + func add(_ transaction: PersistentTransaction?) { + guard let transaction else { return } + byTxid[transaction.txid] = transaction + } + for account in wallet.accounts { + account.involvedTransactions.forEach(add) + } + let emptyWalletId = Data() + let ownTxos: [PersistentTxo] = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }), + in: backgroundContext + ) + let legacyTxos: [PersistentTxo] = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == emptyWalletId }), + in: backgroundContext + ).filter { Self.resolvedWalletId(of: $0) == walletId } + for txo in ownTxos + legacyTxos { + add(txo.transaction) + add(txo.spendingTransaction) + } + let pendingInputs = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }), + in: backgroundContext + ) + pendingInputs.forEach { add($0.spendingTransaction) } + return Array(byTxid.values) + } + + /// Release a loan handed out by `loadCoreHistoryForAccounting`. + func loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer?) { + onQueue { + guard let rows, + let allocation = coreHistoryLoadAllocations.removeValue(forKey: rows) else { return } + allocation.release() + } + } + /// Outstanding tracked-masternode load allocations keyed by the /// entries pointer we handed to Rust. private var trackedMasternodeLoadAllocations: [UnsafeRawPointer: TrackedMasternodeLoadAllocation] = [:] + /// Outstanding unaccounted-history loans handed to Rust for the + /// accounting backfill, keyed by the rows pointer. + private var coreHistoryLoadAllocations: [UnsafeRawPointer: CoreHistoryLoadAllocation] = [:] + + /// Loans Rust has not returned yet; zero between backfills. + var outstandingCoreHistoryLoanCount: Int { + onQueue { coreHistoryLoadAllocations.count } + } + /// Human-readable name for a persisted account, mirroring the /// top-level `AccountTypeTagFFI` discriminant plus — for tag 0 /// (Standard) — the `StandardAccountTypeTagFFI` sub-discriminant. @@ -9141,7 +9599,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// hash that would round-trip back to Rust as a real block id), /// or when the row has no `transactionData` (the FFI write path /// always populates it, so a missing one signals a corrupt row - /// the Rust side can't decode anyway). + /// the Rust side can't decode anyway). Throws when the fetch + /// itself fails: that is not a miss, and Rust's accounting + /// backfill must not read it as one. /// /// The wallet-id is currently unused (`txid` is globally /// unique), but is accepted to match the Rust trait signature @@ -9149,13 +9609,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func coreTxRecord( walletId: Data, txid: Data - ) -> (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? { + ) throws -> (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? { _ = walletId - return onQueue { + return try onQueue { let descriptor = FetchDescriptor( predicate: #Predicate { $0.txid == txid } ) - guard let row = try? backgroundContext.fetch(descriptor).first else { + guard let row = try backgroundContext.fetch(descriptor).first else { return nil } // The Rust side decodes `transactionData` into a @@ -9321,6 +9781,23 @@ private final class TrackedMasternodeLoadAllocation { } } +/// Owned allocations behind one `loadCoreHistoryForAccounting` answer: the +/// entries buffer plus each copied transaction body. Released by +/// `loadCoreHistoryForAccountingFree`. +private final class CoreHistoryLoadAllocation { + var entries: UnsafeMutablePointer? + var buffers: [UnsafeMutablePointer] = [] + + func release() { + for buffer in buffers { + buffer.deallocate() + } + buffers.removeAll() + entries?.deallocate() + entries = nil + } +} + private final class LoadAllocation { var entries: UnsafeMutablePointer? /// Allocated capacity — equal to `restorable.count`. Used for @@ -9405,6 +9882,10 @@ private final class LoadAllocation { /// each entry points at are staged on `scalarBuffers`, like the /// asset-lock records above. var unconfirmedOutgoingTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] + /// `RecordedTransactionRestoreFFI` arrays per wallet — the stored history + /// replayed at load. The `tx_bytes` each entry points at are staged on + /// `scalarBuffers`. + var recordedTransactionArrays: [(UnsafeMutablePointer, Int)] = [] /// Per-wallet `ProviderSpecialTxRestoreEntryFFI` arrays — provider /// special txs re-staged so #876 retention keeps them resident after a /// restart. The `tx_bytes` buffer each row references lives in @@ -9489,6 +9970,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in recordedTransactionArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, count) in providerSpecialTxRecordArrays { ptr.deinitialize(count: count) ptr.deallocate() @@ -11217,9 +11702,11 @@ private func loadShieldedViewingKeysFreeCallback( /// the three block fields when context is 2 or 3, plus the tx /// bytes pointer + length) on a hit; returns `0`. /// - Sets `*outFound = false` on a miss; returns `0`. -/// - Returns `0` even on Swift-side errors (treated as miss); the -/// Rust side's `record_or_persister` helper logs and falls -/// through to the caller's existing not-found / poll path. +/// - Returns `1` when the SwiftData fetch fails. Rust's +/// `get_core_tx_record` still reads that as a miss (the asset-lock +/// and DashPay callers retry); the accounting backfill's strict +/// read fails its round instead of reading a wallet input as +/// foreign. private func getCoreTxRecordCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, @@ -11248,7 +11735,17 @@ private func getCoreTxRecordCallback( let walletId = Data(bytes: walletIdPtr, count: 32) let txid = Data(bytes: txidPtr, count: 32) - guard let row = handler.coreTxRecord(walletId: walletId, txid: txid) else { + let found: (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? + do { + found = try handler.coreTxRecord(walletId: walletId, txid: txid) + } catch { + SDKLogger.event( + "persistence_core_tx_record_read_failed", category: .persistence, severity: .error, + fields: ["wallet_reference": .reference(walletId)], error: error + ) + return 1 + } + guard let row = found else { // Miss — outFound already set to false above. return 0 } @@ -11867,6 +12364,64 @@ extension PlatformWalletPersistenceHandler { } } +/// C shim for the extension's `on_persist_transaction_accounting_fn`. +private func persistTransactionAccountingCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + rows: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context, let walletIdPtr else { return 0 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.persistTransactionAccounting( + walletId: Data(bytes: walletIdPtr, count: 32), rows: rows, count: count + ) ? 0 : 1 +} + +/// C shim for `on_load_core_history_for_accounting_fn`. Hands Rust a loaned +/// array released by the free shim below. +private func loadCoreHistoryForAccountingCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + outBackfilled: UnsafeMutablePointer?, + outRows: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletIdPtr, let outBackfilled, let outRows, let outCount else { return 1 } + outBackfilled.pointee = false + outRows.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let (backfilled, rows, count, errored) = handler.loadCoreHistoryForAccounting( + walletId: Data(bytes: walletIdPtr, count: 32) + ) + outBackfilled.pointee = backfilled + outRows.pointee = rows + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +/// C shim for `on_persist_core_history_accounted_fn`. +private func persistCoreHistoryAccountedCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer? +) -> Int32 { + guard let context, let walletIdPtr else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.persistCoreHistoryAccounted(walletId: Data(bytes: walletIdPtr, count: 32)) ? 0 : 1 +} + +/// C shim for `on_load_core_history_for_accounting_free_fn`. +private func loadCoreHistoryForAccountingFreeCallback( + context: UnsafeMutableRawPointer?, + rows: UnsafePointer?, + _ count: UInt +) { + guard let context else { return } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + handler.loadCoreHistoryForAccountingFree(rows: rows.map(UnsafeRawPointer.init)) +} + private func persistAssetLockOperationIdCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, operationId: UnsafePointer? @@ -11928,3 +12483,109 @@ private func loadIdentityBalanceBlockTimeCallback( return 0 } catch { return -1 } } + +private func persistCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, txid: UnsafePointer?, + bytes: UnsafePointer?, len: UInt +) -> Int32 { + guard let context, let walletId, let txid, let bytes, len > 0 else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistCoreInstantLock(walletId: Data(bytes: walletId, count: 32), + txid: Data(bytes: txid, count: 32), + bytes: Data(bytes: bytes, count: Int(len))) + return 0 + } catch { return -1 } +} + +private func loadCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, txid: UnsafePointer?, + outBytes: UnsafeMutablePointer?>?, outLen: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let txid, let outBytes, let outLen else { return -1 } + outBytes.pointee = nil + outLen.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + guard let bytes = try handler.loadCoreInstantLock(walletId: Data(bytes: walletId, count: 32), + txid: Data(bytes: txid, count: 32)) else { return 0 } + guard !bytes.isEmpty else { return -1 } + let buffer = UnsafeMutablePointer.allocate(capacity: bytes.count) + bytes.copyBytes(to: buffer, count: bytes.count) + outBytes.pointee = UnsafePointer(buffer) + outLen.pointee = UInt(bytes.count) + return 0 + } catch { return -1 } +} + +private func freeCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, bytes: UnsafePointer?, len: UInt +) { + if let bytes { UnsafeMutablePointer(mutating: bytes).deallocate() } +} + +private func replayRows(_ pointer: UnsafePointer?, count: UInt) throws -> UnsafeBufferPointer { + guard let length = Int(exactly: count), length == 0 || pointer != nil else { + throw HistoryReplayPersistenceError.invalidEffects + } + return UnsafeBufferPointer(start: pointer, count: length) +} + +private func replayWalletCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + operation: (@convention(c) (UnsafeMutableRawPointer?) -> Int32)?, + operationContext: UnsafeMutableRawPointer? +) -> Int32 { + guard let context, let walletId, let operation else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.replayWallet(walletId: Data(bytes: walletId, count: 32)) { + operation(operationContext) + } +} + +private func loadReplayClaimsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outRows: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outRows, let outCount else { return 1 } + outRows.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + let rows = try handler.loadReplayClaims(walletId: Data(bytes: walletId, count: 32)) + if !rows.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: rows.count) + rows.withUnsafeBufferPointer { source in + if let base = source.baseAddress { buffer.initialize(from: base, count: source.count) } + } + outRows.pointee = UnsafePointer(buffer) + outCount.pointee = UInt(rows.count) + } + return 0 + } catch { return 1 } +} + +private func freeReplayClaimsCallback( + context: UnsafeMutableRawPointer?, rows: UnsafePointer?, count: UInt +) { + guard let rows else { return } + for row in UnsafeBufferPointer(start: rows, count: Int(count)) { + if let script = row.script { UnsafeMutablePointer(mutating: script).deallocate() } + } + let buffer = UnsafeMutablePointer(mutating: rows) + buffer.deinitialize(count: Int(count)) + buffer.deallocate() +} + +private func applyReplayEffectsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + effects: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let effects else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.applyReplayEffects(walletId: Data(bytes: walletId, count: 32), effects: effects.pointee) + return 0 + } catch { return 1 } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift index 2a1df95d016..c5875a5c3eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -164,7 +164,8 @@ enum CoreSpvLauncher { /// non-obvious rules the caller relies on: /// /// * a **no-wallets** launch does NOT latch (leaving the door open for -/// a future call once wallets exist), whereas +/// a future call once wallets exist); persisted wallets count even when +/// their history load failed for unavailable finality evidence, whereas /// * an **already-running** client DOES latch (the auto-start is /// considered "handled" — we don't want to keep re-checking). enum CoreSpvAutoStart { @@ -196,10 +197,11 @@ enum CoreSpvAutoStart { static func decision( alreadyLatched: Bool, hasWallets: Bool, + hasPersistedWallets: Bool = false, spvRunning: Bool ) -> Decision { if alreadyLatched { return .skipAlreadyLatched } - if !hasWallets { return .skipNoWallets } + if !hasWallets && !hasPersistedWallets { return .skipNoWallets } if spvRunning { return .latchOnly } return .startAndLatch } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index e056190e51b..2b5bbc1fe7c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -164,7 +164,7 @@ struct AccountDetailView: View { .navigationTitle(account.accountTypeName) .navigationBarTitleDisplayMode(.large) .sheet(item: $selectedTransaction) { transaction in - TransactionDetailView(transaction: transaction) + TransactionDetailView(transaction: transaction, walletId: wallet.walletId) } .sheet(isPresented: $showingPINPrompt) { PINPromptView( @@ -465,7 +465,7 @@ struct AccountDetailView: View { Button { selectedTransaction = tx } label: { - TransactionRowView(transaction: tx) + TransactionRowView(transaction: tx, walletId: wallet.walletId) } .buttonStyle(.plain) if idx < txs.count - 1 { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index c222a532b70..0875e244d16 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -3,36 +3,29 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction - /// Override amount for asset-lock txs. The wallet's `netAmount` - /// shows ~0 for these (credit output is structurally self-owned), - /// so the list view passes the linked - /// `PersistentAssetLock.amountDuffs`. `nil` for non-asset-lock - /// rows OR consumed asset locks whose tracking row was cleaned - /// up after successful identity registration. + var walletId: Data? = nil + /// This wallet's Rust-emitted amount and direction; `nil` when Rust + /// emitted no accounting for it (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32? { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @State private var showCopiedAlert = false - /// Amount label rendered prominently at the top of the sheet. - /// Same precedence rule as the row: asset-lock duffs when we - /// have them, else an explicit "amount unknown" label for the - /// historical-asset-lock case (rather than the misleading - /// `+0.00000000 DASH` from `transaction.formattedAmount`). - /// `nil` for a payload-only provider special tx — a ProRegTx - /// observed via the owner/voting keys moves no wallet balance, - /// and `+0.00000000 DASH` reads as a broken zero-value receive. + /// Show the lock's funding amount, or the wallet's Core value movement for ordinary transactions. private var displayAmount: String? { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return nil } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -42,11 +35,14 @@ struct TransactionDetailView: View { || transaction.isProviderSpecial { return transaction.displayDirection } - switch transaction.netAmount { - case let amount where amount > 0: + guard let direction else { return "Transaction" } + switch direction { + case CoreDirectionCode.incoming: return "Received" - case let amount where amount < 0: + case CoreDirectionCode.outgoing: return "Sent" + case CoreDirectionCode.coinJoin: + return "CoinJoin" default: return "Self-Transfer" } @@ -56,14 +52,7 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch transaction.netAmount { - case let amount where amount > 0: - return "arrow.down.circle.fill" - case let amount where amount < 0: - return "arrow.up.circle.fill" - default: - return "arrow.triangle.2.circlepath" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -73,14 +62,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.netAmount { - case let amount where amount > 0: - return .green - case let amount where amount < 0: - return .red - default: - return .blue - } + return TransactionDirectionStyle.color(for: direction) } private var isConfirmed: Bool { @@ -97,7 +79,7 @@ struct TransactionDetailView: View { } private var formattedFee: String? { - guard let fee = transaction.fee else { return nil } + guard let fee = transaction.displayFee(for: walletId) else { return nil } let dash = Double(fee) / 100_000_000.0 return String(format: "%.8f DASH", dash) } @@ -208,7 +190,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, transaction.netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift new file mode 100644 index 00000000000..256b7a8ae19 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -0,0 +1,28 @@ +import SwiftUI +import SwiftDashSDK + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + /// `nil` (no accounting from Rust for this wallet) reads as unknown. + static func icon(for direction: UInt32?) -> String { + guard let direction else { return "questionmark.circle" } + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32?) -> Color { + guard let direction else { return .secondary } + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index f185f78c20f..3d6eaffa633 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -29,10 +29,9 @@ struct TransactionListView: View { @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount - /// for each asset-lock tx — `PersistentTransaction.netAmount` is - /// the wallet's input-vs-output diff, which sees the credit - /// output as "to-self" and reports ~0 for asset locks. The - /// `amountDuffs` on the asset-lock row is the actual L1 burn. + /// for each asset-lock tx: `amountDuffs` on the asset-lock row is the + /// payload funding amount, while `PersistentTransaction.netAmount` is + /// the Core debit, which includes the fee. @Query private var assetLocks: [PersistentAssetLock] /// This wallet's owning identities. The DashPay payment / contact /// join below must be scoped to these — two identities in one store @@ -169,6 +168,7 @@ struct TransactionListView: View { .sheet(item: $selectedTransaction) { transaction in TransactionDetailView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmountByTxid[transaction.txidHex] ) } @@ -202,6 +202,7 @@ struct TransactionListView: View { } label: { TransactionRowView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmounts[transaction.txidHex], dashpayPayment: payment, dashpayCounterpartyName: payment.map { @@ -219,12 +220,12 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction - /// Override amount displayed for asset-lock rows. The wallet's - /// `netAmount` shows ~0 for these (credit output is structurally - /// self-owned), so the list view passes the linked - /// `PersistentAssetLock.amountDuffs` — the actual L1 DASH burned - /// to mint platform credits. `nil` for non-asset-lock rows or - /// when no matching row was found. + var walletId: Data? = nil + /// This wallet's Rust-emitted amount and direction; `nil` when Rust + /// emitted no accounting for it (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32? { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in /// `TransactionListView`. When set, the row shows the contact context @@ -253,14 +254,7 @@ struct TransactionRowView: View { // `Internal` — the wallet just sees its own owner/voting/payout // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } - // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch transaction.direction { - case 0: return "arrow.down.circle.fill" - case 1: return "arrow.up.circle.fill" - case 2: return "arrow.triangle.2.circlepath" - case 3: return "shuffle.circle.fill" - default: return "questionmark.circle" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -278,12 +272,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.direction { - case 0: return .green - case 1, 2: return .red - case 3: return .blue - default: return .secondary - } + return TransactionDirectionStyle.color(for: direction) } /// Primary label: the contact context for a DashPay payment, else the @@ -400,7 +389,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, transaction.netAmount < 0 { + if let fee = transaction.displayFee(for: walletId), let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) @@ -417,35 +406,23 @@ struct TransactionRowView: View { return String(format: "%.8f DASH", dash) } - /// Amount label for the row. For asset-lock txs we substitute - /// the linked `PersistentAssetLock.amountDuffs` (the L1 DASH - /// actually burned to mint platform credits); the wallet's - /// `netAmount` is ~0 for these because the credit output is a - /// self-owned address. Rendered as a negative (DASH leaving L1). - /// - /// If we know the row is an asset lock but the linked - /// `PersistentAssetLock` is missing (e.g. a historical record - /// from before the `Consumed`-status retention change shipped), - /// we render "Asset Lock (amount unknown)" instead of falling - /// through to `transaction.formattedAmount` — that would say - /// `+0.00000000 DASH`, which is misleading for a row the user - /// can see was a funding tx. + /// Keep asset-lock funding amounts distinct from the Core debit, which includes fees. private var displayAmount: String { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } // A payload-only provider special tx moves no wallet balance; // `+0.00000000 DASH` reads as a broken zero-value receive, so // put the tx kind in the amount slot instead. A provider tx // that DOES move value (e.g. this wallet funded the collateral) // falls through and shows the real signed amount. - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 62db9b2d36d..4987437f12f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -372,6 +372,7 @@ struct SwiftExampleAppApp: App { let decision = CoreSpvAutoStart.decision( alreadyLatched: didAutoStartCoreSpv, hasWallets: !manager.wallets.isEmpty, + hasPersistedWallets: !(manager.persistence?.restorableWalletIds().isEmpty ?? true), spvRunning: manager.spvIsRunning ) if decision.shouldLatch { didAutoStartCoreSpv = true } @@ -441,18 +442,19 @@ struct SwiftExampleAppApp: App { await PlatformWalletManager.warmUpShieldedProver() } - platformState.initializeSDK(modelContext: modelContainer.mainContext) - - // Give the Platform SDK a moment to finish its internal init. - try? await Task.sleep(for: .milliseconds(500)) + // An explicit restore retry keeps the same manager and its running SPV client. + if platformState.sdk == nil { + platformState.initializeSDK(modelContext: modelContainer.mainContext) + try? await Task.sleep(for: .milliseconds(500)) + } if let sdk = platformState.sdk { // Activate the per-network manager for the launch // network. The store creates + configures the // manager and runs `loadFromPersistor` against it // (filtered to the launch network's wallets via - // the network-aware persistence handler), so no - // separate restore pass is needed here. + // the network-aware persistence handler). History requiring + // SPV evidence gets an explicit retry after startup below. try walletManagerStore.activate( network: platformState.currentNetwork, sdk: sdk @@ -496,6 +498,12 @@ struct SwiftExampleAppApp: App { // bumps last wins and the other bails (never a double-start). isInitialized = true await autoStartCoreSpvIfNeeded() + if platformState.sdk != nil { + // Starting SPV opens its cache but does not guarantee quorum readiness. + // A load error leaves SPV running and the existing Retry action can try again. + _ = try await walletManager.loadFromPersistor() + rebindWalletScopedServices() + } SDKLogger.event( "app_initialization_completed", category: .lifecycle, @@ -505,6 +513,7 @@ struct SwiftExampleAppApp: App { ] ) } catch { + isInitialized = false bootstrapError = error SDKLogger.event( "app_initialization_failed", diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index a567917dedf..d2d429ac86e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -145,6 +145,11 @@ struct StorageExplorerView: View { modelRow("Pending Inputs", icon: "hourglass", type: PersistentPendingInput.self) { PendingInputStorageListView(network: network) } + modelRow( + "Transaction Accounting", icon: "plusminus.circle", type: PersistentTransactionAccounting.self + ) { + TransactionAccountingStorageListView(network: network) + } modelRow("Asset Locks", icon: "lock.shield", type: PersistentAssetLock.self) { AssetLockStorageListView(network: network) } @@ -328,6 +333,9 @@ struct StorageExplorerView: View { filteredCount(PersistentPendingInput.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentTransactionAccounting.self) { + walletsOnNetwork.contains($0.walletId) + } filteredCount(PersistentShieldedNote.self) { walletsOnNetwork.contains($0.walletId) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 23bc60dd8c3..a3433e034fd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1162,10 +1162,10 @@ struct TransactionStorageListView: View { // Direction. switch directionFilter { case .all: break - case .incoming where record.direction != 0: return false - case .outgoing where record.direction != 1: return false - case .internalTx where record.direction != 2: return false - case .coinjoin where record.direction != 3: return false + case .incoming where record.direction != CoreDirectionCode.incoming: return false + case .outgoing where record.direction != CoreDirectionCode.outgoing: return false + case .internalTx where record.direction != CoreDirectionCode.internalTransfer: return false + case .coinjoin where record.direction != CoreDirectionCode.coinJoin: return false default: break } // Type. Treat the legacy `"Standard"` placeholder (the @@ -1757,6 +1757,53 @@ struct TxoStorageListView: View { } } +// MARK: - PersistentTransactionAccounting + +/// Every per-wallet accounting row `rs-platform-wallet` emitted, newest +/// first: one per `(walletId, txid)`, so a transaction shared by two local +/// wallets appears twice. +struct TransactionAccountingStorageListView: View { + let network: Network + @Query(sort: [SortDescriptor(\PersistentTransactionAccounting.lastUpdated, order: .reverse)]) + private var records: [PersistentTransactionAccounting] + + @Query private var allWallets: [PersistentWallet] + + private var scopedRecords: [PersistentTransactionAccounting] { + let ids = Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: TransactionAccountingStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.txid.reversed().map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1).truncationMode(.middle) + HStack(spacing: 8) { + Text(PersistentTransaction.format(duffs: record.netAmount)) + .font(.caption2) + Spacer() + Text(record.walletId.prefix(4).map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Transaction Accounting (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Accounting Rows", systemImage: "plusminus.circle") + } + } + } +} + // MARK: - PersistentPendingInput /// Diagnostic list of every `PersistentPendingInput` row — one per diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 7fe6ed1cd28..d6b06c037a4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1590,6 +1590,10 @@ struct WalletStorageDetailView: View { FieldRow(label: "Birth Height", value: "\(record.birthHeight)") FieldRow(label: "Synced Height", value: "\(record.syncedHeight)") FieldRow(label: "Imported", value: record.isImported ? "Yes" : "No") + FieldRow( + label: "Accounting Backfilled", + value: record.transactionAccountingBackfilled ? "Yes" : "No" + ) } Section("Relationships") { FieldRow(label: "Accounts", value: "\(record.accounts.count)") @@ -2180,6 +2184,40 @@ struct TxoStorageDetailView: View { } } +// MARK: - PersistentTransactionAccounting + +struct TransactionAccountingStorageDetailView: View { + let record: PersistentTransactionAccounting + + var body: some View { + Form { + Section("Accounting") { + FieldRow(label: "Net Amount", value: PersistentTransaction.format(duffs: record.netAmount)) + FieldRow(label: "Direction", value: "\(record.direction)") + FieldRow(label: "Fee", value: record.fee.map { "\($0) duffs" } ?? "—") + } + Section("Keys") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "TXID", value: record.txid.reversed().map { String(format: "%02x", $0) }.joined()) + } + Section("Relationships") { + if let transaction = record.transaction { + NavigationLink(destination: TransactionStorageDetailView(record: transaction)) { + FieldRow(label: "Transaction", value: transaction.txidHex) + } + } else { + FieldRow(label: "Transaction", value: "— (unlinked)") + } + } + Section("Timestamps") { + FieldRow(label: "Last Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Transaction Accounting") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentPendingInput struct PendingInputStorageDetailView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift index 8d45e46fcba..dcf678a07c2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift @@ -54,6 +54,27 @@ final class CoreSpvAutoStartTests: XCTestCase { XCTAssertFalse(decision.shouldStart) } + // TODO(macOS-validation): Run startup gate tests with the SwiftExampleApp target. + func testShouldStartForPersistedWalletsWhenHistoryLoadHasFailed() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + hasPersistedWallets: true, + spvRunning: false + ) + XCTAssertEqual(decision, .startAndLatch) + } + + func testShouldKeepRunningSpvForAnExplicitHistoryLoadRetry() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + hasPersistedWallets: true, + spvRunning: true + ) + XCTAssertEqual(decision, .latchOnly) + } + // MARK: - Already-running gate latches without starting func testAlreadyRunningLatchesButDoesNotStart() { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift index f1dec9f0c87..33552994ed9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -86,7 +86,8 @@ final class AssetLockOperationPersistenceTests: XCTestCase { XCTAssertEqual(rows.count, 1) XCTAssertNil(rows.first?.operationId) XCTAssertEqual(rows.first?.amountDuffs, 1000) - XCTAssertEqual(DashModelContainer.schema.version, Schema.Version(4, 0, 0)) + let currentSchema = try XCTUnwrap(DashMigrationPlan.schemas.last) + XCTAssertEqual(DashModelContainer.schema.version, currentSchema.versionIdentifier) } func testMalformedOperationIdFailsLoadInsteadOfBecomingAbsent() throws { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index 7aaafce9db8..eda7af61608 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -12,13 +12,9 @@ import DashSDKFFI // funding TXOs it consumed stay `isSpent == false` forever. Every // relaunch then hands those consumed outputs back to Rust as spendable. // -// Two repairs cover it — the callback-time reconcile in -// `persistAssetLocks`, and the load-time guard in `loadWalletList` for -// wallets whose lock reached the terminal `Consumed` status before that -// reconcile existed and so has no future callback to fire. Both depend -// on a SwiftData read, and both must fail CLOSED: the whole point is to -// withhold outputs that are gone, so an unreadable table may never be -// read as "nothing to withhold." +// Callback-time reconciliation and scoped Rust replay both preserve spent +// inputs. The raw load only marshals rows; owner decisions reach Swift as +// exact claim effects inside the replay transaction. // // Both directions are pinned here. The readable behaviour goes through a // live store; the fail-closed behaviour goes through the handler's @@ -216,47 +212,57 @@ final class AssetLockSpendVisibilityTests: XCTestCase { return try XCTUnwrap(try context.fetch(descriptor).first).isSpent } - // MARK: Load-time guard + // MARK: Raw snapshot and owner-directed repair - /// `Consumed` is terminal — the lock never upserts again — so the - /// callback-time reconcile has no future event to repair this with, - /// and every relaunch would re-inflate the balance with an output - /// that is provably gone. The load guard must both exclude it from - /// the restore and heal the row in place. - func testLoadExcludesAndHealsATxoConsumedByAFinalizedAssetLock() throws { - let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + /// The Rust owner tests choose this hold from the terminal lifecycle; + /// this host test checks its exact transactional application. + private func applyOwnerHold(outpoint: Data) throws { + var claim = ReplayClaimEffectFFI() + withUnsafeMutableBytes(of: &claim.txid) { $0.copyBytes(from: outpoint.prefix(32)) } + claim.vout = outpoint.suffix(4).enumerated().reduce(UInt32(0)) { + $0 | (UInt32($1.element) << (8 * $1.offset)) + } + claim.action = 2 + claim.has_claimant = true + withUnsafeMutableBytes(of: &claim.claimant) { $0.copyBytes(from: lockTxid) } + XCTAssertEqual(handler.replayWallet(walletId: walletId) { + do { + try withUnsafePointer(to: &claim) { pointer in + var effects = HistoryReplayEffectsFFI() + effects.claims = pointer + effects.claims_count = 1 + try handler.applyReplayEffects(walletId: walletId, effects: effects) + } + return 0 + } catch { + XCTFail("Unexpected owner-effect failure: \(error)") + return 1 + } + }, 0) + } - // Without a finalized lock row this IS the phantom UTXO. + // TODO(macOS-validation): run raw snapshot and scoped hold tests on an Apple SDK runner. + func testRawLoadDefersConsumedAssetLockRepairToScopedOwnerEffects() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) XCTAssertEqual(try restoredUtxoCount(), 1) XCTAssertFalse(try txoIsSpent(outpoint: outpoint)) - try insertConsumedAssetLock(into: container, vout: 0) - XCTAssertEqual( - try restoredUtxoCount(), 0, - "the finalized lock's funding output must not rehydrate as spendable" - ) - XCTAssertTrue( - try txoIsSpent(outpoint: outpoint), - "and the stale flag must be healed in place" - ) + XCTAssertEqual(try restoredUtxoCount(), 1, "raw load must preserve the authoritative snapshot") + XCTAssertFalse(try txoIsSpent(outpoint: outpoint), "reading must not commit a repair") + try applyOwnerHold(outpoint: outpoint) + XCTAssertEqual(try restoredUtxoCount(), 0, "scoped owner repair must exclude the consumed coin") + XCTAssertTrue(try txoIsSpent(outpoint: outpoint), "the hold must survive the next raw snapshot") } - /// DIP-0027 lets one funding transaction carry several credit - /// outputs, and Rust persists each tracked lock under its own - /// credit-output index, so a valid lock row can be keyed `:1` - /// with no `:0` row anywhere. Finality belongs to the - /// transaction, so the guard keys on the funding txid alone. - func testLoadExcludesATxoConsumedByALockPersistedAtANonZeroVout() throws { + /// A tracked credit-output index does not change the funding inputs held by Rust. + func testScopedOwnerHoldExcludesInputOfLockAtANonZeroCreditVout() throws { let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) - XCTAssertEqual(try restoredUtxoCount(), 1) - try insertConsumedAssetLock(into: container, vout: 1) - - XCTAssertEqual( - try restoredUtxoCount(), 0, - "finality belongs to the funding transaction, not to credit output 0" - ) + XCTAssertEqual(try restoredUtxoCount(), 1) + XCTAssertFalse(try txoIsSpent(outpoint: outpoint)) + try applyOwnerHold(outpoint: outpoint) + XCTAssertEqual(try restoredUtxoCount(), 0) XCTAssertTrue(try txoIsSpent(outpoint: outpoint)) } @@ -368,8 +374,8 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], - "the wallet and unspent-TXO reads must have been served — only the lock read failed" + ["PersistentWallet", "PersistentTxo", "PersistentTransaction", "PersistentAssetLock"], + "wallet, unspent-TXO and history reads must succeed before the injected lock read failure" ) XCTAssertTrue( errored, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift new file mode 100644 index 00000000000..155a250a635 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift @@ -0,0 +1,105 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +@MainActor +final class AtomicHistoryReplayTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let txid = Data(repeating: 7, count: 32) + + // TODO(macOS-validation): run the reentrant transaction and rollback tests on an Apple SDK runner. + func testShouldRollbackWritesMadeByAReentrantReplayOperation() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + var calls = 0 + let result = handler.replayWallet(walletId: walletId) { + calls += 1 + do { + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([2])) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([2])) + return 71 + } catch { + XCTFail("Unexpected staging error: \(error)") + return 72 + } + } + XCTAssertNotEqual(result, 0) + XCTAssertEqual(calls, 1) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1])) + } + + func testShouldCommitReplayAndRejectANestedTransaction() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + var nestedCalls = 0 + let result = handler.replayWallet(walletId: walletId) { + XCTAssertNotEqual(handler.replayWallet(walletId: walletId) { + nestedCalls += 1 + return 0 + }, 0) + do { + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([3])) + return 0 + } catch { + XCTFail("Unexpected staging error: \(error)") + return 1 + } + } + XCTAssertEqual(result, 0) + XCTAssertEqual(nestedCalls, 0) + let reopened = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertEqual(try reopened.loadCoreInstantLock(walletId: walletId, txid: txid), Data([3])) + } + + func testShouldApplyExactClaimStampAndRollbackAllEffectsOnFailure() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let transaction = PersistentTransaction(txid: txid, transactionData: Data([1])) + seed.insert(transaction) + let coin = PersistentTxo(transaction: transaction, vout: 0, amount: 42, address: "unused", height: 1) + coin.walletId = walletId + seed.insert(coin) + try seed.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + var claim = ReplayClaimEffectFFI() + withUnsafeMutableBytes(of: &claim.txid) { $0.copyBytes(from: txid) } + claim.action = 2 + claim.has_claimant = true + withUnsafeMutableBytes(of: &claim.claimant) { $0.copyBytes(from: Data(repeating: 9, count: 32)) } + claim.has_mined_height = true + claim.mined_height = 123 + let result = handler.replayWallet(walletId: walletId) { + do { + try withUnsafePointer(to: &claim) { pointer in + var effects = HistoryReplayEffectsFFI() + effects.claims = pointer + effects.claims_count = 1 + try handler.applyReplayEffects(walletId: walletId, effects: effects) + } + let rows = try handler.loadReplayClaims(walletId: walletId) + defer { + for row in rows { + if let script = row.script { UnsafeMutablePointer(mutating: script).deallocate() } + } + } + let held = rows.filter { $0.is_held } + XCTAssertEqual(held.count, 1, "the output must not add an unstamped duplicate claim") + XCTAssertEqual(held.first?.mined_height, 123) + XCTAssertEqual(held.first?.has_mined_height, true) + return 71 + } catch { + XCTFail("Unexpected replay error: \(error)") + return 72 + } + } + XCTAssertNotEqual(result, 0) + let reopened = ModelContext(container) + XCTAssertFalse(try XCTUnwrap(reopened.fetch(FetchDescriptor()).first).isSpent) + XCTAssertTrue(try reopened.fetch(FetchDescriptor()).isEmpty) + } + +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift new file mode 100644 index 00000000000..59b549f6ba1 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift @@ -0,0 +1,56 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +final class CoreInstantLockPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let txid = Data(repeating: 7, count: 32) + + // TODO(macOS-validation): run these SwiftData rollback/reopen tests on an Apple SDK runner. + func testShouldCommitRawLockAndRollbackReplacement() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1, 2, 3])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([4])) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1, 2, 3])) + XCTAssertNil(try handler.loadCoreInstantLock(walletId: Data(repeating: 43, count: 32), txid: txid)) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertNil(try mainnet.loadCoreInstantLock(walletId: walletId, txid: txid)) + XCTAssertThrowsError(try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([5]))) + } + + func testShouldDeleteOnlyTheSelectedWalletNetworkSidecar() throws { + let container = try DashModelContainer.createInMemory() + let testnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + for handler in [testnet, mainnet] { + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + try testnet.deleteWalletData(walletId: walletId) + XCTAssertNil(try testnet.loadCoreInstantLock(walletId: walletId, txid: txid)) + XCTAssertEqual(try mainnet.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1])) + } + + func testShouldPreserveRawLockAcrossStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([9, 8, 7])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([9, 8, 7])) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index d3c07c9f73f..8a494a0a09c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,16 +11,19 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV5: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV6: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(6, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV5.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self, BridgeFutureV6.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self), + .custom(fromVersion: DashSchemaV5.self, toVersion: BridgeFutureV6.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -771,7 +774,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV5.self) + let schema = Schema(versionedSchema: BridgeFutureV6.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -888,8 +891,9 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - XCTAssertEqual(try container.mainContext.fetchCount( - FetchDescriptor()), 1) + let wallet = try XCTUnwrap(container.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(wallet.name, "historical audit wallet") } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index e2ea9489f0a..672e8983cd9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,11 +75,12 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(log.contains("event=store_open_started")) - XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v5\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) + XCTAssertTrue(log.contains("target_version=\"5.0.0\"")) + XCTAssertFalse(log.contains("target_version=\"4.0.0\""), "every diagnostic names the live schema") + XCTAssertTrue(log.contains("route=\"labelled-current-v5\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -94,8 +95,9 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) + XCTAssertFalse(updatedLog.contains("target_version=\"4.0.0\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +108,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV5RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +119,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["5.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -454,6 +456,13 @@ final class DashModelMigrationTests: XCTestCase { ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) } + func testV5AddsPerWalletTransactionAccountingAndCoreInstantLocksToV4() { + XCTAssertEqual( + Set(Schema(versionedSchema: DashSchemaV5.self).entities.map(\.name)) + .subtracting(Schema(versionedSchema: DashSchemaV4.self).entities.map(\.name)), + ["PersistentTransactionAccounting", "PersistentCoreInstantLock"]) + } + @MainActor func testV1BalanceGetsNoWatermarkUntilOneIsPersistedInLiveV3() throws { let (directory, url) = try copyFixture(Self.fixtures[0]) @@ -513,7 +522,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +599,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index d283b5a20c0..5cae950d672 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -99,6 +99,64 @@ final class DashReleasedSchemaTests: XCTestCase { } } + private enum BaseV4Plan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [DashSchemaV3.self, DashSchemaV4.self] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + } + } + + /// The per-wallet accounting entity added in V5: migrated rows start + /// without one and keep their stored amount, and a row written after the + /// migration survives a reopen and cascades with its transaction. + @MainActor + // TODO(macOS-validation): Run the V4-to-V5 SwiftData migration test on macOS. + func testShouldMigrateV4ToPerWalletAccountingRows() throws { + let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { + $0.version.versionIdentifier == Schema.Version(3, 0, 0) + }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("accounting.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV4.self) + let container = try ModelContainer(for: schema, migrationPlan: BaseV4Plan.self, + configurations: [ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)]) + try container.mainContext.save() + } + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) + let txid = Data(repeating: 0x32, count: 32) + let walletId = Data(repeating: 0x07, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertTrue(row.walletAccounting.isEmpty, "V4 rows carry no per-wallet accounting yet") + XCTAssertNil(row.netAmount(for: walletId), "Global V4 accounting is not wallet-scoped") + let accounting = PersistentTransactionAccounting( + walletId: walletId, txid: txid, netAmount: -1_234, + direction: CoreDirectionCode.outgoing, fee: 34, transaction: nil + ) + container.mainContext.insert(accounting) + row.walletAccounting.append(accounting) + try container.mainContext.save() + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertEqual(row.netAmount(for: walletId), -1_234) + XCTAssertEqual(row.direction(for: walletId), CoreDirectionCode.outgoing) + XCTAssertEqual(row.fee(for: walletId), 34) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["5.0.0"]) + container.mainContext.delete(row) + try container.mainContext.save() + XCTAssertTrue(try container.mainContext.fetch(FetchDescriptor()).isEmpty) + } + } + @MainActor func testRuntimePlanHasNoDuplicateModelChecksums() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift index dc891ae1469..0393f3ea3ee 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift @@ -255,10 +255,8 @@ final class IdentityIsLocalPersistenceTests: XCTestCase { // MARK: Startup heal - /// `loadWalletList()` promotes wallet-linked rows still carrying - /// the constant-`false` of the pre-fix persister; unlinked rows - /// (manual adds) are untouched in both directions. - func testLoadWalletListHealsWalletLinkedRows() throws { + /// Discovery is read-only; the atomic load owns the wallet-scoped metadata repair. + func testAtomicReplayHealsWalletLinkedRowsAfterReadOnlyDiscovery() throws { try insertWalletRow() let context = ModelContext(container) let wallet = try XCTUnwrap( @@ -280,13 +278,15 @@ final class IdentityIsLocalPersistenceTests: XCTestCase { try context.save() // The fixture wallet has no restorable accounts, so the - // returned list is empty — the heal runs regardless. + // returned list is empty; discovery itself must remain read-only. let result = handler.loadWalletList() XCTAssertNil(result.entries) XCTAssertFalse(result.errored) + XCTAssertFalse(try XCTUnwrap(try fetchIdentity(ownIdentityId)).isLocal) + XCTAssertEqual(handler.replayWallet(walletId: walletId) { 0 }, 0) let healed = try XCTUnwrap(try fetchIdentity(ownIdentityId)) - XCTAssertTrue(healed.isLocal, "wallet-linked rows heal to local") + XCTAssertTrue(healed.isLocal, "wallet-linked rows heal inside the committed replay") let manual = try XCTUnwrap(try fetchIdentity(observedIdentityId)) XCTAssertTrue(manual.isLocal, "manual adds are untouched by the heal") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 7b51fce43d9..7591fc09e6e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -67,6 +67,10 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDashpayPayment` rows, so the sweep's Failed flip // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments + // Stored transaction history: `loadWalletList` hands every + // wallet-owned `PersistentTransaction` back as + // `recorded_transactions` for the load replay. + | PlatformWalletPersistenceCapabilities.coreHistoryRestore | PlatformWalletPersistenceCapabilities.assetLockOperationIds XCTAssertEqual( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift index 02b0bc78db8..f3f6d5deb40 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift @@ -12,7 +12,10 @@ final class PersistentTransactionDisplayTests: XCTestCase { /// Direction 2 = internal — the raw classification every special /// tx gets (asset locks, provider txs), which the display helpers /// exist to override. - private func makeTransaction(kind: UInt8, direction: UInt32 = 2) -> PersistentTransaction { + private func makeTransaction( + kind: UInt8, + direction: UInt32 = CoreDirectionCode.internalTransfer + ) -> PersistentTransaction { let tx = PersistentTransaction( txid: Data(repeating: 0xAB, count: 32), transactionData: Data(), @@ -53,7 +56,7 @@ final class PersistentTransactionDisplayTests: XCTestCase { XCTAssertEqual(makeTransaction(kind: 7).displayDirection, "Asset Unlock") // …and non-special kinds fall through to the raw direction. XCTAssertEqual(makeTransaction(kind: 0).displayDirection, "Internal") - XCTAssertEqual(makeTransaction(kind: 0, direction: 0).displayDirection, "Incoming") + XCTAssertEqual(makeTransaction(kind: 0, direction: CoreDirectionCode.incoming).displayDirection, "Incoming") XCTAssertEqual(makeTransaction(kind: 0xFF).displayDirection, "Internal") } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift new file mode 100644 index 00000000000..c2955b0621b --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -0,0 +1,324 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// The Swift store persists the per-wallet accounting `rs-platform-wallet` +/// emits and displays it; it never derives it. The rule itself is tested in +/// Rust (`platform_wallet::changeset::wallet_accounting`). +@MainActor +final class TransactionAccountingTests: XCTestCase { + private let walletA = Data(repeating: 1, count: 32) + private let walletB = Data(repeating: 2, count: 32) + private let txid = Data(repeating: 3, count: 32) + + private func makeStore() throws -> (ModelContainer, PlatformWalletPersistenceHandler) { + let container = try DashModelContainer.createInMemory() + container.mainContext.insert(PersistentWallet(walletId: walletA, network: .testnet)) + container.mainContext.insert(PersistentWallet(walletId: walletB, network: .testnet)) + try container.mainContext.save() + return (container, PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) + } + + private func row(_ txid: Data, net: Int64, direction: UInt32, fee: UInt64? = nil) -> TransactionAccountingFFI { + var row = TransactionAccountingFFI() + withUnsafeMutableBytes(of: &row.txid) { $0.copyBytes(from: txid) } + row.net_amount = net + row.direction = direction + row.fee = fee ?? 0 + row.has_fee = fee != nil + return row + } + + /// Hand the handler `rows` the way Rust's extension slot does: inside a + /// round, optionally followed by the backfill's completion marker. + @discardableResult + private func account( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, + _ rows: [TransactionAccountingFFI], markBackfilled: Bool = false, success: Bool = true + ) -> Bool { + handler.beginChangeset(walletId: walletId) + var stored = rows.withUnsafeBufferPointer { + handler.persistTransactionAccounting(walletId: walletId, rows: $0.baseAddress, count: UInt($0.count)) + } + if markBackfilled { + stored = handler.persistCoreHistoryAccounted(walletId: walletId) && stored + } + _ = handler.endChangeset(walletId: walletId, success: success) + return stored + } + + /// The bodies `loadCoreHistoryForAccounting` offers, returning the loan. + private func offered( + _ handler: PlatformWalletPersistenceHandler, walletId: Data + ) throws -> (backfilled: Bool, bodies: [Data]) { + let history = handler.loadCoreHistoryForAccounting(walletId: walletId) + XCTAssertFalse(history.errored) + guard let rows = history.rows else { + XCTAssertEqual(history.count, 0) + return (history.backfilled, []) + } + let bodies = (0.. Data in + Data(bytes: rows[index].tx_bytes!, count: Int(rows[index].tx_bytes_len)) + } + handler.loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer(rows)) + return (history.backfilled, bodies) + } + + private func transaction(_ container: ModelContainer, _ txid: Data) throws -> PersistentTransaction { + try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == txid }) + } + + private func persist( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, txid: Data, + bytes: Data? = nil, net: Int64 = 0, kind: UInt8 = 0, + inputTxids: [Data] = [], outputs: [(Data, UInt64)] = [] + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("") + defer { free(name); free(address) } + var record = TransactionRecordFFI() + withUnsafeMutableBytes(of: &record.txid) { $0.copyBytes(from: txid) } + record.context = 2 + record.net_amount = net + record.transaction_type_kind = kind + var inputs = inputTxids.map { txid -> OutPointFFI in + var result = OutPointFFI() + withUnsafeMutableBytes(of: &result.txid) { $0.copyBytes(from: txid) } + return result + } + var txos = outputs.map { txid, amount -> UtxoEntryFFI in + var result = UtxoEntryFFI() + withUnsafeMutableBytes(of: &result.outpoint.txid) { $0.copyBytes(from: txid) } + result.amount = amount + result.address = address + return result + } + var raw = Array(bytes ?? Data()) + handler.beginChangeset(walletId: walletId) + raw.withUnsafeMutableBufferPointer { rawPtr in + inputs.withUnsafeMutableBufferPointer { inputPtr in + txos.withUnsafeMutableBufferPointer { txoPtr in + record.tx_data = rawPtr.baseAddress + record.tx_data_len = UInt(rawPtr.count) + record.input_outpoints = inputPtr.baseAddress + record.input_outpoints_count = UInt(inputPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = bytes == nil ? 0 : 1 + account.utxos_added = txoPtr.baseAddress + account.utxos_added_count = UInt(txoPtr.count) + withUnsafeMutablePointer(to: &account) { accountPtr in + var changeset = WalletChangeSetFFI() + changeset.accounts = accountPtr + changeset.accounts_count = 1 + withUnsafePointer(to: &changeset) { ptr in + XCTAssertTrue(handler.persistWalletChangeset(walletId: walletId, changeset: ptr)) + } + } + } + } + } + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testShouldStoreEachWalletsEmittedAccountingSeparately() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100) + persist(handler, walletId: walletB, txid: txid, bytes: Data([1]), net: 90) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.outgoing, fee: 10)]) + account(handler, walletId: walletB, [row(txid, net: 90, direction: CoreDirectionCode.incoming)]) + + let stored = try transaction(container, txid) + XCTAssertEqual(stored.walletAccounting.count, 2) + XCTAssertEqual(stored.netAmount(for: walletA), -100) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(stored.fee(for: walletA), 10) + XCTAssertEqual(stored.netAmount(for: walletB), 90) + XCTAssertEqual(stored.direction(for: walletB), CoreDirectionCode.incoming) + XCTAssertNil(stored.fee(for: walletB), "a wallet that funded nothing knows no fee") + XCTAssertEqual(stored.displayFormattedAmount(for: walletB), "+0.00000090 DASH") + } + + func testShouldUpdateTheWalletsRowInsteadOfAddingAnother() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) + account(handler, walletId: walletA, [row(txid, net: -60, direction: CoreDirectionCode.outgoing)]) + + let stored = try transaction(container, txid) + XCTAssertEqual(stored.walletAccounting.count, 1) + XCTAssertEqual(stored.netAmount(for: walletA), -60) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.outgoing) + } + + /// The load-time backfill's rows and marker commit with its round. + func testShouldCommitTheBackfillsRowsAndMarkerTogether() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) + + XCTAssertTrue(account(handler, walletId: walletA, + [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], markBackfilled: true)) + + XCTAssertEqual(try transaction(container, txid).netAmount(for: walletA), -60) + XCTAssertEqual(try offered(handler, walletId: walletA).backfilled, true) + } + + /// A failed backfill round leaves neither rows nor marker, so the next + /// load offers the history again. + func testShouldDropTheBackfillsRowsAndMarkerWhenItsRoundFails() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) + + account(handler, walletId: walletA, + [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], markBackfilled: true, success: false) + + XCTAssertNil(try transaction(container, txid).netAmount(for: walletA)) + let history = try offered(handler, walletId: walletA) + XCTAssertFalse(history.backfilled) + XCTAssertEqual(history.bodies, [Data([1])]) + } + + func testShouldIgnoreAccountingForATransactionTheStoreDoesNotHold() throws { + let (container, handler) = try makeStore() + + XCTAssertTrue(account(handler, walletId: walletA, [row(txid, net: 5, direction: CoreDirectionCode.incoming)])) + + XCTAssertTrue(try ModelContext(container).fetch(FetchDescriptor()).isEmpty) + } + + /// A record Rust re-emits without accounting (an asset lock restored from + /// its bytes, promoted by a chain lock) overwrites only the shared + /// scalar; the wallet's row, which Rust did not resend, still answers. + func testShouldKeepTheWalletsRowWhenARecordArrivesWithoutAccounting() throws { + let (container, handler) = try makeStore() + let assetLock = TransactionTypeKind.assetLock.rawValue + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100, kind: assetLock) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.internalTransfer)]) + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 0, kind: assetLock) + + let stored = try transaction(container, txid) + XCTAssertEqual(stored.netAmount, 0, "the shared scalar is written as delivered") + XCTAssertEqual(stored.netAmount(for: walletA), -100) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.internalTransfer) + } + + /// Without a row for the wallet there is no amount to show for it; the + /// shared scalar answers only unscoped views. + func testShouldShowNoAccountingWithoutAWalletRow() { + let tx = PersistentTransaction( + txid: txid, transactionData: Data(), direction: CoreDirectionCode.incoming, netAmount: 25 + ) + XCTAssertNil(tx.netAmount(for: walletA)) + XCTAssertNil(tx.direction(for: walletA)) + XCTAssertEqual(tx.formattedAmount(for: walletA), PersistentTransaction.noAccounting) + XCTAssertEqual(tx.displayNetAmount(for: nil), 25) + XCTAssertEqual(tx.displayDirectionCode(for: nil), CoreDirectionCode.incoming) + } + + func testShouldOfferNothingWhenEveryTransactionHasTheWalletsRow() throws { + let (_, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) + + let history = try offered(handler, walletId: walletA) + + XCTAssertFalse(history.backfilled, "Rust still has to record completion") + XCTAssertEqual(history.bodies, []) + } + + /// Only this wallet's transactions lacking its row cross; Rust fetches + /// any funding transaction it needs on its own. Every loan is returned. + func testShouldOfferOnlyTheWalletsUnaccountedTransactions() throws { + let (_, handler) = try makeStore() + let other = Data(repeating: 4, count: 32) + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + persist(handler, walletId: walletA, txid: other, bytes: Data([7, 8, 9]), net: -40) + persist(handler, walletId: walletB, txid: Data(repeating: 5, count: 32), bytes: Data([5]), net: 1) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) + + let history = try offered(handler, walletId: walletA) + + XCTAssertFalse(history.backfilled) + XCTAssertEqual(history.bodies, [Data([7, 8, 9])]) + XCTAssertEqual(handler.outstandingCoreHistoryLoanCount, 0) + } + + /// Once the marker is stored, later loads skip the history entirely. + func testShouldReportABackfilledWalletWithoutOfferingHistory() throws { + let (_, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + account(handler, walletId: walletA, [], markBackfilled: true) + + let history = try offered(handler, walletId: walletA) + + XCTAssertTrue(history.backfilled) + XCTAssertEqual(history.bodies, []) + } + + /// The backfill end to end through the C shims Rust calls: read the + /// unaccounted body, return the loan, write the row and the marker in + /// one round, then report the wallet backfilled. + func testShouldDriveTheBackfillThroughTheExtensionShims() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + let callbacks = handler.makePersistenceCallbacksExtension() + let load = try XCTUnwrap(callbacks.on_load_core_history_for_accounting_fn) + let free = try XCTUnwrap(callbacks.on_load_core_history_for_accounting_free_fn) + let persistRows = try XCTUnwrap(callbacks.on_persist_transaction_accounting_fn) + let accounted = try XCTUnwrap(callbacks.on_persist_core_history_accounted_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + let rows = [row(txid, net: -60, direction: CoreDirectionCode.outgoing, fee: 5)] + + try walletA.withUnsafeBytes { wallet in + let walletBytes = try XCTUnwrap(wallet.bindMemory(to: UInt8.self).baseAddress) + var backfilled = true + var entries: UnsafePointer? + var count: UInt = 0 + XCTAssertEqual(load(context, walletBytes, &backfilled, &entries, &count), 0) + XCTAssertFalse(backfilled) + XCTAssertEqual(count, 1) + let entry = try XCTUnwrap(entries)[0] + XCTAssertEqual(Data(bytes: try XCTUnwrap(entry.tx_bytes), count: Int(entry.tx_bytes_len)), Data([1, 2])) + free(context, entries, count) + XCTAssertEqual(handler.outstandingCoreHistoryLoanCount, 0) + + handler.beginChangeset(walletId: walletA) + XCTAssertEqual(rows.withUnsafeBufferPointer { + persistRows(context, walletBytes, $0.baseAddress, UInt($0.count)) + }, 0) + XCTAssertEqual(accounted(context, walletBytes), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletA, success: true)) + + XCTAssertEqual(load(context, walletBytes, &backfilled, &entries, &count), 0) + XCTAssertTrue(backfilled) + XCTAssertEqual(count, 0) + XCTAssertNil(entries) + XCTAssertEqual(load(nil, walletBytes, &backfilled, &entries, &count), 1) + } + XCTAssertEqual(try transaction(container, txid).fee(for: walletA), 5) + } + + func testShouldDeleteOnlyTheDepartingWalletsRows() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100) + persist(handler, walletId: walletB, txid: txid, bytes: Data([1]), net: 90) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.outgoing)]) + account(handler, walletId: walletB, [row(txid, net: 90, direction: CoreDirectionCode.incoming)]) + + try handler.deleteWalletData(walletId: walletA) + + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.map(\.walletId), [walletB]) + XCTAssertEqual(rows.first?.netAmount, 90) + } + + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } +} diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..dc61fbfc8c6 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,9 @@ "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", - "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", + "PersistentTransactionAccounting": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift", + "PersistentCoreInstantLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift" }, "value_types": [ { diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index ef4cf8b08d2..d5c59b05b57 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -333,6 +333,8 @@ def emit(path, text): f"{OUT_DIR}/{freeze.schema}+{model}.swift", render_model(freeze, sha, source, model, sibling), ) + # Preserve the merged V4 model graph independently of release capture. + files.update(render_source_snapshot(root, "4.0.0", "292b5101f33b7b7a68f7ffd97df1520b2ff9ef67")) return files @@ -662,13 +664,19 @@ def render_snapshot(root, version, entry, *, inventory=None): commit = entry["platform_sha"] if not re.fullmatch(r"[0-9a-f]{40}", commit): raise SystemExit("release source must be a full Git SHA") - namespace = "DashSchemaSnapshotV" + version.split(".")[0] - if entry["namespace"] != namespace: + inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) + if set(inventory["models"]) != set(entry["schema"]["entity_hashes"]): + raise SystemExit("historical inventory differs from captured model membership") + if entry["namespace"] != "DashSchemaSnapshotV" + version.split(".")[0]: raise SystemExit("unexpected snapshot namespace") + return render_source_snapshot(root, version, commit, inventory=inventory) + + +def render_source_snapshot(root, version, commit, *, inventory=None): + """Freeze a source graph without asserting App Store release provenance.""" + namespace = "DashSchemaSnapshotV" + version.split(".")[0] inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) models = inventory["models"] - if set(models) != set(entry["schema"]["entity_hashes"]): - raise SystemExit("historical inventory differs from captured model membership") sources = inventory_sources(root, inventory, commit) validate_storage_graph(inventory, sources) names = set(models) | {name for group in inventory["value_types"] for name in group["names"]} diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 128f501bd42..4d2ef3be8c6 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -51,7 +51,7 @@ def setUp(self): shutil.copyfile(os.path.join(ROOT, gen.TEST_REGISTRY_FILE), target) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(gen.render_baseline(ROOT)), 71) + self.assertEqual(len(gen.render_baseline(ROOT)), 109) self.assertIn(gen.TEST_REGISTRY_FILE, self.files) self.assertEqual(gen.check_problems(ROOT, self.files), [])