diff --git a/Cargo.lock b/Cargo.lock index 51bc3a52007..360f3648a51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1647,7 +1647,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1658,7 +1658,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "dash-network", ] @@ -1753,7 +1753,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "async-trait", "chrono", @@ -1782,7 +1782,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "anyhow", "base64-compat", @@ -1808,12 +1808,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "dashcore-rpc-json", "hex", @@ -1826,7 +1826,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "dashcore", "grovedb-bincode", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "dashcore-private", "grovedb-bincode", @@ -2918,7 +2918,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" [[package]] name = "glob" @@ -4150,7 +4150,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "aes", "async-trait", @@ -4179,7 +4179,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4195,7 +4195,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=719de34bd90792efa77aa5e1065ab21ac09206ee#719de34bd90792efa77aa5e1065ab21ac09206ee" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 8068ff3d235..af07107c87d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,14 +64,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "74818ceb9 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "74818ceb95805f56f141d24ca41f58b75c031a7f" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "74818ceb95805f56f141d24ca41f58b75c031a7f" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "74818ceb95805f56f141d24ca41f58b75c031a7f" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "719de34bd90792efa77aa5e1065ab21ac09206ee" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "0c0dca374e47a3c308110aaa58f0ac7590229a5e" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which 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 68e3dd8ff8a..bb26ee659a2 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -108,6 +108,37 @@ pub struct UtxoCreditVerdictFFI { pub spent_at_height: u32, } +/// One change to a wallet's locked outpoints, carried by the persistence +/// extension's `on_persist_wallet_changeset_outpoint_locks_fn` slot. +/// +/// A locked outpoint is kept out of coin selection: the collateral of a +/// masternode registration the wallet processed (spending it would end the +/// registration), or an outpoint locked by hand. The lock does not need a +/// coin behind it, so a host keeps it apart from its UTXO rows, keyed by +/// `(wallet_id, outpoint)`, and hands every stored lock back through the +/// `on_load_wallet_locked_outpoints_fn` slot. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct OutpointLockFFI { + pub outpoint: OutPointFFI, + /// `true`: store the lock. `false`: delete it. + pub locked: bool, +} + +/// Project a changeset's outpoint locks into their C mirrors for the +/// extension slot, in outpoint order. +pub(crate) fn build_outpoint_locks_for_callback( + cs: &platform_wallet::changeset::CoreChangeSet, +) -> Vec { + cs.outpoint_locks + .iter() + .map(|(outpoint, locked)| OutpointLockFFI { + outpoint: OutPointFFI::from(outpoint), + locked: *locked, + }) + .collect() +} + /// Project a changeset's credit verdicts into their C mirrors for the /// extension slot, in outpoint order (the map's own ordering — stable, /// so a host log of a round is reproducible). diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index dc32a5957b6..aed12f86745 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -63,6 +63,7 @@ pub mod masternode_update_service; pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; +pub mod outpoint_locks; pub mod parse_state_transition; pub mod persistence; pub mod platform_address_sync; @@ -142,6 +143,7 @@ pub use manager::*; pub use manager_diagnostics::*; pub use memory_explorer::*; pub use mnemonic_words::*; +pub use outpoint_locks::*; pub use persistence::*; pub use platform_address_sync::*; pub use platform_address_types::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 20f0c421b20..81c3a7b0688 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,9 +8,10 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn, - LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, - PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, + FFIPersister, FreeTrackedMasternodesFn, FreeWalletLockedOutpointsFn, + LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, LoadWalletLockedOutpointsFn, + PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetOutpointLocksFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, @@ -277,6 +278,18 @@ unsafe fn persistence_extension_callbacks( on_load_identity_balance_block_time_fn, LoadIdentityBalanceBlockTimeFn ), + wallet_changeset_outpoint_locks: slot!( + on_persist_wallet_changeset_outpoint_locks_fn, + PersistWalletChangesetOutpointLocksFn + ), + load_wallet_locked_outpoints: slot!( + on_load_wallet_locked_outpoints_fn, + LoadWalletLockedOutpointsFn + ), + load_wallet_locked_outpoints_free: slot!( + on_load_wallet_locked_outpoints_free_fn, + FreeWalletLockedOutpointsFn + ), } } diff --git a/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs b/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs new file mode 100644 index 00000000000..b6555c538a9 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs @@ -0,0 +1,191 @@ +//! FFI bindings for a wallet's locked outpoints: the outpoints kept out of +//! coin selection. +//! +//! The wallet locks the collateral of a masternode registration once the +//! registration is in a block (a collateral the ProRegTx creates as its own +//! output, from any sighting), since spending the collateral would end the +//! registration. +//! These calls list the locks and lock or unlock an outpoint by hand; a lock +//! or unlock is persisted before the call returns (see +//! `PlatformWallet::lock_outpoint`). + +use crate::check_ptr; +use crate::core_wallet_types::OutPointFFI; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +/// List the outpoints the wallet keeps out of coin selection, in outpoint +/// order. An entry may name a coin the wallet does not hold (yet). +/// +/// The caller owns the array and frees it with +/// [`platform_wallet_locked_outpoints_free`]. `out_outpoints` / `out_count` +/// are set to null / 0 when nothing is locked. +/// +/// # Safety +/// `out_outpoints` and `out_count` must be writable. `handle` must refer to +/// a live platform wallet for the duration of this call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_locked_outpoints( + handle: Handle, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(out_outpoints); + check_ptr!(out_count); + *out_outpoints = std::ptr::null(); + *out_count = 0; + + let option = PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { + let wallet = wallet.clone(); + block_on_worker(async move { wallet.locked_outpoints().await }) + }); + let result = unwrap_option_or_return!(option); + let outpoints: Vec = unwrap_result_or_return!(result) + .iter() + .map(OutPointFFI::from) + .collect(); + if outpoints.is_empty() { + return PlatformWalletFFIResult::ok(); + } + *out_count = outpoints.len(); + *out_outpoints = Box::into_raw(outpoints.into_boxed_slice()) as *const OutPointFFI; + PlatformWalletFFIResult::ok() +} + +/// Free an array returned by [`platform_wallet_locked_outpoints`]. +/// +/// # Safety +/// `outpoints` / `count` must be exactly what that call returned, freed once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_locked_outpoints_free( + outpoints: *mut OutPointFFI, + count: usize, +) { + if !outpoints.is_null() && count > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(outpoints, count)); + } +} + +/// Lock `outpoint`, so no send, asset lock or special-transaction fee spends +/// it until [`platform_wallet_unlock_outpoint`]. The wallet does not need to +/// hold the coin yet. `out_changed` is set to whether the outpoint was not +/// locked before. +/// +/// The lock is persisted before this returns. On a persistence error the +/// wallet still holds the lock in memory, but it does not survive a restart. +/// +/// # Safety +/// `outpoint` must be readable and `out_changed` writable. `handle` must +/// refer to a live platform wallet for the duration of this call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_lock_outpoint( + handle: Handle, + outpoint: *const OutPointFFI, + out_changed: *mut bool, +) -> PlatformWalletFFIResult { + set_outpoint_lock(handle, outpoint, out_changed, true) +} + +/// Unlock `outpoint`, so coin selection may spend it again. Unlocking a +/// masternode collateral lets a send spend it, and spending it ends the +/// masternode registration. `out_changed` is set to whether the outpoint +/// was locked. +/// +/// The unlock is persisted before this returns. On a persistence error the +/// outpoint is unlocked in memory but comes back locked after a restart. +/// +/// # Safety +/// As [`platform_wallet_lock_outpoint`]. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_unlock_outpoint( + handle: Handle, + outpoint: *const OutPointFFI, + out_changed: *mut bool, +) -> PlatformWalletFFIResult { + set_outpoint_lock(handle, outpoint, out_changed, false) +} + +unsafe fn set_outpoint_lock( + handle: Handle, + outpoint: *const OutPointFFI, + out_changed: *mut bool, + locked: bool, +) -> PlatformWalletFFIResult { + check_ptr!(outpoint); + check_ptr!(out_changed); + *out_changed = false; + let outpoint = dashcore::OutPoint::from(&*outpoint); + + let option = PLATFORM_WALLET_STORAGE.with_item(handle, |wallet| { + let wallet = wallet.clone(); + block_on_worker(async move { + if locked { + wallet.lock_outpoint(outpoint).await + } else { + wallet.unlock_outpoint(outpoint).await + } + }) + }); + let result = unwrap_option_or_return!(option); + *out_changed = unwrap_result_or_return!(result); + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::runtime; + use platform_wallet::test_support::test_platform_wallet_manager; + + unsafe fn listed(handle: Handle) -> Vec<(u8, u32)> { + let mut ptr: *const OutPointFFI = std::ptr::null(); + let mut count = 0usize; + let result = platform_wallet_locked_outpoints(handle, &mut ptr, &mut count); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + if count == 0 { + assert!(ptr.is_null()); + return Vec::new(); + } + let entries = std::slice::from_raw_parts(ptr, count) + .iter() + .map(|outpoint| (outpoint.txid[0], outpoint.vout)) + .collect(); + platform_wallet_locked_outpoints_free(ptr as *mut OutPointFFI, count); + entries + } + + #[test] + fn should_lock_list_and_unlock_an_outpoint_through_the_ffi() { + let (manager, handle) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + (manager, PLATFORM_WALLET_STORAGE.insert(wallet)) + }); + let outpoint = OutPointFFI { + txid: [0x33; 32], + vout: 2, + }; + let mut changed = false; + unsafe { + assert!(listed(handle).is_empty()); + + let result = platform_wallet_lock_outpoint(handle, &outpoint, &mut changed); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(changed, "the outpoint was not locked before"); + assert_eq!(listed(handle), vec![(0x33, 2)]); + + let result = platform_wallet_unlock_outpoint(handle, &outpoint, &mut changed); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(changed, "the outpoint was locked"); + assert!(listed(handle).is_empty()); + + let result = platform_wallet_unlock_outpoint(handle, &outpoint, &mut changed); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!changed, "unlocking twice changes nothing"); + } + PLATFORM_WALLET_STORAGE.remove(handle); + drop(manager); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 9334cde2789..ece333160c5 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -54,8 +54,9 @@ use crate::contact_persistence::{ }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ - build_sweep_batches_for_callback, build_utxo_credit_verdicts_for_callback, - free_wallet_changeset_ffi, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, + build_outpoint_locks_for_callback, build_sweep_batches_for_callback, + build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, OutPointFFI, + OutpointLockFFI, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -154,6 +155,12 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; +/// Locked outpoints (masternode collateral, and outpoints locked by hand) +/// are persisted AND restored. Requires the extension trio +/// `on_persist_wallet_changeset_outpoint_locks_fn` + +/// `on_load_wallet_locked_outpoints_fn` + +/// `on_load_wallet_locked_outpoints_free_fn`, and the host declaring the bit. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_OUTPOINT_LOCKS: u64 = 1 << 13; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -276,6 +283,36 @@ pub type LoadIdentityBalanceBlockTimeFn = unsafe extern "C" fn( out_block_time: *mut crate::types::BlockTime, ) -> i32; +/// Carries a round's changes to the wallet's locked outpoints (see +/// [`OutpointLockFFI`]): store a row per `locked` entry, keyed by +/// `(wallet_id, outpoint)`, and delete the row of every other entry. A lock +/// needs no coin behind it, so the row lives apart from the UTXO rows and +/// survives the coin being spent. Fired inside the round's begin/end bracket, +/// after the round's other core callbacks, only on rounds that carry a lock +/// change. A non-zero return fails the round like any other per-kind +/// callback. +pub type PersistWalletChangesetOutpointLocksFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + locks: *const OutpointLockFFI, + locks_count: usize, +) -> i32; + +/// Return every outpoint the host keeps locked for `wallet_id`, whether or +/// not it holds the coin. The host allocates the array and keeps it valid +/// until Rust hands it back through the matching free callback. Called once +/// per restored wallet during load. +pub type LoadWalletLockedOutpointsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, +) -> i32; + +/// Release an array previously returned by a [`LoadWalletLockedOutpointsFn`]. +pub type FreeWalletLockedOutpointsFn = + unsafe extern "C" fn(context: *mut c_void, outpoints: *const OutPointFFI, count: usize); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -406,6 +443,33 @@ pub struct PersistenceCallbacksExtension { out_block_time: *mut crate::types::BlockTime, ) -> i32, >, + /// The round's outpoint lock changes (see + /// [`PersistWalletChangesetOutpointLocksFn`]). Wired together with the + /// load + free pair below: the `OUTPOINT_LOCKS` capability is attested + /// only when all three are present (and declared). Appended under the + /// same version as every slot above; a host whose `struct_size` stops + /// before it keeps locks for the session only. + pub on_persist_wallet_changeset_outpoint_locks_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + locks: *const OutpointLockFFI, + locks_count: usize, + ) -> i32, + >, + /// See [`LoadWalletLockedOutpointsFn`]. + pub on_load_wallet_locked_outpoints_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, + ) -> i32, + >, + /// See [`FreeWalletLockedOutpointsFn`]. + pub on_load_wallet_locked_outpoints_free_fn: Option< + unsafe extern "C" fn(context: *mut c_void, outpoints: *const OutPointFFI, count: usize), + >, } impl Default for PersistenceCallbacksExtension { @@ -423,6 +487,9 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_changeset_utxo_verdicts_fn: None, on_persist_identity_balance_block_time_fn: None, on_load_identity_balance_block_time_fn: None, + on_persist_wallet_changeset_outpoint_locks_fn: None, + on_load_wallet_locked_outpoints_fn: None, + on_load_wallet_locked_outpoints_free_fn: None, } } } @@ -441,6 +508,9 @@ pub struct PersistenceExtensionCallbacks { pub wallet_changeset_utxo_verdicts: Option, pub persist_identity_balance_block_time: Option, pub load_identity_balance_block_time: Option, + pub wallet_changeset_outpoint_locks: Option, + pub load_wallet_locked_outpoints: Option, + pub load_wallet_locked_outpoints_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1377,6 +1447,11 @@ pub struct FFIPersister { tracked_masternodes_callbacks: PersistenceExtensionCallbacks, persist_identity_balance_block_time_callback: Option, load_identity_balance_block_time_callback: Option, + /// Extension-negotiated outpoint-lock trio (persist / load / free). A + /// host without it keeps locks for the session only. + wallet_changeset_outpoint_locks_callback: Option, + load_wallet_locked_outpoints_callback: Option, + load_wallet_locked_outpoints_free_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. @@ -1500,6 +1575,10 @@ 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, + wallet_changeset_outpoint_locks_callback: extensions.wallet_changeset_outpoint_locks, + load_wallet_locked_outpoints_callback: extensions.load_wallet_locked_outpoints, + load_wallet_locked_outpoints_free_callback: extensions + .load_wallet_locked_outpoints_free, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } @@ -1520,6 +1599,56 @@ impl FFIPersister { } } + /// Hand every outpoint lock the host stored for `wallet_id` back to the + /// wallet through `lock_outpoint`, which also moves a held coin into the + /// locked balance. A host without the load slot restores none. + fn restore_locked_outpoints( + &self, + wallet_id: &[u8; 32], + wallet_info: &mut key_wallet::wallet::ManagedWalletInfo, + ) -> Result<(), PersistenceError> { + let Some(load) = self.load_wallet_locked_outpoints_callback else { + return Ok(()); + }; + // Fail closed on a half-wired pair, like the tracked-masternode and + // shielded loads: without the free callback every load would leak + // the host's array. + let Some(free) = self.load_wallet_locked_outpoints_free_callback else { + return Err(PersistenceError::backend( + "on_load_wallet_locked_outpoints_fn requires \ + on_load_wallet_locked_outpoints_free_fn; wire both or neither", + )); + }; + let mut outpoints_ptr: *const OutPointFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut outpoints_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_wallet_locked_outpoints_fn returned error code {rc}"), + )); + } + let mut outpoints = Vec::with_capacity(count); + if !outpoints_ptr.is_null() && count > 0 { + // SAFETY: the host guarantees `count` contiguous entries that stay + // valid until the free callback below. + let rows = unsafe { slice::from_raw_parts(outpoints_ptr, count) }; + outpoints.extend(rows.iter().map(dashcore::OutPoint::from)); + } + unsafe { free(self.callbacks.context, outpoints_ptr, count) }; + for outpoint in outpoints { + wallet_info.lock_outpoint(outpoint); + } + Ok(()) + } + /// 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. @@ -1565,6 +1694,12 @@ impl FFIPersister { { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); } + if self.wallet_changeset_outpoint_locks_callback.is_some() + && self.load_wallet_locked_outpoints_callback.is_some() + && self.load_wallet_locked_outpoints_free_callback.is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::OUTPOINT_LOCKS); + } if self.callbacks.on_persist_wallet_changeset_fn.is_some() && wallet_restore && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -2176,6 +2311,34 @@ impl PlatformWalletPersistence for FFIPersister { } } } + + // Outpoint locks ride their own size-negotiated extension slot: + // a lock needs no coin behind it, so it has no place among the + // per-account UTXO rows of the frozen changeset struct. Fired + // last in the core block, only when the round changes a lock. A + // host without the slot keeps locks for the session only and + // cannot attest `OUTPOINT_LOCKS`. + if !core_cs.outpoint_locks.is_empty() { + if let Some(cb) = self.wallet_changeset_outpoint_locks_callback { + let locks = build_outpoint_locks_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + locks.as_ptr(), + locks.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset outpoint-lock persistence callback returned error \ + code {}", + result + ); + outcome.record(result); + } + } + } } // Send identity scalar changeset — upserts and removals. @@ -3197,6 +3360,7 @@ impl PlatformWalletPersistence for FFIPersister { let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + self.restore_locked_outpoints(&entry.wallet_id, &mut wallet_state.wallet_info)?; 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() { @@ -8705,6 +8869,36 @@ mod tests { PersistenceCallbacksExtension, on_load_identity_balance_block_time_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_outpoint_locks_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_outpoint_locks_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_wallet_locked_outpoints_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_wallet_locked_outpoints_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_wallet_locked_outpoints_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_wallet_locked_outpoints_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -8763,6 +8957,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, PersistenceCapabilities::DASHPAY_PAYMENTS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_OUTPOINT_LOCKS, + PersistenceCapabilities::OUTPOINT_LOCKS.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES @@ -10918,3 +11116,197 @@ mod tests { ); } } + +#[cfg(test)] +mod outpoint_lock_tests { + //! The outpoint-lock trio: a round's lock changes reach the host through + //! their own extension slot, the host's stored locks come back on load, + //! and `OUTPOINT_LOCKS` is attested only with the whole trio declared. + + use super::*; + use dashcore::hashes::Hash as _; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::changeset::CoreChangeSet; + + #[derive(Default)] + struct Sink { + locks: std::sync::Mutex>, + stored: Vec, + freed: std::sync::Mutex, + } + + unsafe extern "C" fn record_locks( + ctx: *mut c_void, + _wallet_id: *const u8, + locks: *const OutpointLockFFI, + locks_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let mut recorded = sink.locks.lock().unwrap(); + for lock in slice::from_raw_parts(locks, locks_count) { + recorded.push((dashcore::OutPoint::from(&lock.outpoint), lock.locked)); + } + 0 + } + + unsafe extern "C" fn load_locks( + ctx: *mut c_void, + _wallet_id: *const u8, + out_outpoints: *mut *const OutPointFFI, + out_count: *mut usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + *out_outpoints = sink.stored.as_ptr(); + *out_count = sink.stored.len(); + 0 + } + + unsafe extern "C" fn free_locks( + ctx: *mut c_void, + _outpoints: *const OutPointFFI, + _count: usize, + ) { + let sink = &*(ctx as *const Sink); + *sink.freed.lock().unwrap() += 1; + } + + fn outpoint(byte: u8, vout: u32) -> dashcore::OutPoint { + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([byte; 32]), + vout, + } + } + + fn trio() -> PersistenceExtensionCallbacks { + PersistenceExtensionCallbacks { + wallet_changeset_outpoint_locks: Some(record_locks), + load_wallet_locked_outpoints: Some(load_locks), + load_wallet_locked_outpoints_free: Some(free_locks), + ..Default::default() + } + } + + #[test] + fn should_hand_a_rounds_lock_changes_to_the_host_in_outpoint_order() { + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::NONE, + trio(), + ); + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("a round without lock changes succeeds"); + assert!( + sink.locks.lock().unwrap().is_empty(), + "a round without lock changes never fires the slot" + ); + + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + outpoint_locks: BTreeMap::from([ + (outpoint(0xCD, 0), false), + (outpoint(0xAB, 1), true), + ]), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("a lock round succeeds"); + assert_eq!( + sink.locks.lock().unwrap().clone(), + vec![(outpoint(0xAB, 1), true), (outpoint(0xCD, 0), false)] + ); + drop(persister); + } + + #[test] + fn should_restore_the_hosts_locks_into_the_wallet_on_load() { + let wallet = key_wallet::Wallet::from_seed_bytes( + [0x21; 64], + dashcore::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"); + let mut info = key_wallet::wallet::ManagedWalletInfo::from_wallet(&wallet, 0); + let sink = Sink { + stored: vec![ + OutPointFFI::from(&outpoint(0x11, 0)), + OutPointFFI::from(&outpoint(0x22, 3)), + ], + ..Sink::default() + }; + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::NONE, + trio(), + ); + + persister + .restore_locked_outpoints(&[1u8; 32], &mut info) + .expect("restore"); + assert!(info.is_outpoint_locked(&outpoint(0x11, 0))); + assert!(info.is_outpoint_locked(&outpoint(0x22, 3))); + assert_eq!( + *sink.freed.lock().unwrap(), + 1, + "the host's array is freed once" + ); + drop(persister); + } + + #[test] + fn should_attest_outpoint_locks_only_with_the_whole_trio_declared() { + let declared = PersistenceCapabilities::OUTPOINT_LOCKS; + let wired = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks::default(), + declared, + trio(), + ); + assert!(wired + .persistence_capabilities() + .contains(PersistenceCapabilities::OUTPOINT_LOCKS)); + + let undeclared = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks::default(), + PersistenceCapabilities::NONE, + trio(), + ); + assert!(!undeclared + .persistence_capabilities() + .contains(PersistenceCapabilities::OUTPOINT_LOCKS)); + + let without_free = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks::default(), + declared, + PersistenceExtensionCallbacks { + load_wallet_locked_outpoints_free: None, + ..trio() + }, + ); + assert!(!without_free + .persistence_capabilities() + .contains(PersistenceCapabilities::OUTPOINT_LOCKS)); + } +} diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 769378695c1..f903b276b2a 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -847,3 +847,4 @@ table-rebuild migration, as V004 does. | V016 | `V016__identity_keys_null_scope_requires_existing_identity.rs` | Recreates the `identity_keys` null-scope trigger pair (see Triggers above) to also reject a NULL-scoped key naming an identity that does not exist at all, closing the gap where V008's guard caught only the wallet-owned case. | | V017 | `V017__identity_scan_state.rs` | Adds `identity_scan_states` (one row per wallet: the last gap-limit identity-scan verdict — `complete`, `probed_from`/`probed_through`, `unlocated_gap`) and `identity_scan_failed_indices` (indices probed without an answer, cascading from the verdict row via `wallet_id`). Purely additive; an upgraded database reads back "no verdict recorded" for every wallet until the next scan (dashpay/platform#4365). | | V018 | `V018__identity_hard_delete.rs` | Retires identity tombstoning. Adds `cascade_children_on_identity_delete` (brooms `identity_keys` / `contacts` / `ignored_senders` / `pending_contact_crypto` by the deleted identity id, covering the rows no live FK reaches) plus its access-path indexes `idx_contacts_owner`, `idx_ignored_senders_owner`, and `idx_pending_contact_crypto_owner`; purges every already-tombstoned identity and its dependents; drops `identities.tombstoned`. | +| V019 | `V019__core_locked_outpoints.rs` | Adds `core_locked_outpoints`, one row per `(wallet_id, outpoint)` the wallet keeps out of coin selection: the collateral of a registered masternode, or an outpoint locked by hand. A table of its own rather than a `core_utxos` column, because a lock can exist before its coin and outlives the coin's spend. Deleting the row unlocks the outpoint. Purely additive; an upgraded database starts with no locks. | diff --git a/packages/rs-platform-wallet-storage/migrations/V019__core_locked_outpoints.rs b/packages/rs-platform-wallet-storage/migrations/V019__core_locked_outpoints.rs new file mode 100644 index 00000000000..336dcaa6c7d --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V019__core_locked_outpoints.rs @@ -0,0 +1,28 @@ +//! Add the `core_locked_outpoints` table (outpoints a wallet keeps out of +//! coin selection). +//! +//! One row per `(wallet_id, outpoint)`, and the row is the lock: the +//! collateral of a registered masternode, locked when the wallet processes +//! its ProRegTx, or an outpoint locked by hand. Deleting the row unlocks it. +//! Spending a collateral would end its masternode registration, which is why +//! the wallet keeps it out of ordinary sends. +//! +//! A table of its own rather than a `core_utxos` column: a lock can exist +//! before its coin does (the ProRegTx can be processed before the collateral +//! it names arrives, and an outpoint can be locked by hand at any time), and +//! it outlives the coin being spent, so it cannot live on the coin's row. +//! `outpoint` carries the same encoding as `core_utxos.outpoint`. +//! +//! Purely additive: an upgraded database starts with no locks. + +pub fn migration() -> String { + "\ +CREATE TABLE core_locked_outpoints ( + wallet_id BLOB NOT NULL, + outpoint BLOB NOT NULL, + PRIMARY KEY (wallet_id, outpoint), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index fd865405121..6fa3e4623f0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1306,8 +1306,9 @@ impl PlatformWalletPersistence for SqlitePersister { fn persistence_capabilities(&self) -> PersistenceCapabilities { // Every `flush_inner` applies the complete changeset in one SQLite // transaction. The current schema also has lossless token balances, - // invitations, account pools, tracked asset locks, and - // deferred-contact-crypto queue rows. + // invitations, account pools, tracked asset locks, + // deferred-contact-crypto queue rows, and locked outpoints (applied + // by `core_state::apply`, handed back by `load_state`). // Do NOT attest WALLET_RESTORE (and therefore not provider restore): // token balances and the DashPay overlay have no load readers, so a // full restore remains lossy. Shielded viewing keys are native when @@ -1321,7 +1322,8 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - .union(PersistenceCapabilities::DASHPAY_PAYMENTS); + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(PersistenceCapabilities::OUTPOINT_LOCKS); #[cfg(feature = "shielded")] { capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) @@ -2387,6 +2389,7 @@ mod tests { "contacts", "core_address_pool", "core_instant_locks", + "core_locked_outpoints", "core_sync_state", "core_transactions", "core_utxos", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 270e0a436d0..5fdb257b735 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -220,6 +220,11 @@ pub(crate) fn restore_provider_platform_node_pool( /// entry is replayed through `mark_instant_send_utxos` after the UTXO /// restore, so instant-locked funds come back instant-locked instead of /// waiting for the next sync to re-learn them. +/// - **Locked outpoints** (masternode collateral and outpoints locked by +/// hand): every `outpoint_locks` entry is handed to +/// `ManagedWalletInfo::lock_outpoint` (an unlock to `unlock_outpoint`), so +/// a locked coin comes back in the locked balance and out of coin +/// selection, including one whose coin arrives only after the load. /// - **Sync watermarks**: `synced_height` / `last_processed_height`. /// /// # Reconstructed when the persister supplies it @@ -300,6 +305,17 @@ pub fn apply_persisted_core_state( wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); } + // Locked outpoints first: a lock needs no coin behind it, and the + // `update_balance()` below sets every restored coin's flag from the lock + // set, whatever flag the coin was restored with. + for (outpoint, locked) in &core.outpoint_locks { + if *locked { + wallet_info.lock_outpoint(*outpoint); + } else { + wallet_info.unlock_outpoint(outpoint); + } + } + // 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 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 77d4808ed75..6a02e3ca024 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 @@ -193,6 +193,25 @@ pub fn apply( ])?; } } + if !cs.outpoint_locks.is_empty() { + // A lock is a row of its own, never a `core_utxos` column: it can + // name a coin that has not arrived yet and outlives the coin's spend. + let mut lock_stmt = tx.prepare_cached( + "INSERT INTO core_locked_outpoints (wallet_id, outpoint) VALUES (?1, ?2) \ + ON CONFLICT(wallet_id, outpoint) DO NOTHING", + )?; + let mut unlock_stmt = tx.prepare_cached( + "DELETE FROM core_locked_outpoints WHERE wallet_id = ?1 AND outpoint = ?2", + )?; + for (outpoint, locked) in &cs.outpoint_locks { + let key = blob::encode_outpoint(outpoint)?; + if *locked { + lock_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } else { + unlock_stmt.execute(params![wallet_id.as_slice(), &key[..]])?; + } + } + } let chainlock_height = cs .last_applied_chain_lock .as_ref() @@ -898,6 +917,11 @@ fn upsert_sync_state( /// and checked against their typed txid and height columns. /// - **IS-locks** / **sync watermarks**: decoded bit-exact, fail-hard on a /// corrupt blob. +/// - **Locked outpoints** (`outpoint_locks`): every `core_locked_outpoints` +/// row, as a lock, whether or not the wallet holds the coin. Each restored +/// UTXO's `is_locked` follows it. Fail-hard on a malformed outpoint, like +/// the UTXO reader: a dropped lock would hand a masternode collateral back +/// to coin selection. /// /// # Deferred to the first post-load `sync` (safe re-warm) /// @@ -962,6 +986,24 @@ pub fn load_state( } } + // Locked outpoints, read before the coins so each restored coin carries + // its lock. The wallet re-derives every coin's flag from the lock set it + // is handed (`ManagedWalletInfo::lock_outpoint`), so these flags only + // keep this changeset consistent with itself. Same pre-read length gate + // as the `core_utxos.outpoint` read below. + { + let mut stmt = conn.prepare( + "SELECT length(outpoint), outpoint FROM core_locked_outpoints WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + cs.outpoint_locks + .insert(blob::decode_outpoint(&op_bytes)?, true); + } + } + // Unspent UTXOs → new_utxos (the balance source). // Pre-read `length()` gates on `outpoint` and `script` before materializing // the Vec so tampered oversize values are caught before heap allocation. @@ -1011,7 +1053,7 @@ pub fn load_state( is_coinbase: false, is_confirmed: height.is_some(), is_instantlocked: false, - is_locked: false, + is_locked: cs.outpoint_locks.get(&outpoint).copied().unwrap_or(false), is_trusted: false, }; cs.new_utxos.push(utxo); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 16d225c1813..f40ebc5fc9b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -71,6 +71,11 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "core_state.rs", "SELECT length(outpoint), outpoint, value, length(script), script", ), + // load_state locked-outpoint reader: pre-read length() gate on outpoint. + ( + "core_state.rs", + "SELECT length(outpoint), outpoint FROM core_locked_outpoints WHERE wallet_id", + ), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), // Pool reader: verbatim used-set with owner columns, a one-shot read-only // scan per wallet. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_locked_outpoints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_locked_outpoints.rs new file mode 100644 index 00000000000..2aa818deaec --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_locked_outpoints.rs @@ -0,0 +1,365 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Locked outpoints (masternode collateral, and outpoints locked by hand) +//! survive the SQLite store: `core_locked_outpoints` rows come back through +//! `load_state` and `apply_persisted_core_state`, whether or not the wallet +//! already holds the coin, and keep the collateral out of coin selection. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::{Address, BlockHash, OutPoint, Transaction, TxIn, TxOut}; +use key_wallet::transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +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 platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::LoadCtx; + +const DUFFS_PER_DASH: u64 = 100_000_000; +const COLLATERAL: u64 = 1_000 * DUFFS_PER_DASH; +const SPARE: u64 = 5 * DUFFS_PER_DASH; +const HEIGHT: u32 = 100; + +fn test_wallet() -> Wallet { + Wallet::from_seed_bytes( + [0x42; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("wallet from seed") +} + +fn manifest_for(wallet: &Wallet) -> Vec { + wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() +} + +/// Two receive addresses of the wallet: one for the collateral, one for the +/// spare coin. +fn two_addresses(wallet: &Wallet) -> (Address, Address) { + let info = ManagedWalletInfo::from_wallet(wallet, 1); + let mut addresses = WalletInfoInterface::monitored_addresses(&info).into_iter(); + let first = addresses.next().expect("a monitored address"); + let second = addresses.next().expect("a second monitored address"); + (first, second) +} + +/// A transaction paying `value` to `address` as output 0. `marker` keeps the +/// txids of otherwise identical transactions apart. +fn payment(address: &Address, value: u64, marker: u8) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(dashcore::Txid::from_byte_array([marker; 32]), 0), + ..TxIn::default() + }], + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + } +} + +fn coin(tx: &Transaction, address: &Address) -> Utxo { + Utxo { + outpoint: OutPoint::new(tx.txid(), 0), + txout: tx.output[0].clone(), + address: address.clone(), + height: HEIGHT, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn store_core( + persister: &platform_wallet_storage::SqlitePersister, + w: WalletId, + core: CoreChangeSet, +) { + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + }, + ) + .expect("store"); +} + +fn locks(entries: &[(OutPoint, bool)]) -> BTreeMap { + entries.iter().copied().collect() +} + +/// Reopen the database and rebuild the wallet the way `load()` does. +fn reload(path: &std::path::Path, w: &WalletId, wallet: &Wallet) -> ManagedWalletInfo { + let persister = platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister"); + let conn = persister.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, w, key_wallet::Network::Testnet, &LoadCtx::strict()) + .expect("load_state"); + drop(conn); + let mut info = ManagedWalletInfo::from_wallet(wallet, 1); + platform_wallet_storage::sqlite::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_for(wallet), + &core, + &utxo_accounts, + &Default::default(), + &LoadCtx::strict(), + ) + .expect("apply persisted core state"); + info +} + +fn spendable_outpoints(info: &ManagedWalletInfo) -> Vec { + WalletInfoInterface::get_spendable_utxos(info) + .into_iter() + .map(|utxo| utxo.outpoint) + .collect() +} + +#[test] +fn should_restore_a_locked_collateral_in_the_locked_balance_after_reopen() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + let wallet = test_wallet(); + let (collateral_address, spare_address) = two_addresses(&wallet); + let collateral_tx = payment(&collateral_address, COLLATERAL, 1); + let spare_tx = payment(&spare_address, SPARE, 2); + let collateral = OutPoint::new(collateral_tx.txid(), 0); + + store_core( + &persister, + w, + CoreChangeSet { + new_utxos: vec![ + coin(&collateral_tx, &collateral_address), + coin(&spare_tx, &spare_address), + ], + outpoint_locks: locks(&[(collateral, true)]), + synced_height: Some(HEIGHT), + last_processed_height: Some(HEIGHT), + ..Default::default() + }, + ); + drop(persister); + + let info = reload(&path, &w, &wallet); + assert!(info.is_outpoint_locked(&collateral)); + let balance = WalletInfoInterface::balance(&info); + assert_eq!(balance.locked(), COLLATERAL); + assert_eq!(balance.spendable(), SPARE); + assert_eq!( + spendable_outpoints(&info), + vec![OutPoint::new(spare_tx.txid(), 0)], + "coin selection must see only the spare coin" + ); +} + +#[test] +fn should_restore_a_lock_stored_before_its_coin() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + let wallet = test_wallet(); + let (collateral_address, _) = two_addresses(&wallet); + let collateral_tx = payment(&collateral_address, COLLATERAL, 3); + let collateral = OutPoint::new(collateral_tx.txid(), 0); + + // The ProRegTx was processed first: the lock lands with no coin behind it. + store_core( + &persister, + w, + CoreChangeSet { + outpoint_locks: locks(&[(collateral, true)]), + ..Default::default() + }, + ); + // The collateral arrives in a later round. + store_core( + &persister, + w, + CoreChangeSet { + new_utxos: vec![coin(&collateral_tx, &collateral_address)], + synced_height: Some(HEIGHT), + last_processed_height: Some(HEIGHT), + ..Default::default() + }, + ); + drop(persister); + + let info = reload(&path, &w, &wallet); + let balance = WalletInfoInterface::balance(&info); + assert_eq!(balance.locked(), COLLATERAL); + assert_eq!(balance.spendable(), 0); + assert!(spendable_outpoints(&info).is_empty()); +} + +#[tokio::test] +async fn should_lock_a_coin_that_arrives_after_the_reload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + let mut wallet = test_wallet(); + let (collateral_address, _) = two_addresses(&wallet); + let collateral_tx = payment(&collateral_address, COLLATERAL, 4); + let collateral = OutPoint::new(collateral_tx.txid(), 0); + + store_core( + &persister, + w, + CoreChangeSet { + outpoint_locks: locks(&[(collateral, true)]), + ..Default::default() + }, + ); + drop(persister); + + let mut info = reload(&path, &w, &wallet); + assert!(info.is_outpoint_locked(&collateral)); + assert_eq!(WalletInfoInterface::balance(&info).locked(), 0); + + let block = BlockInfo::new(HEIGHT, BlockHash::all_zeros(), 1_700_000_000); + let result = info + .check_core_transaction( + &collateral_tx, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + assert!(result.is_relevant, "the collateral pays this wallet"); + + let balance = WalletInfoInterface::balance(&info); + assert_eq!(balance.locked(), COLLATERAL, "the coin arrives locked"); + assert_eq!(balance.spendable(), 0); + assert!(spendable_outpoints(&info).is_empty()); +} + +#[test] +fn should_not_restore_an_unlocked_outpoint() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC4); + ensure_wallet_meta(&persister, &w); + let wallet = test_wallet(); + let (collateral_address, _) = two_addresses(&wallet); + let collateral_tx = payment(&collateral_address, COLLATERAL, 5); + let collateral = OutPoint::new(collateral_tx.txid(), 0); + + store_core( + &persister, + w, + CoreChangeSet { + new_utxos: vec![coin(&collateral_tx, &collateral_address)], + outpoint_locks: locks(&[(collateral, true)]), + synced_height: Some(HEIGHT), + last_processed_height: Some(HEIGHT), + ..Default::default() + }, + ); + store_core( + &persister, + w, + CoreChangeSet { + outpoint_locks: locks(&[(collateral, false)]), + ..Default::default() + }, + ); + drop(persister); + + let info = reload(&path, &w, &wallet); + assert!(!info.is_outpoint_locked(&collateral)); + let balance = WalletInfoInterface::balance(&info); + assert_eq!(balance.locked(), 0); + assert_eq!(balance.spendable(), COLLATERAL); + assert_eq!(spendable_outpoints(&info), vec![collateral]); +} + +#[test] +fn should_keep_the_unlock_when_one_round_locks_then_unlocks() { + let mut first = CoreChangeSet { + outpoint_locks: locks(&[(OutPoint::new(dashcore::Txid::all_zeros(), 7), true)]), + ..Default::default() + }; + let second = CoreChangeSet { + outpoint_locks: locks(&[(OutPoint::new(dashcore::Txid::all_zeros(), 7), false)]), + ..Default::default() + }; + platform_wallet::changeset::Merge::merge(&mut first, second); + assert_eq!( + first.outpoint_locks, + locks(&[(OutPoint::new(dashcore::Txid::all_zeros(), 7), false)]) + ); +} + +#[test] +fn should_drop_a_wallets_locks_with_the_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC5); + let other = wid(0xC6); + ensure_wallet_meta(&persister, &w); + ensure_wallet_meta(&persister, &other); + let outpoint = OutPoint::new(dashcore::Txid::from_byte_array([0x77; 32]), 1); + for wallet_id in [w, other] { + store_core( + &persister, + wallet_id, + CoreChangeSet { + outpoint_locks: locks(&[(outpoint, true)]), + ..Default::default() + }, + ); + } + + persister + .delete_wallet_skip_backup(w) + .expect("delete wallet"); + + let conn = persister.lock_conn_for_test(); + let count = |wallet_id: &WalletId| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_locked_outpoints WHERE wallet_id = ?1", + [wallet_id.as_slice()], + |row| row.get(0), + ) + .expect("count locks") + }; + assert_eq!(count(&w), 0, "the deleted wallet's locks go with it"); + assert_eq!(count(&other), 1, "another wallet's lock stays"); +} + +#[test] +fn should_attest_that_locks_survive_a_restart() { + let (persister, _tmp, _path) = fresh_persister(); + assert!(persister + .persistence_capabilities() + .contains(platform_wallet::changeset::PersistenceCapabilities::OUTPOINT_LOCKS)); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index 282a2f5abf1..6af75bf1eba 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/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 = - "91f2fab573900a41066b8d237a29b83ca94b2fc730702389ab9c088271da522f"; + "43cbce5690ee3d195018867f776d344d57a76c4721d06c441745976b0313c444"; /// 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 = - "0dbcfb2ab8d8362a206c0ab948051028c7eafc640861a823c4c50f13b0602be8"; + "8007c3b36c6bbf432e9edbe9b0d03d2745ff1e7ed7b26a20d8e4dbb5f54bd31a"; /// 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/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 1fe4d1eb514..0e532d7bde7 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -69,6 +69,8 @@ use crate::wallet::identity::{ /// [`fold_same_txid_records`]), uses monotonic-max for the height /// watermarks, `extend` for the utxo vecs and for `sweeps` (in emission /// order — see the field), and last-write-wins for the IS-lock map. +/// [`Self::outpoint_locks`] carries unlocks as well as locks, and merges +/// last-write-wins per outpoint. /// /// # Why a projection instead of the upstream type /// @@ -300,6 +302,26 @@ pub struct CoreChangeSet { /// [`Self::sweeps`]. #[cfg_attr(feature = "serde", serde(default))] pub utxo_credit_verdicts: BTreeMap, + + /// Changes to the outpoints the wallet keeps out of coin selection: + /// `true` locks the outpoint, `false` unlocks it. + /// + /// The wallet locks the collateral of the masternode registrations + /// (ProRegTx) it processes, a collateral a ProRegTx names once the + /// ProRegTx is in a block, since spending the collateral would end the + /// registration, and the user can lock or unlock any outpoint by hand. + /// The in-memory set is `ManagedWalletInfo::locked_outpoints`; this is + /// its persistence delta. A lock does not need a coin behind it (the + /// ProRegTx can arrive before its collateral) and outlives the coin's + /// spend, so a persister keeps locks apart from its UTXO rows. On load + /// it hands every stored lock back through + /// `ManagedWalletInfo::lock_outpoint`. + /// + /// Merge: newest wins per outpoint, so an unlock after a lock in one + /// fold persists the unlock. `serde(default)` for the same + /// backward-compatible reading as [`Self::sweeps`]. + #[cfg_attr(feature = "serde", serde(default))] + pub outpoint_locks: BTreeMap, } /// Why the engine did not credit a `Received` / `Change` output of a @@ -832,6 +854,9 @@ impl Merge for CoreChangeSet { // entries of records the newer changeset re-projected were dropped // at the top of this merge. self.utxo_credit_verdicts.extend(other.utxo_credit_verdicts); + + // Outpoint locks: newest wins per outpoint. + self.outpoint_locks.extend(other.outpoint_locks); } fn is_empty(&self) -> bool { @@ -848,6 +873,7 @@ impl Merge for CoreChangeSet { && self.account_highest_used.is_empty() && self.last_applied_chain_lock.is_none() && self.utxo_credit_verdicts.is_empty() + && self.outpoint_locks.is_empty() } } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 574715eed6b..4d12df1073a 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1108,10 +1108,34 @@ async fn reconstruct_asset_locks_for_event( } /// Project an upstream [`WalletEvent`] into a [`CoreChangeSet`] suitable -/// for atomic persistence. +/// for atomic persistence, carrying every outpoint lock the wallet's +/// transaction checks queued since the last one. +/// +/// No `WalletEvent` carries a lock: a ProRegTx locks its collateral even +/// when the transaction is otherwise irrelevant and emits nothing. The locks +/// ride whichever event of the wallet comes next, which for a block is at +/// the latest the `SyncHeightAdvanced` watermark that certifies it. async fn build_core_changeset( wallet_manager: &Arc>>, event: &WalletEvent, +) -> CoreChangeSet { + let mut cs = project_core_event(wallet_manager, event).await; + let queued = { + let guard = wallet_manager.read().await; + guard + .get_wallet_info(&event.wallet_id()) + .map(PlatformWalletInfo::take_queued_outpoint_locks) + .unwrap_or_default() + }; + cs.outpoint_locks + .extend(queued.into_iter().map(|outpoint| (outpoint, true))); + cs +} + +/// The [`CoreChangeSet`] one [`WalletEvent`] describes. +async fn project_core_event( + wallet_manager: &Arc>>, + event: &WalletEvent, ) -> CoreChangeSet { match event { WalletEvent::TransactionDetected { @@ -2170,6 +2194,7 @@ impl CoreChangeSet { && self.addresses_marked_used.is_empty() && self.account_highest_used.is_empty() && self.utxo_credit_verdicts.is_empty() + && self.outpoint_locks.is_empty() } } @@ -3803,6 +3828,7 @@ mod contact_watch_only_projection_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); @@ -7203,6 +7229,7 @@ mod utxo_credit_verdict_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); @@ -7297,6 +7324,7 @@ mod utxo_credit_verdict_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); @@ -7322,3 +7350,71 @@ mod utxo_credit_verdict_tests { ); } } + +#[cfg(test)] +mod outpoint_lock_projection_tests { + //! A ProRegTx locks its collateral without emitting any `WalletEvent`, + //! so the lock rides the wallet's next projected changeset. + + use super::*; + use crate::test_support::{masternode_registration, wallet_manager_with_registered_collateral}; + use dashcore::hashes::Hash; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::transaction_checking::BlockInfo; + + const SPARE: u64 = 500_000_000; + + fn sync_height(wallet_id: WalletId, height: u32) -> WalletEvent { + WalletEvent::SyncHeightAdvanced { wallet_id, height } + } + + #[tokio::test] + async fn should_carry_a_registration_lock_on_the_wallets_next_changeset_once() { + let (manager, wallet_id, _generation, _signer, collateral, _spare) = + wallet_manager_with_registered_collateral(StandardAccountType::BIP44Account, SPARE) + .await; + + let cs = build_core_changeset(&manager, &sync_height(wallet_id, 2)).await; + assert_eq!(cs.outpoint_locks, BTreeMap::from([(collateral, true)])); + let lock_only = CoreChangeSet { + outpoint_locks: cs.outpoint_locks.clone(), + ..CoreChangeSet::default() + }; + assert!( + !lock_only.is_empty_no_records(), + "a changeset carrying only a lock must still reach the persister" + ); + + let next = build_core_changeset(&manager, &sync_height(wallet_id, 3)).await; + assert!(next.outpoint_locks.is_empty(), "a lock is carried once"); + } + + #[tokio::test] + async fn should_not_queue_a_registration_seen_again() { + let (manager, wallet_id, _generation, _signer, collateral, _spare) = + wallet_manager_with_registered_collateral(StandardAccountType::BIP44Account, SPARE) + .await; + let _ = build_core_changeset(&manager, &sync_height(wallet_id, 2)).await; + + // The block that confirms it again, or a rescan. + manager + .write() + .await + .check_transaction_in_all_wallets( + &masternode_registration(collateral), + TransactionContext::InChainLockedBlock(BlockInfo::new( + 3, + dashcore::BlockHash::all_zeros(), + 1_700_000_200, + )), + true, + true, + ) + .await; + let cs = build_core_changeset(&manager, &sync_height(wallet_id, 3)).await; + assert!( + cs.outpoint_locks.is_empty(), + "the wallet already holds that lock" + ); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index d930daf1ec9..e254c7a379f 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); + /// Locked outpoints (masternode collateral, and outpoints locked by hand) + /// are persisted AND restored: a stored `CoreChangeSet`'s + /// `outpoint_locks` are durably applied, a lock kept whether or not its + /// coin has a row yet, and a restart hands every stored lock back to the + /// wallet. Without this bit a lock lasts for the session, and a + /// collateral is locked again only when the wallet sees its registration + /// again. On the FFI surface locks travel through the persistence + /// extension's size-negotiated persist slot and come back through its + /// load and free slots; all three must be wired. + pub const OUTPOINT_LOCKS: Self = Self(1 << 13); /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place @@ -112,7 +122,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 = 12; + const HIGHEST_DECLARED_BIT: u32 = 13; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -205,6 +215,7 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), + (PersistenceCapabilities::OUTPOINT_LOCKS, "outpoint_locks"), ]; KNOWN @@ -222,7 +233,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::DASHPAY_PAYMENTS.bits() + PersistenceCapabilities::OUTPOINT_LOCKS.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -250,6 +261,7 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); + assert_eq!(PersistenceCapabilities::OUTPOINT_LOCKS.bits(), 0x2000); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 1f9ed240c84..8a80e8b3e58 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1946,6 +1946,7 @@ mod txo_inventory_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), observed_input_conflicts: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1bbd0330172..3d01a632da9 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -7,6 +7,7 @@ use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletP use crate::error::PlatformWalletError; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; +use crate::wallet::outpoint_locks::lock_known_masternode_collaterals; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; @@ -157,6 +158,11 @@ impl PlatformWalletManager

{ // `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. + // A replayed ProRegTx locks its collateral here, and nothing + // persists that lock unless it is queued with the rest: once the + // registration confirms it leaves this list and is never replayed + // again. + let mut replay_locks = Vec::new(); if !unconfirmed_outgoing_txs.is_empty() { let mut replayed = 0usize; for tx in &unconfirmed_outgoing_txs { @@ -169,6 +175,7 @@ impl PlatformWalletManager

{ true, ) .await; + replay_locks.extend(result.locked_outpoints); if result.is_relevant { replayed += 1; } @@ -229,7 +236,9 @@ impl PlatformWalletManager

{ identity_manager: IdentityManager::from(identity_manager), tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; + platform_info.queue_outpoint_locks(replay_locks); // Seed the double-spend screen's session memory from the // freshly restored state: it closes the race where SPV's // chainlock dispatcher promotion-evicts a restored spender @@ -477,6 +486,17 @@ impl PlatformWalletManager

{ return Err(err); } + // Keep the collateral of every masternode the wallets already know out + // of coin selection: registrations restored into their histories + // without a transaction check, and tracked masternodes whose + // registration has been fetched. New locks are queued for persistence + // like the ones a check makes. See `wallet::outpoint_locks`. + lock_known_masternode_collaterals( + &self.wallet_manager, + &self.tracked_masternodes_service().known_collaterals(), + ) + .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. @@ -1532,3 +1552,164 @@ mod tests { ); } } + +#[cfg(test)] +mod masternode_collateral_load_tests { + //! A load locks the collateral of the masternodes the manager already + //! knows, including one whose registration the wallet never processed. + + use std::collections::BTreeMap; + use std::sync::Arc; + + use dashcore::hashes::Hash; + use dashcore::{BlockHash, OutPoint, Transaction}; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; + use key_wallet::wallet::ManagedWalletInfo; + use key_wallet::Wallet; + + use crate::changeset::{ + ClientStartState, ClientWalletStartState, IdentityManagerStartState, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::masternode::{RegistrationDetails, TrackedMasternode, TrackedMasternodeSnapshot}; + use crate::test_support::{NoopTestEventHandler, MASTERNODE_COLLATERAL_DUFFS}; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + + const SPARE: u64 = 500_000_000; + + /// Hands back one wallet and one tracked masternode. + struct TrackedCollateralPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + tracked: TrackedMasternode, + } + + impl PlatformWalletPersistence for TrackedCollateralPersister { + 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: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + }, + ); + Ok(ClientStartState { + wallets, + ..Default::default() + }) + } + + fn load_tracked_masternodes( + &self, + _network: dashcore::Network, + ) -> Result, PersistenceError> { + Ok(vec![self.tracked.clone()]) + } + } + + /// The user tracks a masternode whose collateral sits in this wallet but + /// whose registration the wallet never processed (funded and signed + /// elsewhere). After a load the collateral is locked: it is out of the + /// spendable balance and out of every send's coin selection, and the + /// lock is queued for persistence. + #[tokio::test] + async fn should_lock_a_tracked_masternodes_collateral_on_load() { + let mut ctx = TestWalletContext::new_random(); + let funding = Transaction::dummy( + &ctx.receive_address, + 0..1, + &[MASTERNODE_COLLATERAL_DUFFS, SPARE], + ); + ctx.check_transaction( + &funding, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 1, + BlockHash::all_zeros(), + 1_700_000_000, + )), + ) + .await; + let collateral = OutPoint::new(funding.txid(), 0); + let wallet_id = ctx.wallet.compute_wallet_id(); + let tracked = TrackedMasternode { + pro_tx_hash: [0x42; 32], + label: None, + added_at: 0, + snapshot: TrackedMasternodeSnapshot { + registration: Some(RegistrationDetails { + height: 2, + collateral: (collateral.txid.to_byte_array(), collateral.vout), + owner_key_hash: [0x01; 20], + voting_key_hash: [0x02; 20], + operator_public_key: [0x03; 48], + payout_script: Vec::new(), + service_address: None, + is_evonode: false, + platform_node_id: None, + platform_http_port: None, + }), + ..Default::default() + }, + }; + + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(TrackedCollateralPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + tracked, + }), + Arc::new(NoopTestEventHandler) as _, + )); + 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"); + assert_eq!( + wallet.locked_outpoints().await.expect("locks"), + vec![collateral] + ); + assert_eq!(wallet.balance().locked(), MASTERNODE_COLLATERAL_DUFFS); + assert_eq!( + wallet + .core() + .pooled_spendable_balance(&[AccountTypePreference::BIP44][..], 0) + .await + .expect("pooled balance"), + SPARE, + "coin selection sees only the spare coin" + ); + let wm = wallet.wallet_manager().read().await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + assert_eq!( + info.take_queued_outpoint_locks(), + std::collections::BTreeSet::from([collateral]), + "the lock is queued for persistence" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index bc211ce76cc..d3dbfeb15f6 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -363,14 +363,20 @@ impl PlatformWalletManager

{ .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) .unwrap_or(wallet.wallet_id); - let platform_info = PlatformWalletInfo { + let mut platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), dpns_name_states: std::collections::BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; + // A tracked masternode's collateral may reach this wallet as it syncs; + // lock it now so it arrives locked. + platform_info.lock_known_masternode_collaterals( + &self.tracked_masternodes_service().known_collaterals(), + ); wallet.downgrade_to_external_signable(); diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 66e179015b5..6fc3b1e7d88 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -26,19 +26,20 @@ //! session-scoped; hosts read //! [`PersistenceCapabilities::TRACKED_MASTERNODES`] to know which they got. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::{SystemTime, UNIX_EPOCH}; use dash_sdk::platform::Fetch; use dashcore::hashes::Hash; use dashcore::transaction::special_transaction::provider_registration::ProviderMasternodeType; use dashcore::transaction::TransactionPayload; -use dashcore::{Address as DashAddress, Network}; +use dashcore::{Address as DashAddress, Network, OutPoint}; use dpp::identifier::MasternodeIdentifiers; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{Identity, Purpose}; use dpp::prelude::Identifier; +use key_wallet_manager::WalletManager; use serde_json::{json, Value}; use super::list::MasternodeListSummary; @@ -50,6 +51,8 @@ use crate::manager::PlatformWalletManager; use crate::wallet::masternode_withdrawal::{ execute_masternode_withdrawal, MasternodeWithdrawalKey, RawSecretCoreSigner, }; +use crate::wallet::outpoint_locks::{lock_known_masternode_collaterals, named_collateral}; +use crate::wallet::platform_wallet::PlatformWalletInfo; // --------------------------------------------------------------------------- // Model @@ -492,6 +495,9 @@ pub struct TrackedMasternodes { sdk: std::sync::Arc, persister: std::sync::Arc, network: Network, + /// The manager's wallets, so a refresh that learns a registration can + /// lock its collateral in them. + wallet_manager: std::sync::Arc>>, } /// Apply one registry mutation and durably replace the persisted set as one @@ -644,6 +650,24 @@ impl TrackedMasternodes { } } + /// The collateral of every tracked masternode whose registration has + /// been fetched (see [`Self::refresh`]). + pub(crate) fn known_collaterals(&self) -> BTreeSet { + self.registry + .rows + .read() + .expect("tracked masternode registry lock poisoned") + .values() + .filter_map(|tracked| { + let registration = tracked.snapshot.registration.as_ref()?; + Some(named_collateral( + &tracked.pro_tx_hash, + registration.collateral, + )) + }) + .collect() + } + /// The wire proTxHashes currently tracked (for locate's /// `already_tracked` mark). pub fn hashes(&self) -> std::collections::BTreeSet<[u8; 32]> { @@ -785,6 +809,17 @@ impl TrackedMasternodes { ) .await?; + // Once the registration is known, keep its collateral out of every + // wallet's coin selection: spending it would end the registration. + if let Some(registration) = tracked.snapshot.registration.as_ref() { + let collateral = named_collateral(&tracked.pro_tx_hash, registration.collateral); + lock_known_masternode_collaterals( + self.wallet_manager.as_ref(), + &BTreeSet::from([collateral]), + ) + .await; + } + let entry = outcome .list_now .as_ref() @@ -1035,6 +1070,7 @@ impl PlatformWalletManager

{ sdk: self.sdk_arc(), persister: std::sync::Arc::clone(&self.persister) as _, network: self.sdk().network, + wallet_manager: std::sync::Arc::clone(&self.wallet_manager), } } diff --git a/packages/rs-platform-wallet/src/masternode/update_service.rs b/packages/rs-platform-wallet/src/masternode/update_service.rs index 90a66fda515..5785af00410 100644 --- a/packages/rs-platform-wallet/src/masternode/update_service.rs +++ b/packages/rs-platform-wallet/src/masternode/update_service.rs @@ -741,4 +741,61 @@ mod tests { .verify(&public_key, payload.base_payload_hash().as_byte_array()) .expect("operator BLS signature verifies over base_payload_hash"); } + + /// The fee of a ProUpServTx is funded like any send, so it never spends + /// the masternode's collateral: with a 5 DASH coin beside the 1,000 DASH + /// collateral the fee comes from the 5 DASH coin, and with that coin also + /// locked there is nothing left to pay it with. + #[tokio::test] + async fn should_fund_an_update_service_fee_without_the_masternode_collateral() { + const DASH: u64 = 100_000_000; + let (wallet_manager, wallet_id, generation, signer, _collateral, spare) = + crate::test_support::wallet_manager_with_registered_collateral( + StandardAccountType::BIP44Account, + 5 * DASH, + ) + .await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + wallet_manager, + wallet_id, + Arc::new(RecordingBroadcaster::default()), + generation, + ); + let entry = operator_entry(0x45, false); + + let placeholder = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect("placeholder"); + let prepared = + build_sign_update_service(&core, placeholder, Zeroizing::new(OPERATOR_SECRET), &signer) + .await + .expect("the spare coin pays the fee"); + let inputs: Vec<_> = prepared + .transaction() + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!(inputs, vec![spare], "only the spare coin may pay the fee"); + core.abandon_transaction(&prepared).await; + + { + let mut wm = core.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + assert!(info.core_wallet.lock_outpoint(spare)); + } + let placeholder = prepare_update_service_placeholder(&entry, None, ScriptBuf::new()) + .expect("placeholder"); + let err = + build_sign_update_service(&core, placeholder, Zeroizing::new(OPERATOR_SECRET), &signer) + .await + .expect_err("only the collateral is left to pay the fee"); + assert!( + matches!(err, PlatformWalletError::CorePooledInsufficientFunds { .. }), + "expected a funding shortfall, got {err:?}" + ); + } } diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 63e42594fc6..46660e99dd2 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -122,6 +122,14 @@ pub struct WalletSigner { wallet: Wallet, } +#[cfg(test)] +impl WalletSigner { + /// A signer over `wallet`'s seed. + pub(crate) fn for_wallet(wallet: Wallet) -> Self { + Self { wallet } + } +} + #[async_trait] impl Signer for WalletSigner { type Error = String; @@ -251,6 +259,7 @@ pub async fn funded_wallet_manager_with_outputs( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -259,6 +268,126 @@ pub async fn funded_wallet_manager_with_outputs( (Arc::new(RwLock::new(wm)), wallet_id, generation, signer) } +/// 1,000 DASH in duffs: the collateral of a regular masternode. +#[cfg(test)] +pub(crate) const MASTERNODE_COLLATERAL_DUFFS: u64 = 100_000_000_000; + +/// A masternode registration (ProRegTx) naming `collateral`, with no inputs +/// or outputs of its own and keys that belong to no test wallet: the shape +/// of a registration funded and signed elsewhere, which touches a wallet +/// only through the collateral it names. +#[cfg(test)] +pub(crate) fn masternode_registration(collateral: dashcore::OutPoint) -> Transaction { + use dashcore::blockdata::transaction::special_transaction::provider_registration::{ + ProviderMasternodeType, ProviderRegistrationPayload, + }; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::bls_sig_utils::BLSPublicKey; + use dashcore::hash_types::InputsHash; + use dashcore::{PubkeyHash, ScriptBuf}; + + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::ProviderRegistrationPayloadType( + ProviderRegistrationPayload { + version: ProviderRegistrationPayload::CURRENT_VERSION, + masternode_type: ProviderMasternodeType::Regular, + masternode_mode: 0, + collateral_outpoint: collateral, + service_address: "10.0.0.1:9999".parse().expect("socket address"), + owner_key_hash: PubkeyHash::from_byte_array([0x71; 20]), + operator_public_key: BLSPublicKey::from([0x72; 48]), + voting_key_hash: PubkeyHash::from_byte_array([0x73; 20]), + operator_reward: 0, + script_payout: ScriptBuf::new(), + inputs_hash: InputsHash::all_zeros(), + signature: vec![], + platform_node_id: None, + platform_p2p_port: None, + platform_http_port: None, + }, + )), + } +} + +/// The outpoint of the coin worth `value` in the wallet's `account_type` +/// index-0 account. +#[cfg(test)] +pub(crate) async fn coin_worth( + manager: &RwLock>, + wallet_id: &WalletId, + account_type: StandardAccountType, + value: u64, +) -> dashcore::OutPoint { + let wm = manager.read().await; + let info = wm.get_wallet_info(wallet_id).expect("wallet present"); + let account = match account_type { + StandardAccountType::BIP44Account => info.core_wallet.first_bip44_managed_account(), + StandardAccountType::BIP32Account => info.core_wallet.first_bip32_managed_account(), + } + .expect("funded account"); + account + .utxos + .values() + .find(|utxo| utxo.value() == value) + .map(|utxo| utxo.outpoint) + .expect("a coin of that value") +} + +/// Like [`funded_wallet_manager_with_outputs`], with the account holding a +/// 1,000 DASH masternode collateral and one `spare` coin, and the wallet +/// having processed the ProRegTx that registers the collateral, through the +/// same `check_transaction_in_all_wallets` path block processing takes. The +/// ProRegTx is otherwise irrelevant to the wallet. Returns the manager +/// fixture plus the collateral and spare outpoints. +#[cfg(test)] +pub(crate) async fn wallet_manager_with_registered_collateral( + account_type: StandardAccountType, + spare: u64, +) -> ( + Arc>>, + WalletId, + Arc, + WalletSigner, + dashcore::OutPoint, + dashcore::OutPoint, +) { + let (manager, wallet_id, generation, signer) = + funded_wallet_manager_with_outputs(account_type, &[MASTERNODE_COLLATERAL_DUFFS, spare]) + .await; + let collateral = coin_worth( + &manager, + &wallet_id, + account_type, + MASTERNODE_COLLATERAL_DUFFS, + ) + .await; + let spare = coin_worth(&manager, &wallet_id, account_type, spare).await; + { + let mut wm = manager.write().await; + wm.check_transaction_in_all_wallets( + &masternode_registration(collateral), + TransactionContext::InChainLockedBlock(BlockInfo::new( + 2, + BlockHash::all_zeros(), + 1_700_000_100, + )), + true, + true, + ) + .await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet present"); + assert!( + info.core_wallet.is_outpoint_locked(&collateral), + "processing the registration locks its collateral" + ); + } + (manager, wallet_id, generation, signer, collateral, spare) +} + /// The `WalletEvent` the wallet emits when it first observes `tx` spending /// its outpoints — the real shape the spend-observation seam /// ([`SpendObservationHandler`](crate::wallet::core::SpendObservationHandler)) @@ -380,6 +509,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -483,6 +613,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -560,6 +691,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); @@ -765,6 +897,7 @@ pub(crate) async fn mnemonic_wallet_manager( identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let mut wm = WalletManager::::new(Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 10780172b4a..10bdbdeadf4 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -440,6 +440,7 @@ mod tests { tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), } } 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 951c1e21ce4..9220344b79a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -3682,4 +3682,61 @@ mod tests { "and the row itself is gone, not just reported as removed" ); } + + /// An asset lock from a wallet holding a 1,000 DASH masternode collateral + /// and a 5 DASH coin is funded from the 5 DASH coin: a lock only the + /// collateral could fund is refused, and draining the account locks the + /// spare coin alone. + #[tokio::test] + async fn should_fund_an_asset_lock_without_the_masternode_collateral() { + const DASH: u64 = 100_000_000; + let (wallet_manager, wallet_id, _generation, signer, _collateral, spare) = + crate::test_support::wallet_manager_with_registered_collateral( + StandardAccountType::BIP44Account, + 5 * DASH, + ) + .await; + let (manager, _persistence) = asset_lock_manager_over( + wallet_manager, + wallet_id, + Arc::new(CountingOkBroadcaster::default()), + ); + + let shortfall = manager + .build_asset_lock_transaction( + 6 * DASH, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await; + assert!( + matches!( + shortfall, + Err(PlatformWalletError::AssetLockInsufficientFunds { .. }) + ), + "a 6 DASH lock needs the collateral, got {shortfall:?}" + ); + + let (tx, _path, _token, _accounts) = manager + .build_asset_lock_transaction_with_funding( + super::AssetLockBuildAmount::DrainAll { + minimum_lock_duffs: None, + }, + &[AccountTypePreference::BIP44], + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("draining the account locks the spare coin"); + let inputs: Vec = tx.input.iter().map(|input| input.previous_output).collect(); + assert_eq!( + inputs, + vec![spare], + "the drain must leave the collateral alone" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index a14417dba53..c7262e845c9 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -664,6 +664,7 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; assert_eq!( funding_account_index(&info, &txid), 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 e4fc8302234..5192f427d8f 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 @@ -2524,6 +2524,7 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), }; let out_point = OutPoint::new(tx.txid(), 0); let lock = TrackedAssetLock { diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index de4bdb692c0..c92cd1aac76 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -2007,4 +2007,205 @@ mod tests { Err(PlatformWalletError::CoreInsufficientFunds { .. }) )); } + + const DASH: u64 = 100_000_000; + const SPARE: u64 = 5 * DASH; + + /// A BIP44 core wallet holding a 1,000 DASH masternode collateral, locked + /// by the registration the wallet processed, and one 5 DASH coin. + async fn collateral_core() -> ( + CoreWallet, + WalletSigner, + dashcore::OutPoint, + dashcore::OutPoint, + ) { + let (manager, wallet_id, generation, signer, collateral, spare) = + crate::test_support::wallet_manager_with_registered_collateral( + StandardAccountType::BIP44Account, + SPARE, + ) + .await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + ( + CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ), + signer, + collateral, + spare, + ) + } + + fn largest_first_send(amount: u64) -> TransactionBuilder { + TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::LargestFirst) + .add_output(&DashAddress::dummy(Network::Testnet, 90), amount) + } + + /// Sending 1 DASH from a wallet holding a 1,000 DASH collateral and a + /// 5 DASH coin spends the 5 DASH coin. Largest-first would reach for the + /// collateral first, and the drain would sweep it, if the lock did not + /// keep it out of selection. + #[tokio::test] + async fn should_send_from_the_spare_coin_and_never_the_masternode_collateral() { + let (core, signer, _collateral, spare) = collateral_core().await; + + let finalized = core + .finalize_transaction( + largest_first_send(DASH), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("the spare coin covers 1 DASH"); + let inputs: Vec<_> = finalized + .transaction() + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!(inputs, vec![spare], "only the spare coin may be spent"); + core.abandon_transaction(&finalized).await; + + let drain = core + .finalize_transaction( + TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::All) + .add_output(&DashAddress::dummy(Network::Testnet, 91), 0), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("the drain spends the spare coin"); + let inputs: Vec<_> = drain + .transaction() + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!( + inputs, + vec![spare], + "a drain must leave the collateral alone" + ); + core.abandon_transaction(&drain).await; + } + + /// A payment only the collateral could cover is refused rather than + /// funded from it. + #[tokio::test] + async fn should_refuse_a_send_only_the_masternode_collateral_could_cover() { + let (core, signer, _collateral, _spare) = collateral_core().await; + let err = core + .finalize_transaction( + largest_first_send(6 * DASH), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect_err("6 DASH needs the collateral"); + assert!( + matches!(err, PlatformWalletError::CorePooledInsufficientFunds { .. }), + "expected a funding shortfall, got {err:?}" + ); + } + + /// The spendable pool and the max-sendable figure count the spare coin + /// only, so a host offering "send max" never offers the collateral. + #[tokio::test] + async fn should_leave_the_masternode_collateral_out_of_max_sendable() { + let (core, _signer, _collateral, _spare) = collateral_core().await; + let sources = [AccountTypePreference::BIP44]; + assert_eq!( + core.pooled_spendable_balance(&sources[..], 0) + .await + .expect("pooled balance"), + SPARE + ); + let fee = FeeRate::normal().calculate_fee(estimate_tx_size(1, 1, false)); + assert_eq!( + core.pooled_max_sendable(&sources[..], 0, None) + .await + .expect("max sendable"), + SPARE - fee, + "max sendable is the spare coin less the fee of spending it alone" + ); + } + + /// A caller-seeded copy of the collateral is dropped, not spent: a + /// reservation-only build seeded with nothing but the collateral has + /// nothing to fund from. + #[tokio::test] + async fn should_not_spend_a_seeded_masternode_collateral() { + let (core, signer, collateral, _spare) = collateral_core().await; + let seeded = { + let wm = core.wallet_manager.read().await; + let info = wm + .get_wallet_info(&core.wallet_id()) + .expect("wallet present in manager"); + info.core_wallet + .first_bip44_managed_account() + .expect("bip44 managed account") + .utxos + .get(&collateral) + .cloned() + .expect("the wallet holds its collateral") + }; + let err = core + .finalize_transaction_with_options( + largest_first_send(DASH).add_inputs([seeded]), + &[AccountTypePreference::BIP44], + 0, + &signer, + true, + ) + .await + .expect_err("the seeded collateral must not fund the send"); + assert!( + matches!(err, PlatformWalletError::CoreInsufficientFunds { .. }), + "expected a funding shortfall, got {err:?}" + ); + } + + /// Unlocking the collateral hands it back to coin selection: + /// largest-first then spends it. + #[tokio::test] + async fn should_spend_the_masternode_collateral_after_an_explicit_unlock() { + let (core, signer, collateral, _spare) = collateral_core().await; + { + let mut wm = core.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&core.wallet_id()) + .expect("wallet present in manager"); + assert!(info.core_wallet.unlock_outpoint(&collateral)); + } + let finalized = core + .finalize_transaction( + largest_first_send(DASH), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("the unlocked collateral funds the send"); + let inputs: Vec<_> = finalized + .transaction() + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!( + inputs, + vec![collateral], + "largest-first takes the collateral" + ); + core.abandon_transaction(&finalized).await; + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 50d41630dd8..ef28afcfd8f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4179,6 +4179,7 @@ mod sweep_tests { tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: BTreeMap::new(), + pending_outpoint_locks: Default::default(), } } 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 ad136c8ae71..ee15a78a110 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -7649,4 +7649,112 @@ mod tests { type-3 tx at 1 duff/byte when change is emitted" ); } + + /// Broadcaster stub that accepts every transaction and keeps a copy, so a + /// send-path test can read the inputs the build selected. + struct RecordingBroadcaster { + sent: Mutex>, + } + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for RecordingBroadcaster { + async fn broadcast( + &self, + transaction: &dashcore::Transaction, + ) -> Result { + self.sent + .lock() + .expect("recorded transactions") + .push(transaction.clone()); + Ok(transaction.txid()) + } + } + + /// A DashPay payment of 1 DASH from a wallet holding a 1,000 DASH + /// masternode collateral and a 5 DASH coin spends the 5 DASH coin, and a + /// payment only the collateral could cover is refused. The payment path + /// selects largest-first, so without the lock the collateral would be the + /// first coin it takes. + #[tokio::test] + async fn should_pay_a_contact_from_the_spare_coin_and_never_the_masternode_collateral() { + use crate::test_support::{masternode_registration, MASTERNODE_COLLATERAL_DUFFS}; + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + const DASH: u64 = 100_000_000; + let (manager, _persister, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; + fund_bip44_account_0(&manager, wallet_id, 0xC0, MASTERNODE_COLLATERAL_DUFFS).await; + fund_bip44_account_0(&manager, wallet_id, 0xC1, 5 * DASH).await; + let collateral = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0xC0; 32]), + vout: 0, + }; + let spare = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0xC1; 32]), + vout: 0, + }; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let mut wm = wallet.identity().wallet_manager.write().await; + wm.check_transaction_in_all_wallets( + &masternode_registration(collateral), + TransactionContext::InChainLockedBlock(BlockInfo::new( + 101, + dashcore::BlockHash::all_zeros(), + 1_700_000_000, + )), + true, + true, + ) + .await; + } + + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("valid mnemonic") + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + let broadcaster = Arc::new(RecordingBroadcaster { + sent: Mutex::new(Vec::new()), + }); + let real = wallet.identity(); + let iw = crate::wallet::identity::IdentityWallet { + sdk: Arc::clone(&real.sdk), + wallet_manager: Arc::clone(&real.wallet_manager), + wallet_id: real.wallet_id, + asset_locks: Arc::clone(&real.asset_locks), + persister: real.persister.clone(), + broadcaster: Arc::clone(&broadcaster), + sdk_writer: Arc::clone(&real.sdk_writer), + dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), + dpns_sync_progress: Arc::clone(&real.dpns_sync_progress), + }; + + let err = iw + .dashpay() + .send_payment(&owner_id, &contact_id, 6 * DASH, None, &signer, &provider) + .await + .expect_err("6 DASH needs the collateral"); + assert!( + matches!(&err, PlatformWalletError::TransactionBuild(reason) + if reason.to_lowercase().contains("insufficient")), + "expected a funding shortfall, got {err:?}" + ); + + iw.dashpay() + .send_payment(&owner_id, &contact_id, DASH, None, &signer, &provider) + .await + .expect("the spare coin covers 1 DASH"); + let sent = broadcaster.sent.lock().expect("recorded transactions"); + assert_eq!(sent.len(), 1, "one payment was broadcast"); + let inputs: Vec<_> = sent[0] + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!(inputs, vec![spare], "only the spare coin may be spent"); + } } diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 29ce8338a4f..63a6b0ab2bf 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -4,6 +4,7 @@ pub mod core; pub mod core_address_key; pub mod identity; pub mod masternode_withdrawal; +pub(crate) mod outpoint_locks; pub mod persister; pub mod platform_addresses; pub mod platform_wallet; diff --git a/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs b/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs new file mode 100644 index 00000000000..c5d167ab809 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs @@ -0,0 +1,675 @@ +//! Outpoints a wallet keeps out of coin selection, and their persistence. +//! +//! The lock set lives on `key_wallet`'s `ManagedWalletInfo` (see +//! [`ManagedWalletInfo::locked_outpoints`]): a masternode registration +//! (ProRegTx) the wallet processes locks its collateral, because spending the +//! collateral would end the registration, and coin selection, the asset-lock +//! builder and special-transaction funding all skip a locked coin. A +//! collateral the ProRegTx names is locked once the ProRegTx is in a block; a +//! collateral it creates, as its own output, from any sighting. This module +//! carries that set to the persister and exposes explicit lock and unlock. +//! +//! Locks reach the persister through [`CoreChangeSet::outpoint_locks`] on two +//! paths: +//! +//! - A transaction check. `check_core_transaction` queues the outpoints it +//! locked on [`PlatformWalletInfo`], since no `WalletEvent` carries them, and +//! the wallet-event adapter drains the queue into the next changeset it +//! stores for the wallet. The `SyncHeightAdvanced` watermark that certifies +//! a block is a later event for the same wallet, so a lock made in a block +//! is stored with that watermark or before it. +//! - [`PlatformWallet::lock_outpoint`] and [`PlatformWallet::unlock_outpoint`], +//! which store the change themselves. +//! +//! On load each backend hands every stored lock back through +//! [`ManagedWalletInfo::lock_outpoint`]. +//! +//! # Masternodes the wallet already knows +//! +//! A ProRegTx locks its collateral only when a transaction check sees it (in a +//! block, for a collateral it names). +//! Two kinds of registration can escape that: one restored into the +//! transaction history without a check (the mobile restore stages provider +//! transactions directly, and a wallet from before locks were kept has no +//! stored locks), and one the wallet never processed at all, such as a +//! registration funded and signed elsewhere for a collateral this wallet +//! holds. The first is in the wallet's transaction history; the second is +//! known only when the user tracks the masternode and its registration has +//! been fetched. [`lock_known_masternode_collaterals`] locks both kinds in +//! every wallet of a manager, on load, when a wallet is registered, and when +//! a tracked masternode's registration becomes known. +//! +//! [`ManagedWalletInfo::locked_outpoints`]: key_wallet::wallet::ManagedWalletInfo::locked_outpoints +//! [`ManagedWalletInfo::lock_outpoint`]: key_wallet::wallet::ManagedWalletInfo::lock_outpoint + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::blockdata::transaction::special_transaction::TransactionPayload; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, Transaction, Txid}; +use key_wallet_manager::WalletManager; +use tokio::sync::RwLock; + +use crate::changeset::{CoreChangeSet, PlatformWalletChangeSet}; +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::{PlatformWallet, PlatformWalletInfo}; + +/// The collateral `tx` registers when it is a ProRegTx: the outpoint its +/// payload names, or, when that names a null txid, the ProRegTx's own output +/// at that index. `None` for any other transaction, and for an own-output +/// index past the outputs (such a registration names no coin). +pub(crate) fn registration_collateral(tx: &Transaction) -> Option { + let Some(TransactionPayload::ProviderRegistrationPayloadType(registration)) = + &tx.special_transaction_payload + else { + return None; + }; + let named = registration.collateral_outpoint; + if named.txid != Txid::all_zeros() { + return Some(named); + } + ((named.vout as usize) < tx.output.len()).then(|| OutPoint::new(tx.txid(), named.vout)) +} + +/// The collateral of a registration known by its proTxHash and the outpoint +/// its payload names, both as wire-order bytes, the way the tracked registry +/// keeps them. A null txid names the ProRegTx's own output, and the ProRegTx's +/// txid is the proTxHash. +pub(crate) fn named_collateral(pro_tx_hash: &[u8; 32], (txid, vout): ([u8; 32], u32)) -> OutPoint { + let txid = if txid == [0u8; 32] { + *pro_tx_hash + } else { + txid + }; + OutPoint::new(Txid::from_byte_array(txid), vout) +} + +/// Lock, in every wallet `wallet_manager` holds, the collateral of each +/// masternode registration in that wallet's transaction history (see +/// [`PlatformWalletInfo::lock_known_masternode_collaterals`] for which) and +/// every outpoint in `tracked`, and queue the new locks for persistence. +/// Returns how many locks were added across all wallets. +/// +/// A tracked collateral is locked in every wallet, whether or not the wallet +/// holds the coin yet: an entry needs no coin behind it, so a collateral +/// that reaches a wallet later (a restore still syncing) arrives locked. +pub(crate) async fn lock_known_masternode_collaterals( + wallet_manager: &RwLock>, + tracked: &BTreeSet, +) -> usize { + let mut wm = wallet_manager.write().await; + let wallet_ids: Vec<_> = wm.list_wallets().into_iter().copied().collect(); + let mut added = 0; + for wallet_id in &wallet_ids { + if let Some(info) = wm.get_wallet_info_mut(wallet_id) { + added += info.lock_known_masternode_collaterals(tracked); + } + } + added +} + +impl PlatformWalletInfo { + /// Lock the collateral of the masternode registrations in this wallet's + /// transaction history, and every outpoint in `tracked`. The new locks + /// are queued for persistence. Returns how many were added. + /// + /// The same rule as a transaction check: a collateral a registration + /// names is locked only when its record is in a block, since an + /// unconfirmed ProRegTx is not a registration yet; a collateral it + /// creates, as its own output, from any record. The check that confirms + /// an unconfirmed registration locks the collateral it names. + pub(crate) fn lock_known_masternode_collaterals( + &mut self, + tracked: &BTreeSet, + ) -> usize { + let mut collaterals: BTreeSet = self + .core_wallet + .accounts + .all_accounts() + .iter() + .flat_map(|account| account.transactions().values()) + .filter_map(|record| { + let collateral = registration_collateral(&record.transaction)?; + let creates_it = collateral.txid == record.txid; + (creates_it || record.is_confirmed()).then_some(collateral) + }) + .collect(); + collaterals.extend(tracked.iter().copied()); + let added: Vec = collaterals + .into_iter() + .filter(|outpoint| self.core_wallet.lock_outpoint(*outpoint)) + .collect(); + if !added.is_empty() { + self.publish_core_balance(); + } + let count = added.len(); + self.queue_outpoint_locks(added); + count + } + + /// Queue locks a transaction check made for the wallet-event adapter to + /// persist. + pub(crate) fn queue_outpoint_locks(&self, outpoints: impl IntoIterator) { + let mut outpoints = outpoints.into_iter().peekable(); + if outpoints.peek().is_none() { + return; + } + self.pending_outpoint_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .extend(outpoints); + } + + /// Take every queued lock, leaving the queue empty. + pub(crate) fn take_queued_outpoint_locks(&self) -> BTreeSet { + std::mem::take( + &mut *self + .pending_outpoint_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ) + } + + /// Drop a queued lock for `outpoint`: an explicit lock or unlock stores + /// the outpoint's state itself, and a queued lock stored after it would + /// overwrite an unlock. + fn forget_queued_outpoint_lock(&self, outpoint: &OutPoint) { + self.pending_outpoint_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(outpoint); + } + + /// Mirror the core balance into the lock-free snapshot the UI reads. + fn publish_core_balance(&self) { + let balance = &self.core_wallet.balance; + self.generation.set( + balance.confirmed(), + balance.unconfirmed(), + balance.immature(), + balance.locked(), + ); + } +} + +impl PlatformWallet { + /// The outpoints this wallet keeps out of coin selection, in outpoint + /// order: the collateral of the masternode registrations the wallet has + /// processed (see the module docs for when), and every outpoint locked + /// with [`Self::lock_outpoint`]. + /// + /// An entry does not need a coin behind it: a collateral whose ProRegTx + /// arrived first, or an outpoint locked before its coin, is listed and + /// the coin arrives locked. + pub async fn locked_outpoints(&self) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + Ok(info + .core_wallet + .locked_outpoints() + .iter() + .copied() + .collect()) + } + + /// Lock `outpoint`, so no send, asset lock or special-transaction fee + /// spends it until [`Self::unlock_outpoint`]. + /// + /// The wallet does not need to hold the coin yet. When it does, the + /// coin's value moves from the spendable balance to the locked one. + /// Returns `true` when the outpoint was not locked before. + /// + /// The lock is persisted before this returns. The state is stored even + /// when nothing changed, so calling again repeats a store that failed. + /// + /// # Errors + /// + /// [`PlatformWalletError::WalletNotFound`] when the wallet is no longer + /// registered, or the store's error when persisting fails. After a store + /// failure the wallet still holds the lock in memory, but it does not + /// survive a restart. + pub async fn lock_outpoint(&self, outpoint: OutPoint) -> Result { + self.set_outpoint_lock(outpoint, true).await + } + + /// Unlock `outpoint`, so coin selection may spend it again. + /// + /// Unlocking a masternode collateral lets a send spend it, and spending + /// it ends the masternode registration. The wallet never unlocks one on + /// its own. Processing the registration again (a rescan) locks it again. + /// Returns `true` when the outpoint was locked. + /// + /// Persisted before this returns, like [`Self::lock_outpoint`]. + /// + /// # Errors + /// + /// As [`Self::lock_outpoint`]. After a store failure the outpoint is + /// unlocked in memory but comes back locked after a restart. + pub async fn unlock_outpoint(&self, outpoint: OutPoint) -> Result { + self.set_outpoint_lock(outpoint, false).await + } + + async fn set_outpoint_lock( + &self, + outpoint: OutPoint, + locked: bool, + ) -> Result { + // The write lock is held through the store, so a transaction check + // cannot queue a lock for this outpoint between the change and its + // persistence. + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id()) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id())))?; + let changed = if locked { + info.core_wallet.lock_outpoint(outpoint) + } else { + info.core_wallet.unlock_outpoint(&outpoint) + }; + info.forget_queued_outpoint_lock(&outpoint); + if changed { + info.publish_core_balance(); + } + + let persister = self.persister(); + let changeset = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + outpoint_locks: BTreeMap::from([(outpoint, locked)]), + ..CoreChangeSet::default() + }), + ..PlatformWalletChangeSet::default() + }; + persister + .store(changeset) + .map_err(|e| persister.classify_store_failure(e))?; + if !persister.store_commits_inline() { + persister + .flush() + .map_err(|e| PlatformWalletError::Persistence(e.to_string()))?; + } + Ok(changed) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use dashcore::{BlockHash, Network, TxOut}; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; + use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; + use key_wallet::wallet::Wallet; + + use super::*; + use crate::changeset::{ClientStartState, PersistenceError, PlatformWalletPersistence}; + use crate::test_support::{ + funded_wallet_manager_with_outputs, masternode_registration, NoopTestEventHandler, + WalletSigner, MASTERNODE_COLLATERAL_DUFFS, + }; + use crate::wallet::platform_wallet::WalletId; + + const DASH: u64 = 100_000_000; + + /// Records the outpoint locks of every changeset it stores. + #[derive(Default)] + struct LockRecordingPersister { + stored: Mutex>>, + } + + impl PlatformWalletPersistence for LockRecordingPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(core) = changeset.core { + if !core.outpoint_locks.is_empty() { + self.stored + .lock() + .expect("stored locks") + .push(core.outpoint_locks); + } + } + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + fn block(height: u32) -> TransactionContext { + TransactionContext::InChainLockedBlock(BlockInfo::new( + height, + BlockHash::all_zeros(), + 1_700_000_000 + height, + )) + } + + #[test] + fn should_name_the_registrations_own_output_for_a_null_collateral_txid() { + let pro_tx_hash = [0x5A; 32]; + assert_eq!( + named_collateral(&pro_tx_hash, ([0u8; 32], 1)), + OutPoint::new(Txid::from_byte_array(pro_tx_hash), 1) + ); + assert_eq!( + named_collateral(&pro_tx_hash, ([0x11; 32], 3)), + OutPoint::new(Txid::from_byte_array([0x11; 32]), 3) + ); + } + + /// Unlocking through the wallet persists the unlock and hands the + /// collateral back to coin selection; locking again persists the lock + /// and takes it back out. + #[tokio::test] + async fn should_persist_explicit_unlock_and_lock_and_spend_the_collateral_only_while_unlocked() + { + let persister = Arc::new(LockRecordingPersister::default()); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let manager = Arc::new(crate::PlatformWalletManager::new( + sdk, + Arc::clone(&persister), + Arc::new(NoopTestEventHandler) as _, + )); + let seed = [0x3C; 64]; + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create wallet"); + let signer = WalletSigner::for_wallet( + Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("seed wallet"), + ); + + let address = wallet + .core() + .next_receive_address_for_account(0) + .await + .expect("receive address"); + let funding = Transaction::dummy(&address, 0..1, &[MASTERNODE_COLLATERAL_DUFFS, 5 * DASH]); + let collateral = OutPoint::new(funding.txid(), 0); + { + let mut wm = wallet.wallet_manager().write().await; + wm.check_transaction_in_all_wallets(&funding, block(1), true, true) + .await; + wm.check_transaction_in_all_wallets( + &masternode_registration(collateral), + block(2), + true, + true, + ) + .await; + } + assert_eq!( + wallet.locked_outpoints().await.expect("locks"), + vec![collateral] + ); + + assert!(wallet.unlock_outpoint(collateral).await.expect("unlock")); + assert!(wallet.locked_outpoints().await.expect("locks").is_empty()); + assert_eq!( + persister.stored.lock().expect("stored locks").last(), + Some(&BTreeMap::from([(collateral, false)])), + "the unlock is persisted" + ); + { + let wm = wallet.wallet_manager().read().await; + let info = wm.get_wallet_info(&wallet.wallet_id()).expect("wallet"); + assert!( + info.take_queued_outpoint_locks().is_empty(), + "the unlock drops the lock the registration queued, so the adapter cannot \ + store it over the unlock" + ); + } + assert_eq!( + wallet.balance().locked(), + 0, + "the UI balance follows the unlock" + ); + + let finalized = wallet + .core() + .finalize_transaction( + TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::LargestFirst) + .add_output(&dashcore::Address::dummy(Network::Testnet, 92), DASH), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect("the unlocked collateral funds the send"); + let inputs: Vec = finalized + .transaction() + .input + .iter() + .map(|input| input.previous_output) + .collect(); + assert_eq!( + inputs, + vec![collateral], + "largest-first takes the unlocked collateral" + ); + wallet.core().abandon_transaction(&finalized).await; + + assert!(wallet.lock_outpoint(collateral).await.expect("lock")); + assert!( + !wallet.lock_outpoint(collateral).await.expect("lock again"), + "a second lock changes nothing" + ); + assert_eq!( + persister.stored.lock().expect("stored locks").last(), + Some(&BTreeMap::from([(collateral, true)])), + "the lock is persisted" + ); + assert_eq!(wallet.balance().locked(), MASTERNODE_COLLATERAL_DUFFS); + let err = wallet + .core() + .finalize_transaction( + TransactionBuilder::new() + .set_selection_strategy(SelectionStrategy::LargestFirst) + .add_output(&dashcore::Address::dummy(Network::Testnet, 93), 6 * DASH), + &crate::SEND_FUNDING_SOURCES, + 0, + &signer, + ) + .await + .expect_err("locked again, the collateral cannot fund 6 DASH"); + assert!( + matches!(err, PlatformWalletError::CorePooledInsufficientFunds { .. }), + "expected a funding shortfall, got {err:?}" + ); + } + + /// A wallet holding a 1,000 DASH collateral and 5 DASH, with a ProRegTx + /// naming the collateral restored into its history in `context` without a + /// transaction check (the mobile restore stages provider transactions + /// directly). Returns the manager, the wallet and the collateral. + async fn wallet_with_restored_registration( + context: TransactionContext, + ) -> ( + Arc>>, + WalletId, + OutPoint, + ) { + let (manager, wallet_id, _generation, _signer) = funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[MASTERNODE_COLLATERAL_DUFFS, 5 * DASH], + ) + .await; + let collateral = crate::test_support::coin_worth( + &manager, + &wallet_id, + StandardAccountType::BIP44Account, + MASTERNODE_COLLATERAL_DUFFS, + ) + .await; + let registration = masternode_registration(collateral); + { + let mut wm = manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account"); + let record = TransactionRecord::new( + registration.clone(), + account.managed_account_type().to_account_type(), + context, + TransactionType::ProviderRegistration, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + account + .transactions_mut() + .insert(registration.txid(), record); + assert!(!info.core_wallet.is_outpoint_locked(&collateral)); + } + (manager, wallet_id, collateral) + } + + /// A registration restored into the history without a transaction check + /// has its collateral locked by the known-masternode pass, once. + #[tokio::test] + async fn should_lock_the_collateral_of_a_registration_restored_into_the_history() { + let (manager, wallet_id, collateral) = wallet_with_restored_registration(block(2)).await; + + assert_eq!( + lock_known_masternode_collaterals(&manager, &BTreeSet::new()).await, + 1 + ); + assert_eq!( + lock_known_masternode_collaterals(&manager, &BTreeSet::new()).await, + 0, + "a lock is added once" + ); + let wm = manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + assert!(info.core_wallet.is_outpoint_locked(&collateral)); + assert_eq!( + info.core_wallet.balance.locked(), + MASTERNODE_COLLATERAL_DUFFS + ); + assert_eq!(info.core_wallet.balance.spendable(), 5 * DASH); + assert_eq!( + info.take_queued_outpoint_locks(), + BTreeSet::from([collateral]), + "the new lock is queued for persistence" + ); + } + + /// An unconfirmed ProRegTx in the history is not a registration yet: the + /// known-masternode pass leaves the collateral it names spendable, as a + /// transaction check does, and the block holding it locks the collateral. + #[tokio::test] + async fn should_lock_a_named_collateral_of_a_restored_registration_only_once_it_is_mined() { + let (manager, wallet_id, collateral) = + wallet_with_restored_registration(TransactionContext::Mempool).await; + + assert_eq!( + lock_known_masternode_collaterals(&manager, &BTreeSet::new()).await, + 0 + ); + { + let wm = manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + assert!(!info.core_wallet.is_outpoint_locked(&collateral)); + assert_eq!( + info.core_wallet.balance.spendable(), + MASTERNODE_COLLATERAL_DUFFS + 5 * DASH + ); + assert!(info.take_queued_outpoint_locks().is_empty()); + } + + let mut wm = manager.write().await; + wm.check_transaction_in_all_wallets( + &masternode_registration(collateral), + block(3), + true, + true, + ) + .await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + assert!(info.core_wallet.is_outpoint_locked(&collateral)); + assert_eq!( + info.core_wallet.balance.locked(), + MASTERNODE_COLLATERAL_DUFFS + ); + assert_eq!( + info.take_queued_outpoint_locks(), + BTreeSet::from([collateral]), + "the block's lock is queued for persistence" + ); + } + + /// A tracked masternode's collateral is locked in a wallet that does not + /// hold the coin yet, so the coin arrives locked when it syncs. + #[tokio::test] + async fn should_lock_a_tracked_collateral_before_its_coin_arrives() { + let (manager, wallet_id, _generation, _signer) = + funded_wallet_manager_with_outputs(StandardAccountType::BIP44Account, &[5 * DASH]) + .await; + let address = { + let wm = manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + info.core_wallet + .first_bip44_managed_account() + .expect("bip44 account") + .utxos + .values() + .next() + .expect("funded coin") + .address + .clone() + }; + let incoming = Transaction { + output: vec![TxOut { + value: MASTERNODE_COLLATERAL_DUFFS, + script_pubkey: address.script_pubkey(), + }], + ..Transaction::dummy(&address, 7..8, &[1]) + }; + let collateral = OutPoint::new(incoming.txid(), 0); + + assert_eq!( + lock_known_masternode_collaterals(&manager, &BTreeSet::from([collateral])).await, + 1 + ); + let mut wm = manager.write().await; + wm.check_transaction_in_all_wallets(&incoming, block(3), true, true) + .await; + let info = wm.get_wallet_info(&wallet_id).expect("wallet"); + assert_eq!( + info.core_wallet.balance.locked(), + MASTERNODE_COLLATERAL_DUFFS + ); + assert_eq!(info.core_wallet.balance.spendable(), 5 * DASH); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 7cbe111776d..aacabc8f26b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1,6 +1,6 @@ //! The main PlatformWallet struct combining core, identity (+DashPay), and platform sub-wallets. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ops::{Deref, DerefMut}; use std::sync::Arc; @@ -296,6 +296,17 @@ pub struct PlatformWalletInfo { /// host-side persister mirror fed by /// [`DpnsNameStateChangeSet`](crate::changeset::DpnsNameStateChangeSet). pub dpns_name_states: BTreeMap, + /// Outpoints `core_wallet` locked during a transaction check that are not + /// persisted yet. + /// + /// A ProRegTx the wallet processes locks its collateral, relevant or not + /// (a collateral it names once it is in a block), and no `WalletEvent` + /// carries that lock. The check queues it here + /// and the wallet-event adapter drains the queue into the next changeset + /// it stores for this wallet. Interior mutability because the adapter + /// projects under the manager's read lock; a poisoned mutex keeps its + /// contents. + pub(crate) pending_outpoint_locks: std::sync::Mutex>, } /// A platform wallet that combines core UTXO functionality with identity management. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index 02d052359de..669e12648f2 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -42,6 +42,7 @@ impl WalletInfoInterface for PlatformWalletInfo { tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), + pending_outpoint_locks: Default::default(), } } @@ -56,6 +57,7 @@ impl WalletInfoInterface for PlatformWalletInfo { tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), + pending_outpoint_locks: Default::default(), } } @@ -218,10 +220,15 @@ impl WalletTransactionChecker for PlatformWalletInfo { update_state: bool, update_balance: bool, ) -> TransactionCheckResult { - // TODO: some logic must here - restore - self.core_wallet + let result = self + .core_wallet .check_core_transaction(tx, context, wallet, update_state, update_balance) - .await + .await; + // A ProRegTx locks its collateral, relevant or not, and no + // `WalletEvent` carries the lock: queue it for the wallet-event + // adapter to persist with this wallet's next changeset. + self.queue_outpoint_locks(result.locked_outpoints.iter().copied()); + result } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..29a68d33ce6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -94,7 +94,8 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentLockedOutpoint.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentLockedOutpoint.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentLockedOutpoint.swift new file mode 100644 index 00000000000..768a69c1b92 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentLockedOutpoint.swift @@ -0,0 +1,25 @@ +import Foundation +import SwiftData + +/// An outpoint a wallet keeps out of coin selection: the collateral of a +/// masternode registration the wallet processed (spending it would end the +/// registration), or an outpoint locked by hand. The row is the lock, and +/// deleting it unlocks the outpoint. +/// +/// A model of its own rather than a flag on `PersistentTxo`: a lock can +/// exist before its coin does and outlives the coin's spend. +@Model +public final class PersistentLockedOutpoint { + #Unique([\.networkRaw, \.walletId, \.outpoint]) + public var networkRaw: UInt32 + public var walletId: Data + /// 36 bytes, as `PersistentTxo.outpoint`: the txid's raw bytes, then the + /// vout little-endian. + public var outpoint: Data + + public init(networkRaw: UInt32, walletId: Data, outpoint: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.outpoint = outpoint + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 917519ee3cd..ba1ade8a923 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -88,6 +88,79 @@ public final class ManagedPlatformWallet: @unchecked Sendable { ) } + // MARK: - Locked outpoints + + /// A transaction output: the txid's raw 32 bytes (the order the FFI + /// carries, as `PersistentTxo.outpoint`) and the output index. + public struct Outpoint: Hashable, Sendable { + public let txid: Data + public let vout: UInt32 + + public init(txid: Data, vout: UInt32) { + self.txid = txid + self.vout = vout + } + } + + /// The outpoints this wallet keeps out of coin selection: the collateral + /// of the masternode registrations the wallet has seen in a block (or + /// that created their collateral), and every outpoint locked with + /// `lockOutpoint`. An entry may name a coin the + /// wallet does not hold yet; the coin arrives locked. + public func lockedOutpoints() throws -> [Outpoint] { + var entries: UnsafePointer? + var count: UInt = 0 + let result = platform_wallet_locked_outpoints(handle, &entries, &count) + try result.check() + guard let entries, count > 0 else { return [] } + defer { + platform_wallet_locked_outpoints_free(UnsafeMutablePointer(mutating: entries), count) + } + return (0.. Bool { + try await setOutpointLock(outpoint, locked: true) + } + + /// Unlock `outpoint`, so coin selection may spend it again. Unlocking a + /// masternode collateral lets a send spend it, and spending it ends the + /// masternode registration. Returns `true` when it was locked. The + /// unlock is persisted before this returns. + @discardableResult + public func unlockOutpoint(_ outpoint: Outpoint) async throws -> Bool { + try await setOutpointLock(outpoint, locked: false) + } + + private func setOutpointLock(_ outpoint: Outpoint, locked: Bool) async throws -> Bool { + guard outpoint.txid.count == 32 else { + throw PlatformWalletError.invalidParameter("txid must be 32 bytes") + } + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + let ffiOutpoint = entry + return try await Task.detached(priority: .userInitiated) { [self] in + try withExtendedLifetime(self) { + var input = ffiOutpoint + var changed = false + let result = locked + ? platform_wallet_lock_outpoint(handle, &input, &changed) + : platform_wallet_unlock_outpoint(handle, &input, &changed) + try result.check() + return changed + } + }.value + } + // MARK: - Sub-wallet access /// Get the platform address wallet for BLAST sync, transfers, and withdrawals. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index b5551dae598..966e3fbad4d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -107,6 +107,12 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Rust only honours the declaration when the payments callback is /// actually wired. public static let dashpayPayments: UInt64 = 1 << 12 + /// Locked outpoints (masternode collateral, and outpoints locked by + /// hand) are persisted and restored across restarts, through the + /// persistence extension's outpoint-lock persist / load / free slots. + /// Mirrors `PersistenceCapabilities::OUTPOINT_LOCKS`; Rust only honours + /// the declaration when all three slots are wired. + public static let outpointLocks: UInt64 = 1 << 13 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d9ffdea6697..660e9504cb2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3160,6 +3160,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.outpointLocks ) } @@ -3197,6 +3198,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { persistWalletChangesetUtxoVerdictsCallback extensionCallbacks.on_persist_identity_balance_block_time_fn = persistIdentityBalanceBlockTimeCallback extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback + // Locked outpoints (masternode collateral, and outpoints locked by + // hand) persist through their own rows and come back per wallet on + // load; a lock needs no coin behind it, so it has no place on the + // frozen changeset struct's TXO rows. + extensionCallbacks.on_persist_wallet_changeset_outpoint_locks_fn = + persistWalletChangesetOutpointLocksCallback + extensionCallbacks.on_load_wallet_locked_outpoints_fn = loadWalletLockedOutpointsCallback + extensionCallbacks.on_load_wallet_locked_outpoints_free_fn = + loadWalletLockedOutpointsFreeCallback return extensionCallbacks } @@ -4962,6 +4972,118 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Locked outpoints (additive persistence extension) + + /// The lock rows of `walletId`. Keyed by network like the balance + /// metadata sidecar, resolved the same way. + private func lockedOutpointsDescriptor(walletId: Data) + -> FetchDescriptor { + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet + let networkRaw = network.rawValue + return FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId + }) + } + + /// Apply a round's outpoint lock changes: insert a row for each lock, + /// delete the row of each unlock. A lock needs no coin behind it, so it + /// never touches `PersistentTxo`. Fired by Rust inside the begin/end + /// bracket, only on rounds that change a lock. Returns `false` to fail + /// the round: a lock dropped here would hand a masternode collateral back + /// to coin selection after the next restart. + func persistWalletChangesetOutpointLocks( + walletId: Data, + locks: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_outpoint_locks") { + case .failed: return false + case .absent: return true + case .found: break + } + guard count > 0, let locks else { return true } + do { + var stored: [Data: PersistentLockedOutpoint] = [:] + for row in try backgroundContext.fetch(lockedOutpointsDescriptor(walletId: walletId)) { + stored[row.outpoint] = row + } + let network = self.network ?? walletNetwork(walletId: walletId) ?? .testnet + for i in 0.. (entries: UnsafePointer?, count: Int, errored: Bool) { + onQueue { + let rows: [PersistentLockedOutpoint] + do { + rows = try backgroundContext.fetch(lockedOutpointsDescriptor(walletId: walletId)) + } catch { + SDKLogger.event( + "persistence_outpoint_locks_load_failed", + category: .persistence, + severity: .error, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + return (nil, 0, true) + } + // A key that is not 36 bytes names no outpoint. + let keys = rows.map(\.outpoint).filter { $0.count == 36 } + guard !keys.isEmpty else { return (nil, 0, false) } + let buf = UnsafeMutablePointer.allocate(capacity: keys.count) + for (i, key) in keys.enumerated() { + var entry = OutPointFFI() + copyBytes(key.prefix(32), into: &entry.txid) + var vout: UInt32 = 0 + _ = withUnsafeMutableBytes(of: &vout) { key.suffix(4).copyBytes(to: $0) } + entry.vout = UInt32(littleEndian: vout) + (buf + i).initialize(to: entry) + } + return (UnsafePointer(buf), keys.count, false) + } + } + + /// Release rows handed out by `loadWalletLockedOutpoints`. + func loadWalletLockedOutpointsFree(entries: UnsafePointer?, count: UInt) { + guard let entries else { return } + let buf = UnsafeMutablePointer(mutating: entries) + buf.deinitialize(count: Int(count)) + buf.deallocate() + } + // MARK: - Identity snapshot structs /// Swift-side snapshot of the Rust `IdentityEntryFFI` with C @@ -6365,6 +6487,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { where !claimedNetworks.contains(row.networkRaw) { backgroundContext.delete(row) } + // Outpoint locks carry the same explicit network key. + let locks: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + locks = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + locks = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + for row in try backgroundContext.fetch(locks) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } if let walletRow = walletRow { // Wallet → identities is `.nullify`; this delete @@ -11571,3 +11706,65 @@ private func loadIdentityBalanceBlockTimeCallback( return 0 } catch { return -1 } } + +/// C shim for the extension's `on_persist_wallet_changeset_outpoint_locks_fn`. +/// A non-zero return fails the round. +private func persistWalletChangesetOutpointLocksCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + locksPtr: UnsafePointer?, + locksCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetOutpointLocks( + walletId: walletId, + locks: locksPtr, + count: locksCount + ) ? 0 : 1 +} + +/// C shim for the extension's `on_load_wallet_locked_outpoints_fn`. +private func loadWalletLockedOutpointsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + outOutpoints: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr, + let outOutpoints = outOutpoints, + let outCount = outCount else { + return 1 + } + outOutpoints.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + let (entries, count, errored) = handler.loadWalletLockedOutpoints( + walletId: Data(bytes: walletIdPtr, count: 32) + ) + outOutpoints.pointee = entries + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +/// C shim for the extension's `on_load_wallet_locked_outpoints_free_fn`. +private func loadWalletLockedOutpointsFreeCallback( + context: UnsafeMutableRawPointer?, + outpoints: UnsafePointer?, + count: UInt +) { + guard let context = context else { return } + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + handler.loadWalletLockedOutpointsFree(entries: outpoints, count: count) +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1ac6248f4cc..0cd890f6df4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -447,11 +447,14 @@ final class DashModelMigrationTests: XCTestCase { } } - func testV3AddsTrackedMasternodesAndBalanceMetadataToTheBaselineEntitySet() { + func testV3AddsTrackedMasternodesBalanceMetadataAndLockedOutpointsToTheBaselineEntitySet() { XCTAssertEqual( Set(Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name)) .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), - ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) + [ + "PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata", + "PersistentLockedOutpoint", + ]) } @MainActor diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 4fd3c52c85c..16c01267d0a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -67,6 +67,9 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDashpayPayment` rows, so the sweep's Failed flip // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments + // Locked outpoints: the handler wires the persist/load/free trio + // onto `PersistentLockedOutpoint`, so locks survive a restart. + | PlatformWalletPersistenceCapabilities.outpointLocks XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..c80d8885652 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,8 @@ "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", + "PersistentLockedOutpoint": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentLockedOutpoint.swift" }, "value_types": [ {