From e81dfb6c668cbb2c6e523680ebff7a330ff69ebf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 28 Sep 2026 12:59:50 +0700 Subject: [PATCH 1/5] chore(platform-wallet): pin rust-dashcore to the key-wallet collateral lock backport Moves every rust-dashcore dependency from 719de34b to beb262b0, the backport of dashpay/rust-dashcore#1078 onto 719de34b (branch backport/key-wallet-collateral-lock-on-719de34b). The pin should move to the merged rev once #1078 lands. Only the rust-dashcore source lines of Cargo.lock change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51bc3a52007..997410a76fc 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" [[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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" [[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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 8068ff3d235..d9a548cabba 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 = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which From 523a81e0e9565146495886826c984e6e4a91a827 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 28 Sep 2026 13:00:16 +0700 Subject: [PATCH 2/5] feat(platform-wallet)!: keep masternode collateral out of coin selection The wallet now keeps outputs that back a registered masternode out of coin selection, persists those locks, and exposes explicit lock and unlock. Spending the collateral would end the masternode registration. key-wallet locks the collateral of every ProRegTx a transaction check processes and skips locked coins in every selection strategy, the asset-lock builder and special-transaction funding. This carries that lock set through platform-wallet: - PlatformWalletInfo::check_core_transaction queues the outpoints a check locked. No WalletEvent carries them, so the wallet-event adapter drains the queue into the wallet's next changeset, as CoreChangeSet::outpoint_locks. The load-time replay of unconfirmed sends queues its locks the same way. - PlatformWallet::lock_outpoint / unlock_outpoint / locked_outpoints lock and unlock by hand and persist the change before returning. - On load, and when a wallet is registered, the collateral of every ProRegTx in a wallet's history and of every tracked masternode whose registration is known is locked; a tracked masternode's refresh locks it once its registration is fetched. This covers a registration the wallet never processed and one restored without a check. - SQLite: V019 adds core_locked_outpoints, keyed (wallet_id, outpoint), written by core_state::apply and handed back through lock_outpoint on load; restored coins carry their lock. - FFI: a persist / load / free trio on the persistence extension, the OUTPOINT_LOCKS capability (bit 13), and platform_wallet_lock_outpoint, platform_wallet_unlock_outpoint and platform_wallet_locked_outpoints. - Swift: PersistentLockedOutpoint rows, the trio's callbacks, and ManagedPlatformWallet.lockedOutpoints / lockOutpoint / unlockOutpoint. Co-Authored-By: Claude Opus 5.5 --- .../src/core_wallet_types.rs | 31 + packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../rs-platform-wallet-ffi/src/manager.rs | 19 +- .../src/outpoint_locks.rs | 189 ++++++ .../rs-platform-wallet-ffi/src/persistence.rs | 396 +++++++++++- packages/rs-platform-wallet-storage/SCHEMA.md | 1 + .../migrations/V019__core_locked_outpoints.rs | 28 + .../src/sqlite/persister.rs | 9 +- .../src/sqlite/rehydrate.rs | 16 + .../src/sqlite/schema/core_state.rs | 44 +- .../tests/sqlite_locked_outpoints.rs | 365 +++++++++++ .../tests/sqlite_schema_pinning.rs | 4 +- .../src/changeset/changeset.rs | 25 + .../src/changeset/core_bridge.rs | 98 ++- .../src/changeset/persistence_capabilities.rs | 16 +- .../src/manager/accessors.rs | 1 + .../rs-platform-wallet/src/manager/load.rs | 181 ++++++ .../src/manager/wallet_lifecycle.rs | 8 +- .../src/masternode/tracked.rs | 40 +- .../src/masternode/update_service.rs | 57 ++ .../rs-platform-wallet/src/test_support.rs | 133 ++++ .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../src/wallet/asset_lock/build.rs | 57 ++ .../wallet/asset_lock/sync/reconstruction.rs | 1 + .../src/wallet/asset_lock/sync/recovery.rs | 1 + .../src/wallet/core/transaction.rs | 201 ++++++ .../identity/network/contact_requests.rs | 1 + .../src/wallet/identity/network/payments.rs | 108 ++++ packages/rs-platform-wallet/src/wallet/mod.rs | 1 + .../src/wallet/outpoint_locks.rs | 602 ++++++++++++++++++ .../src/wallet/platform_wallet.rs | 12 +- .../src/wallet/platform_wallet_traits.rs | 13 +- .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentLockedOutpoint.swift | 25 + .../ManagedPlatformWallet.swift | 72 +++ .../PlatformWalletManager.swift | 6 + .../PlatformWalletPersistenceHandler.swift | 197 ++++++ .../DashModelMigrationTests.swift | 7 +- .../InvitationPersistenceTests.swift | 3 + packages/swift-sdk/schema-models.json | 3 +- 40 files changed, 2952 insertions(+), 25 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/outpoint_locks.rs create mode 100644 packages/rs-platform-wallet-storage/migrations/V019__core_locked_outpoints.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_locked_outpoints.rs create mode 100644 packages/rs-platform-wallet/src/wallet/outpoint_locks.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentLockedOutpoint.swift 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..3d72f003808 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs @@ -0,0 +1,189 @@ +//! FFI bindings for a wallet's locked outpoints: the outpoints kept out of +//! coin selection. +//! +//! The wallet locks the collateral of every masternode registration it +//! processes, 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_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..bc497505a32 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,25 @@ 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 every masternode registration + /// (ProRegTx) it processes, 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 +853,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 +872,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..00c35e9de58 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs @@ -0,0 +1,602 @@ +//! Outpoints a wallet keeps out of coin selection, and their persistence. +//! +//! The lock set lives on `key_wallet`'s `ManagedWalletInfo` (see +//! [`ManagedWalletInfo::locked_outpoints`]): every 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. 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. +//! 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 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 every masternode registration in this wallet's + /// transaction history, and every outpoint in `tracked`. The new locks + /// are queued for persistence. Returns how many were added. + 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| registration_collateral(&record.transaction)) + .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 every masternode registration the wallet has + /// processed, 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 registration restored into the history without a transaction check + /// (the mobile restore stages provider transactions directly) 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, _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(), + block(2), + TransactionType::ProviderRegistration, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + account + .transactions_mut() + .insert(registration.txid(), record); + assert!(!info.core_wallet.is_outpoint_locked(&collateral)); + } + + 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" + ); + } + + /// 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..c9b0824fbf1 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,16 @@ 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. + /// + /// Every ProRegTx the wallet processes locks its collateral, relevant or + /// not, 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..133837f8031 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -88,6 +88,78 @@ 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 every masternode registration the wallet has processed, 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": [ { From fbb254b8e410c257a6f57a7e584f965bca04a094 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 29 Sep 2026 02:20:05 +0700 Subject: [PATCH 3/5] fix(platform-wallet): lock external collateral only once its ProRegTx is mined Move the rust-dashcore pin to e49cb97310, the collateral lock backport with its follow-up: key-wallet locks a collateral a ProRegTx names only once the ProRegTx is in a block (InBlock or InChainLockedBlock), and a collateral the ProRegTx creates as its own output from any sighting. An InstantSend context counts as unconfirmed. A lock also refreshes the balance on the mempool path. Only the rust-dashcore `source` lines of Cargo.lock change. The known-masternode pass follows the same rule. It locked the collateral of every ProRegTx in a wallet's transaction history, including one recorded unconfirmed; it now locks a collateral a ProRegTx names only from a record in a block, and one it creates from any record. The transaction check that confirms an unconfirmed registration locks its collateral, and the lock rides the wallet's next changeset as before. The docs that said every processed ProRegTx locks its collateral now state the rule. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 24 ++-- Cargo.toml | 16 +-- .../src/outpoint_locks.rs | 6 +- .../src/changeset/changeset.rs | 5 +- .../src/wallet/outpoint_locks.rs | 105 +++++++++++++++--- .../src/wallet/platform_wallet.rs | 5 +- .../ManagedPlatformWallet.swift | 5 +- 7 files changed, 122 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 997410a76fc..a832cdcae0e 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" [[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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" 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=beb262b03c1691023fc4c1b08e1e68a576a55862#beb262b03c1691023fc4c1b08e1e68a576a55862" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index d9a548cabba..26bd1e7b2e6 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 = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "beb262b03c1691023fc4c1b08e1e68a576a55862" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } 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/outpoint_locks.rs b/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs index 3d72f003808..b6555c538a9 100644 --- a/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs +++ b/packages/rs-platform-wallet-ffi/src/outpoint_locks.rs @@ -1,8 +1,10 @@ //! FFI bindings for a wallet's locked outpoints: the outpoints kept out of //! coin selection. //! -//! The wallet locks the collateral of every masternode registration it -//! processes, since spending the collateral would end the registration. +//! 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`). diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index bc497505a32..0e532d7bde7 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -306,8 +306,9 @@ pub struct CoreChangeSet { /// Changes to the outpoints the wallet keeps out of coin selection: /// `true` locks the outpoint, `false` unlocks it. /// - /// The wallet locks the collateral of every masternode registration - /// (ProRegTx) it processes, since spending the collateral would end the + /// 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 diff --git a/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs b/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs index 00c35e9de58..c5d167ab809 100644 --- a/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs +++ b/packages/rs-platform-wallet/src/wallet/outpoint_locks.rs @@ -1,10 +1,12 @@ //! Outpoints a wallet keeps out of coin selection, and their persistence. //! //! The lock set lives on `key_wallet`'s `ManagedWalletInfo` (see -//! [`ManagedWalletInfo::locked_outpoints`]): every masternode registration +//! [`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. This module +//! 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 @@ -24,7 +26,8 @@ //! //! # Masternodes the wallet already knows //! -//! A ProRegTx locks its collateral only when a transaction check sees it. +//! 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 @@ -82,9 +85,10 @@ pub(crate) fn named_collateral(pro_tx_hash: &[u8; 32], (txid, vout): ([u8; 32], } /// Lock, in every wallet `wallet_manager` holds, the collateral of each -/// masternode registration in that wallet's transaction history and every -/// outpoint in `tracked`, and queue the new locks for persistence. Returns -/// how many locks were added across all wallets. +/// 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 @@ -105,9 +109,15 @@ pub(crate) async fn lock_known_masternode_collaterals( } impl PlatformWalletInfo { - /// Lock the collateral of every masternode registration in this wallet's + /// 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, @@ -118,7 +128,11 @@ impl PlatformWalletInfo { .all_accounts() .iter() .flat_map(|account| account.transactions().values()) - .filter_map(|record| registration_collateral(&record.transaction)) + .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 @@ -180,8 +194,9 @@ impl PlatformWalletInfo { impl PlatformWallet { /// The outpoints this wallet keeps out of coin selection, in outpoint - /// order: the collateral of every masternode registration the wallet has - /// processed, and every outpoint locked with [`Self::lock_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 @@ -489,11 +504,17 @@ mod tests { ); } - /// A registration restored into the history without a transaction check - /// (the mobile restore stages provider transactions directly) 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() { + /// 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], @@ -519,7 +540,7 @@ mod tests { let record = TransactionRecord::new( registration.clone(), account.managed_account_type().to_account_type(), - block(2), + context, TransactionType::ProviderRegistration, TransactionDirection::Internal, Vec::new(), @@ -531,6 +552,14 @@ mod tests { .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, @@ -556,6 +585,50 @@ mod tests { ); } + /// 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] diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index c9b0824fbf1..aacabc8f26b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -299,8 +299,9 @@ pub struct PlatformWalletInfo { /// Outpoints `core_wallet` locked during a transaction check that are not /// persisted yet. /// - /// Every ProRegTx the wallet processes locks its collateral, relevant or - /// not, and no `WalletEvent` carries that lock. The check queues it here + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 133837f8031..ba1ade8a923 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -103,8 +103,9 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } /// The outpoints this wallet keeps out of coin selection: the collateral - /// of every masternode registration the wallet has processed, and every - /// outpoint locked with `lockOutpoint`. An entry may name a coin the + /// 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? From b74b3b4356410203f4a30d01ca5dfd12e6cca5b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 29 Sep 2026 02:20:06 +0700 Subject: [PATCH 4/5] test(platform-wallet-storage): allow the locked-outpoint load read in the prepare check `load_state` reads `core_locked_outpoints` with `prepare`, a read-only SELECT like the unspent-UTXO read beside it, so it joins the allow-list of `tc_p1_003_prepare_cached_in_writers`. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-storage/tests/sqlite_compile_time.rs | 5 +++++ 1 file changed, 5 insertions(+) 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. From b5c401dc01d5412c0c2c57f0a8f165e41d0d28f4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 29 Sep 2026 03:56:07 +0700 Subject: [PATCH 5/5] chore(platform-wallet): pin rust-dashcore to 0c0dca37 Moves the eight rust-dashcore pins from e49cb973 to 0c0dca37: the key-wallet collateral lock backport plus one fix. A transaction builder now drops a coin a funding account holds locked from the candidates just before coin selection, so a stale copy of the masternode collateral passed to `add_inputs` after `add_funding` is never spent. Before, only a copy seeded before the funding call was dropped. As with a reserved coin, the locked one is dropped silently. Only the rust-dashcore `source` lines of Cargo.lock change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a832cdcae0e..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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +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=e49cb9731009342cd145a6c72d380bbf6dee7c47#e49cb9731009342cd145a6c72d380bbf6dee7c47" +source = "git+https://github.com/dashpay/rust-dashcore?rev=0c0dca374e47a3c308110aaa58f0ac7590229a5e#0c0dca374e47a3c308110aaa58f0ac7590229a5e" dependencies = [ "async-trait", "dashcore", diff --git a/Cargo.toml b/Cargo.toml index 26bd1e7b2e6..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 = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e49cb9731009342cd145a6c72d380bbf6dee7c47" } +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