diff --git a/Cargo.lock b/Cargo.lock index 7399dfdebe6..767997d17c9 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", @@ -3192,7 +3192,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" @@ -4403,7 +4403,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", @@ -4416,6 +4416,7 @@ dependencies = [ "hex-conservative 1.3.0", "serde", "serde_json", + "thiserror 2.0.18", "tracing", "unicode-normalization", "zeroize", @@ -4424,7 +4425,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", @@ -5508,11 +5509,13 @@ dependencies = [ "bs58", "cbindgen", "dash-sdk", + "dash-spv", "dashcore", "dpp", "grovedb-bincode", "hex", "key-wallet", + "key-wallet-manager", "lazy_static", "once_cell", "parking_lot", @@ -5520,6 +5523,7 @@ dependencies = [ "rs-sdk-ffi", "serde_json", "tempfile", + "thiserror 2.0.18", "tokio", "tokio-metrics", "tracing", @@ -5534,10 +5538,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 ae9ae03aa37..9fc379d7f9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,14 +70,15 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "fa92511cb grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "fa92511cb7cdc19f5977d64f20f93107f62844b1" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "fa92511cb7cdc19f5977d64f20f93107f62844b1" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "fa92511cb7cdc19f5977d64f20f93107f62844b1" } -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 002c9b52b10..24a5a946f28 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"] } tempfile = "3.27.0" 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 0723585c6e2..8e526e7544c 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,14 +8,17 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadAssetLockShieldedRecipientFn, LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, - PersistAssetLockOperationIdFn, PersistAssetLockShieldedRecipientFn, PersistDpnsNameStatesFn, - PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + ApplyReplayEffectsFn, FFIPersister, FreeCoreHistoryForAccountingFn, FreeCoreInstantLockFn, + FreeReplayClaimsFn, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, + LoadAssetLockShieldedRecipientFn, LoadCoreHistoryForAccountingFn, LoadCoreInstantLockFn, + LoadIdentityBalanceBlockTimeFn, LoadReplayClaimsFn, LoadTrackedMasternodesFn, + PersistAssetLockOperationIdFn, PersistAssetLockShieldedRecipientFn, + 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}; @@ -299,6 +302,29 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_shielded_recipient_fn, LoadAssetLockShieldedRecipientFn ), + 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), } } @@ -680,6 +706,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. @@ -1381,6 +1411,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( @@ -1424,6 +1503,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 c1a86ac0a98..4e1221f867c 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; @@ -168,6 +181,30 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = /// persist/load callbacks. Reported, never required: a host without the slots /// loads such rows with no recipient and a resume accepts the caller's. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_SHIELDED_RECIPIENTS: u64 = 1 << 14; +/// 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 << 16; + +/// 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 @@ -352,6 +389,54 @@ pub type LoadAssetLockShieldedRecipientFn = unsafe extern "C" fn( out_recipient_raw_43: *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`] @@ -541,6 +626,94 @@ pub struct PersistenceCallbacksExtension { out_recipient_raw_43: *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 { @@ -563,6 +736,17 @@ impl Default for PersistenceCallbacksExtension { on_load_asset_lock_operation_id_fn: None, on_persist_asset_lock_shielded_recipient_fn: None, on_load_asset_lock_shielded_recipient_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, } } } @@ -586,6 +770,17 @@ pub struct PersistenceExtensionCallbacks { pub load_asset_lock_operation_id: Option, pub persist_asset_lock_shielded_recipient: Option, pub load_asset_lock_shielded_recipient: 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** @@ -1527,6 +1722,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. @@ -1545,6 +1752,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 @@ -1652,864 +1860,1351 @@ 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 wallet_restore - && self.callbacks.on_persist_asset_locks_fn.is_some() - && self - .tracked_masternodes_callbacks - .persist_asset_lock_shielded_recipient - .is_some() - && self + { + load_asset_lock_operation_ids( + cb, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.unused_asset_locks, + )?; + } + if let Some(cb) = self .tracked_masternodes_callbacks .load_asset_lock_shielded_recipient - .is_some() - { - capabilities = - capabilities.union(PersistenceCapabilities::ASSET_LOCK_SHIELDED_RECIPIENTS); + { + load_asset_lock_shielded_recipients( + 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 - .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() + + // 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::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); - } + use crate::shielded_persistence::*; + use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; + use platform_wallet::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, ShieldedNote, + ShieldedOutgoingNote, SubwalletId, + }; - // Deliberately absent: PersistenceCallbacks has no deferred contact - // crypto add/clear callback pair, so FFI hosts cannot attest it. - capabilities - } -} + let mut shielded_state = ShieldedSyncStartState::default(); -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) - } + // 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", + )); + } - // 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. + // 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, + }); + } + } + } - fn persistence_capabilities(&self) -> PersistenceCapabilities { - self.declared_capabilities - .intersection(self.callback_capabilities()) - } + // 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 !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, + }); + } + } + } - 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}"), - )); + // 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) }; + } + } + } + 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; + } + } + } + + // 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) + }; + + 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, + }); + } + } + } + + // 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) }; + } + } + } + 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(()) + + Ok(out) } - fn load_tracked_masternodes( + fn load_core_instant_locks( &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, - ) + 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", + )) + } }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_tracked_masternodes_fn returned error code {rc}"), - )); + struct Guard { + context: *mut c_void, + bytes: *const u8, + len: usize, + free: FreeCoreInstantLockFn, } - 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, - }); + impl Drop for Guard { + fn drop(&mut self) { + if !self.bytes.is_null() { + unsafe { (self.free)(self.context, self.bytes, self.len) }; + } } } - 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() + 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(()) } - 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() + /// 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, + } } - 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(); + /// 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; - // 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()) + 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 wallet_restore + && self.callbacks.on_persist_asset_locks_fn.is_some() + && self + .tracked_masternodes_callbacks + .persist_asset_lock_shielded_recipient + .is_some() + && self + .tracked_masternodes_callbacks + .load_asset_lock_shielded_recipient + .is_some() + { + capabilities = + capabilities.union(PersistenceCapabilities::ASSET_LOCK_SHIELDED_RECIPIENTS); + } + 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) { - return Err(PersistenceError::backend_with_kind( - PersistenceErrorKind::Fatal, - "the host holds a DashPay backfill record but registered no backfill \ - callback to invalidate it", - )); + 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); } - // 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()?; + // Deliberately absent: PersistenceCallbacks has no deferred contact + // crypto add/clear callback pair, so FFI hosts cannot attest it. + capabilities + } +} - // 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; +/// 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, + }, +} - // 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, - ) - }; - if result != 0 { - eprintln!( - "Wallet metadata persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - } +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; - // 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(); - } - } - } - } + let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { + return CoreTxRead::Missing; + }; - // 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); - } - } - Err(e) => { - eprintln!("Failed to encode account address pool entries: {}", e); - outcome.record_fatal(); - } - } - } - } + 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; - // 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); - } + // 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, + }; - // 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(); - } - } - } - } + 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"), + } + } +} - // 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(); - } - } - } - } +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) + } - // 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); - } + // 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)) + } - 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; - } + /// 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); } - - // 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); - } - } + } + if complete && outcome.is_success() { + let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + 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); - } - } + } + 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(()), + } + } - // 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(), + 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 result != 0 { - eprintln!( - "Wallet DashPay backfill persistence callback returned error code {}", - result - ); - outcome.record(result); - } + 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) + } + + 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 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(); + // 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, ) }; - // 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 {}", + "Wallet metadata persistence callback returned error code {}", result ); outcome.record(result); @@ -2517,176 +3212,113 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 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); - } - } - } - - // 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); - } - } - } - } - - // 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) }; + // 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(); + } } - if result != 0 { - eprintln!( - "Identity keys changeset 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); + } + } + Err(e) => { + eprintln!("Failed to encode account address pool entries: {}", e); + outcome.record_fatal(); + } } } } - // 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 + // 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(|(iid, tid)| TokenBalanceRemovalFFI { - identity_id: iid.to_buffer(), - token_id: tid.to_buffer(), + .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 !upserts.is_empty() || !removals.is_empty() { + 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 removals.is_empty() { - std::ptr::null() - } else { - removals.as_ptr() - }, - removals.len(), + entries.as_ptr(), + entries.len(), ) }; if result != 0 { eprintln!( - "Token balance persistence callback returned error code {}", + "Address balance persistence callback returned error code {}", result ); outcome.record(result); @@ -2695,350 +3327,250 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 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); - } - } - } - // 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); - } - } - } - // The shielded recipient is a second additive sidecar. Unlike the - // UUID, a host without the slot is not failed: every Type 18 - // funding now carries a recipient, and a host that cannot keep it - // simply resumes as it always did (the caller names the recipient). - if let Some(cb) = self - .tracked_masternodes_callbacks - .persist_asset_lock_shielded_recipient - { - for entry in al_cs.asset_locks.values() { - let Some(recipient) = entry.shielded_recipient else { - 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(), - recipient.as_ptr(), - ) - }; - if rc != 0 { - outcome.record(rc); - } - } - } else if al_cs - .asset_locks - .values() - .any(|entry| entry.shielded_recipient.is_some()) - { - tracing::debug!( - "host has no shielded-recipient persistence slot; the recipient of a \ - shielded asset-lock funding is not persisted with its row" - ); + // 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(); + } + } + } } - } - // 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(), - 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!( - "Invitation persistence callback returned error code {}", - result - ); - outcome.record(result); + // 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(); + } } } } - } - // 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() { + // 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, - )); + 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; } - 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() - { + } + + // 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() + .filter_map(record_accounting) + .map(|row| TransactionAccountingFFI::from(&row)) + .collect(); + 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); @@ -3047,25 +3579,192 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 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 { + // 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); + } + } + } + + // 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(), - height, - timestamp, - recent, + entries.as_ptr(), + entries.len(), ) }; + drop(entries); + drop(_string_storage); if result != 0 { eprintln!( - "Sync state persistence callback returned error code {}", + "DashPay payment persistence callback returned error code {}", result ); outcome.record(result); @@ -3074,1017 +3773,961 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 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::*; + // 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); + } + } + } - // 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 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 notes persistence callback returned error code {}", + "Token balance 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 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 nullifier-spent persistence callback returned error code {}", + "Asset lock 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(); - 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 outgoing-notes persistence callback returned error code {}", - result - ); - outcome.record(result); + if rc != 0 { + outcome.record(rc); } } } - - // 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 { + // The shielded recipient is a second additive sidecar. Unlike the + // UUID, a host without the slot is not failed: every Type 18 + // funding now carries a recipient, and a host that cannot keep it + // simply resumes as it always did (the caller names the recipient). + if let Some(cb) = self + .tracked_masternodes_callbacks + .persist_asset_lock_shielded_recipient + { + for entry in al_cs.asset_locks.values() { + let Some(recipient) = entry.shielded_recipient else { + 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(), + recipient.as_ptr(), ) }; - if result != 0 { - eprintln!( - "Shielded synced-index persistence callback returned error code {}", - result - ); - outcome.record(result); + if rc != 0 { + outcome.record(rc); } } + } else if al_cs + .asset_locks + .values() + .any(|entry| entry.shielded_recipient.is_some()) + { + tracing::debug!( + "host has no shielded-recipient persistence slot; the recipient of a \ + shielded asset-lock funding is not persisted with its row" + ); } + } - // 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 - .tracked_masternodes_callbacks - .load_asset_lock_shielded_recipient - { - load_asset_lock_shielded_recipients( - 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 @@ -4109,114 +4752,71 @@ impl PlatformWalletPersistence for FFIPersister { /// 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, - ) + /// + /// 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, @@ -4252,34 +4852,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, @@ -4298,6 +4870,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, @@ -5484,7 +6080,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 { @@ -5507,7 +6103,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 @@ -5575,6 +6171,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( @@ -6292,6 +7003,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 { @@ -6300,6 +7014,7 @@ fn build_wallet_start_state( identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + recorded_history, dashpay_backfill, }; @@ -9854,8 +10569,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 { @@ -9867,40 +10735,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, @@ -9912,13 +10765,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] @@ -10192,7 +11402,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, @@ -10234,7 +11444,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) @@ -10295,6 +11505,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 { @@ -10309,9 +11554,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; @@ -10331,7 +11583,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)); } @@ -10349,6 +11613,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); @@ -10363,11 +11628,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(); @@ -10495,94 +11759,179 @@ mod tests { ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_chain_lock_height_fn + 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_persist_wallet_dashpay_backfill_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_asset_lock_operation_id_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + 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_asset_lock_shielded_recipient_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_changeset_utxo_verdicts_fn - ) + std::mem::size_of::>(), + on_persist_asset_lock_shielded_recipient_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_identity_balance_block_time_fn + on_load_asset_lock_shielded_recipient_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_identity_balance_block_time_fn - ) + std::mem::size_of::>(), + on_load_asset_lock_shielded_recipient_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_identity_balance_block_time_fn + on_persist_transaction_accounting_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_identity_balance_block_time_fn - ) + std::mem::size_of::>(), + on_persist_transaction_accounting_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_dashpay_backfill_fn + on_load_core_history_for_accounting_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_wallet_dashpay_backfill_fn - ) + std::mem::size_of::>(), + on_load_core_history_for_accounting_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_operation_id_fn + on_load_core_history_for_accounting_free_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_operation_id_fn - ) + std::mem::size_of::>(), + on_load_core_history_for_accounting_free_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_asset_lock_operation_id_fn + on_persist_core_history_accounted_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_asset_lock_operation_id_fn - ) + std::mem::size_of::>(), + on_persist_core_history_accounted_fn + ) + std::mem::size_of::>(), std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_shielded_recipient_fn + on_persist_core_instant_lock_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_persist_asset_lock_shielded_recipient_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_shielded_recipient_fn + on_load_core_instant_lock_free_fn ) ); assert_eq!( std::mem::offset_of!( PersistenceCallbacksExtension, - on_load_asset_lock_shielded_recipient_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!( @@ -10641,6 +11990,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() @@ -11204,6 +12557,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] @@ -13399,6 +14772,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 6e4f94059cd..61a412f601c 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.41", features = ["functions"] } # `test-utils` reaches `provider_key_test_wallet`, shared with diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index b6273525d6d..5458e7f245f 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -882,3 +882,4 @@ table-rebuild migration, as V004 does. | V019 | `V019__asset_lock_operation_id.rs` | Adds the nullable `asset_locks.operation_id BLOB` column (CHECK: NULL or exactly 16 bytes) that correlates a wallet-drain operation with its funding outpoint; the lifecycle blob is untouched. Adds the unique partial index `asset_locks_wallet_operation_id` on `(wallet_id, operation_id) WHERE operation_id IS NOT NULL` and the `asset_locks_operation_id_immutable` BEFORE UPDATE trigger, which aborts any change to a non-NULL `operation_id`. Purely additive; existing rows read back NULL. | | V020 | `V020__asset_lock_shielded_recipient.rs` | Adds the nullable `asset_locks.shielded_recipient BLOB` column (CHECK: NULL or exactly 43 bytes, the raw Orchard address) recording the recipient a shielded asset-lock funding was built to pay, so a resume after restart pays that recipient and refuses another; the lifecycle blob is untouched. Adds the `asset_locks_shielded_recipient_immutable` BEFORE UPDATE trigger, which aborts any change to a non-NULL `shielded_recipient`. Purely additive; existing rows read back NULL and keep accepting the resuming caller's recipient. | | V021 | `V021__core_transaction_accounting.rs` | Adds the raw-input index and original-record archive. Backfills all decodable history and repairs accounting from retained TXOs, preserving coins, claims, checkpoints and unrelated wallet state. | +| V022 | `V022__core_history_rescan.rs` | Adds the access-path indexes `idx_core_utxos_funding` (funding txid within `core_utxos.outpoint`) and partial `idx_core_utxos_claimant` (`spent_in_txid IS NOT NULL`). Rewinds every wallet's `core_sync_state.synced_height` to just below its birth height (to 0 for an unreadable birth height under Recovery) so the next sync rescans Core history. For each wallet holding no unresolved InstantSend or asset-lock evidence, drops mined Core history the rescan redelivers (mined coins, InstantSend locks and input rows of mined records, mined records without retained coins) and clears the record blob and block fields of the mined records it keeps; wallets with such evidence keep their rows. Asset-lock rows are untouched. | diff --git a/packages/rs-platform-wallet-storage/migrations/V022__core_history_rescan.rs b/packages/rs-platform-wallet-storage/migrations/V022__core_history_rescan.rs new file mode 100644 index 00000000000..9335a6adc00 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V022__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 42fee7af8c8..f26b3f28f4b 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; @@ -34,6 +35,9 @@ pub enum AutoBackupOperation { /// Errors produced by the wallet-storage SQLite backend. #[derive(Debug, thiserror::Error)] 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), @@ -418,6 +422,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. @@ -786,6 +800,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::HistoryAliasContextConflict { .. } | Self::TransactionBodyConflict { .. } | Self::UnknownWalletNetwork { .. } | Self::NetAmountOverflow { .. } @@ -802,7 +817,8 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::DatabasePathIsSymlink { .. } => false, + | Self::DatabasePathIsSymlink { .. } + | Self::SpendAuthorization(_) => false, } } @@ -836,9 +852,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 @@ -912,7 +928,8 @@ impl WalletStorageError { | Self::UnownedIdentityHasRegistrationIndex { .. } | Self::EmptyUtxoScript { .. } | Self::EmptyPoolAddressScript { .. } - | Self::DatabasePathIsSymlink { .. } => PersistenceErrorKind::Fatal, + | Self::DatabasePathIsSymlink { .. } + | Self::SpendAuthorization(_) => PersistenceErrorKind::Fatal, } } @@ -980,6 +997,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", @@ -1003,6 +1021,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 ff762617532..a5aaee81a59 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 v021; +mod v022; // 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(21), + rescan_sql: hook_sql(22), 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 { v021::backfill_accounting(&self.tx, self.policy)?; + } else if query == self.rescan_sql { + v022::rescan(&self.tx, self.policy)?; } count += 1; } @@ -439,6 +444,7 @@ pub fn embedded_migrations_sql() -> Vec { pub(crate) fn rewind_v021(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 >= 21;", ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs index ebb682bb0b9..6ae23213344 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.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_v021(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(21)), + 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_v021(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(21)), + 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_v021(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Recovery) - .expect("Recovery completes V021"); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(21)), + LoadPolicy::Recovery, + ) + .expect("Recovery completes V021"); 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/v022.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v022.rs new file mode 100644 index 00000000000..479a74266a5 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v022.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, + "V022 preserved wallet history because finality evidence is unreadable"); + return Ok(true); + } + Err(error) => return Err(error), + } + } + Ok(false) +} + +/// Validate every birth height before V022 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, + "V022 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 5e4d971170d..22a18c0cc63 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,11 +1326,12 @@ 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::ASSET_LOCK_SHIELDED_RECIPIENTS) .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) @@ -1518,12 +1525,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")] { @@ -1569,9 +1709,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 @@ -1579,6 +1738,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)?; @@ -1609,92 +1773,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( @@ -1720,9 +1876,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 @@ -1867,6 +2040,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, @@ -1877,6 +2062,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 e0bb8032e06..a1f194b1ad2 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 @@ -127,6 +127,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..ef917fdf918 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; V021 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 63ce0e420af..e74142aa98b 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_v021(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(21)) + .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 V021 past an undecodable kept record"); - assert_eq!(schema_version(&conn), 21); + 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_v022_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_v021(&conn); + let ops = count_vm_ops(&mut 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, + "V022 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_v022() { + 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_v021(&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: &mut 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..753521b2e20 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs @@ -0,0 +1,632 @@ +#![cfg(feature = "sqlite")] + +//! A wallet whose mined Core history was wiped (what V022 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 V022 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 V021 on the store at `path`, so its next open replays V021 over the +/// history it holds, as on a store upgraded from before V021. +fn rewind_v021(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 >= 21;", + ) + .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. V022 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_v021(&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 V021 and V022 run first. + let state = rescan(&path, wallet_id, &chain.blocks()).await; + + assert_eq!( + sync_state(), + (Some(500), Some(0), Some(400)), + "V022 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, + shielded_recipient: 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 V022 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_v021(&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_error_classification.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs index 289aecb600f..81a33a41e23 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_error_classification.rs @@ -449,6 +449,9 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::CoreTransactionEntryMismatch { .. } => { (false, "core_transaction_entry_mismatch") } + WalletStorageError::HistoryAliasContextConflict { .. } => { + (false, "history_alias_context_conflict") + } WalletStorageError::TransactionBodyConflict { .. } => { (false, "transaction_body_conflict") } @@ -511,6 +514,7 @@ fn tc_p2_005_is_transient_table() { (false, "empty_pool_address_script") } WalletStorageError::DatabasePathIsSymlink { .. } => (false, "database_path_is_symlink"), + WalletStorageError::SpendAuthorization(_) => (false, "spend_authorization"), } } 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..25aab1a5102 --- /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 >= 21;", + ) + .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, 20); + } + 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 f85936cabf4..e4aad8b3c07 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 @@ -1546,9 +1546,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` + @@ -1830,7 +1831,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}; @@ -1859,23 +1860,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); @@ -1883,4 +1897,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..6b84a51a1a9 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 V022 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, "V022 rescans mined outputs from birth"); assert_eq!( count( conn, "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", &full ), - 1 + 0, + "V022 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 V021 before the deliberate V022 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(21)) + .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 V021 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(21)) + .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, + "V022 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..6bc0c55fce9 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,29 @@ 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, + shielded_recipient: 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 +506,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 V022 intentionally removes recordless unspent rows. + mig::runner() + .set_target(refinery::Target::Version(21)) + .run(&mut conn) + .unwrap(); // 5. The poisoned row is gone... let poisoned_rows: i64 = conn @@ -541,6 +568,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, + "V022 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..bce70c7d478 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs @@ -0,0 +1,458 @@ +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_v021(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 >= 22;", + ) + .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_v021(&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, + shielded_recipient: None, + }, + )]), + ..Default::default() + }); + } + persister.store(wallet.wallet_id, changes).unwrap(); + drop(persister); + rewind_to_v021(&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_v021(&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_v021(&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_v021(&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, (21, 1, 1, 150)); + } +} + +#[tokio::test] +async fn should_keep_v021_accounting_archive_when_v022_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>=21;", + ) + .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, + "V022 must preserve V021'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 f09aa4265b8..af1e23dbecc 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 = - "ff3bc3ff849170b50f05b94a87e7d3f2a62af6eaf507815e12e65377dd32846a"; + "e5be8255cba86bf31a9cbf46dbd161f26fd926f5714e1d1495730b28f50b491f"; /// 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 = - "fd8c85c3fd92bd6905715d221b665fe8d471d037f554b45d37c4a2742934dff7"; + "cd941c162402995efb17cc32e99f5ceada5e73207eb8c1c8b9003a3775e435b3"; /// 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..3bea37adbb0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -0,0 +1,395 @@ +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, + shielded_recipient: 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]), + shielded_recipient: None, + }, + )] + .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 29f3622b039..c3faa7f2a05 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)); assert!(capabilities.contains(PersistenceCapabilities::ASSET_LOCK_SHIELDED_RECIPIENTS)); } 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..223be91a139 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs @@ -0,0 +1,2995 @@ +#![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, + shielded_recipient: 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..8cea177b139 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(21)) + .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), + "V022 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 dc3b871742a..6d52531d60a 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 ac34d13b601..9d2b0201fe5 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 c4e5429ffc7..fe37b418f65 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,20 +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, ShieldedRecipientRaw, TokenBalanceChangeSet, WalletMetadataEntry, - SHIELDED_RECIPIENT_SIZE, + 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, + ShieldedRecipientRaw, TokenBalanceChangeSet, WalletMetadataEntry, SHIELDED_RECIPIENT_SIZE, }; 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, @@ -60,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"))] @@ -70,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 afa30546b9a..614741740f7 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 << 16); /// Asset-lock operation UUIDs survive store/load, including consumed tombstones. /// Missing or malformed persistence data fails load rather than becoming an @@ -124,7 +134,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 = 14; + const HIGHEST_DECLARED_BIT: u32 = 16; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -232,6 +242,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::ASSET_LOCK_SHIELDED_RECIPIENTS, "asset_lock_shielded_recipients", ), + ( + PersistenceCapabilities::CORE_HISTORY_RESTORE, + "core_history_restore", + ), ]; KNOWN @@ -249,7 +263,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_SHIELDED_RECIPIENTS.bits() + PersistenceCapabilities::CORE_HISTORY_RESTORE.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -277,6 +291,10 @@ 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(), + 0x1_0000 + ); assert_eq!( PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits(), 0x2000 @@ -317,7 +335,12 @@ mod tests { // `KNOWN` entry would gate behaviour while staying invisible to // every `names()`-based diagnostic — exactly what this test exists // to prevent. Walking the whole width cannot go stale. + // Bit 15 is reserved for `CORE_CHAIN_TRUNCATION` (dashpay/platform#5384). + const RESERVED_BITS: &[u32] = &[15]; for shift in 0..=PersistenceCapabilities::HIGHEST_DECLARED_BIT { + if RESERVED_BITS.contains(&shift) { + continue; + } let bit = PersistenceCapabilities::from_bits_retain(1u64 << shift); assert_eq!( bit.names().len(), 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..89130e4aadc --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -0,0 +1,5327 @@ +//! 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, + shielded_recipient: 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..4db65023c05 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,1833 @@ 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, + shielded_recipient: 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, + shielded_recipient: 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, + shielded_recipient: 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, + shielded_recipient: 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, + shielded_recipient: 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, + shielded_recipient: 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 57a6ae3b46a..06535670701 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -2092,6 +2092,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 d4768962728..9dc659c0d62 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; @@ -495,6 +500,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. @@ -1986,6 +2200,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::{ @@ -1996,7 +2211,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 @@ -3793,6 +4008,390 @@ 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, + shielded_recipient: 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, + shielded_recipient: 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]), + shielded_recipient: 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: 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, + shielded_recipient: 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 b5ca65d2429..7099c3c3b40 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 @@ -169,6 +169,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 bc9c7f29453..8ae509301f3 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,26 +16,43 @@ 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, the `PersistentWallet.transactionAccountingBackfilled` attribute +(default `false`) and the optional `PersistentAssetLock.shieldedRecipientRaw43` +attribute (default `nil`) through a lightweight migration. All of them 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`, `published-v3-to-v4`, `published-v4-to-v5`, -`labelled-current-v5`, `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. @@ -138,9 +156,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 e1ba0479049..3c32c623f10 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: DashSchemaV5.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("5.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("5.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("5.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("5.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("5.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,15 +273,15 @@ 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"] { - // 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") + 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("labelled-current-v5") } else { logRoute("ordinary-current-plan") @@ -394,7 +406,9 @@ public enum DashSchemaV4: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } } -/// Current schema: an asset lock records the optional Orchard recipient of its shielded funding. +/// Current schema: per-wallet transaction accounting (`PersistentTransactionAccounting`), +/// the wallet's accounting-backfill marker, raw InstantLock sidecars, and an asset +/// lock's optional Orchard recipient of its shielded funding. 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 292f790b408..a00dbd3d504 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 << 16 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 /// The Orchard recipient of a shielded asset-lock funding survives store @@ -1648,6 +1655,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 7bc7e76188e..5da0cb9008b 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 @@ -173,6 +178,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 @@ -352,6 +359,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`. @@ -363,11 +371,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, @@ -385,7 +390,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() } } } @@ -2709,6 +2717,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 { @@ -3309,6 +3319,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.coreHistoryRestore | PlatformWalletPersistenceCapabilities.assetLockOperationIds | PlatformWalletPersistenceCapabilities.assetLockShieldedRecipients ) @@ -3354,6 +3365,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { persistAssetLockShieldedRecipientCallback extensionCallbacks.on_load_asset_lock_shielded_recipient_fn = loadAssetLockShieldedRecipientCallback + // 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 } @@ -5123,6 +5150,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) @@ -6712,7 +7008,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) @@ -7063,75 +7379,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. @@ -7144,7 +7398,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 @@ -7180,7 +7433,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( @@ -7211,8 +7465,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, @@ -7236,84 +7488,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 { @@ -7333,6 +7510,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 @@ -7636,6 +7846,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 @@ -8263,6 +8481,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 @@ -9119,11 +9401,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. @@ -9199,7 +9657,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 @@ -9207,13 +9667,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 @@ -9379,6 +9839,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 @@ -9463,6 +9940,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 @@ -9547,6 +10028,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() @@ -11275,9 +11760,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?, @@ -11306,7 +11793,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 } @@ -11925,6 +12422,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? @@ -12017,3 +12572,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 6c6888c53f4..bcf1305bbd3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -122,7 +122,8 @@ final class AssetLockOperationPersistenceTests: XCTestCase { XCTAssertNil(rows.first?.operationId) XCTAssertNil(rows.first?.shieldedRecipientRaw43) XCTAssertEqual(rows.first?.amountDuffs, 1000) - XCTAssertEqual(DashModelContainer.schema.version, Schema.Version(5, 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 b07ad5a84f2..8a494a0a09c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -891,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 94ee9120cf3..672e8983cd9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,10 +75,11 @@ 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=\"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")) @@ -94,6 +95,7 @@ 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=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) @@ -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: DashSchemaV5.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: DashSchemaV5.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 f6c0818f73c..ed0dc471aff 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 // Shielded recipients: the handler wires both recipient slots and // lands the bytes on `PersistentAssetLock.shieldedRecipientRaw43`. 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), [])