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 57e1e044c84..462d5c2bdd1 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -916,7 +916,14 @@ fn transaction_type_to_u8( /// [`MasternodeRecord`], built by [`masternode_entry_ffi`] and /// returned by `platform_wallet_manager_list_masternodes`. Inline /// fixed-size hashes with `has_*` gates (mirroring `TransactionRecordFFI`) -/// keep heap ownership to the three C strings. +/// keep heap ownership to the C strings. +/// +/// # ABI stability +/// +/// This is the original, frozen layout returned by the unversioned +/// `platform_wallet_manager_list_masternodes` entry point. Do not add, remove, +/// or reorder fields. New projections belong in a versioned wrapper such as +/// [`MasternodeEntryV2FFI`]. #[repr(C)] pub struct MasternodeEntryFFI { /// proTxHash (32 wire bytes) — group key; also the registration txid. @@ -994,12 +1001,6 @@ pub struct MasternodeEntryFFI { pub platform_in_wallet: bool, pub platform_account_type: u8, pub platform_key_index: u32, - /// Where this record came from: 0 = one of the wallet's own masternodes - /// (aggregated from its provider transactions), 1 = tracked by the user - /// independently of every wallet. - pub source: u8, - /// User label of a tracked masternode, or null. - pub label: *mut c_char, /// Whether the platform-node ownership check was actually *possible* for /// this query: `true` when the wallet's derived platform-node index had /// entries to compare against, `false` when it was empty/unavailable (no @@ -1011,6 +1012,20 @@ pub struct MasternodeEntryFFI { pub platform_ownership_checked: bool, } +/// Version 2 masternode projection. The frozen V1 entry remains the first +/// field, preserving one canonical definition for all established fields; +/// V2 adds record provenance and the optional tracked-node label. +#[repr(C)] +pub struct MasternodeEntryV2FFI { + pub v1: MasternodeEntryFFI, + /// Where this record came from: 0 = one of the wallet's own masternodes + /// (aggregated from its provider transactions), 1 = tracked by the user + /// independently of every wallet. + pub source: u8, + /// User label of a tracked masternode, or null. + pub label: *mut c_char, +} + /// Encode a hash160 as a network-specific base58 P2PKH address string /// (heap C string), or null on the (impossible-for-a-valid-hash) CString /// interior-nul error. @@ -1138,14 +1153,26 @@ pub(crate) fn masternode_entry_ffi( platform_in_wallet, platform_account_type, platform_key_index, + platform_ownership_checked: mn.platform_ownership_checked, + } +} + +/// Flatten one record into the additive V2 C-ABI entry. +pub(crate) fn masternode_entry_v2_ffi( + mn: &MasternodeRecord, + network: dashcore::Network, +) -> MasternodeEntryV2FFI { + use std::ffi::CString; + + MasternodeEntryV2FFI { + v1: masternode_entry_ffi(mn, network), source: mn.source.as_u8(), label: mn .label .clone() - .and_then(|l| CString::new(l).ok()) + .and_then(|label| CString::new(label).ok()) .map(CString::into_raw) .unwrap_or(std::ptr::null_mut()), - platform_ownership_checked: mn.platform_ownership_checked, } } @@ -1491,4 +1518,97 @@ mod tests { let entries = Box::into_raw(vec![entry].into_boxed_slice()) as *mut MasternodeEntryFFI; unsafe { crate::wallet::platform_wallet_manager_free_masternodes(entries, 1) }; } + + /// Pin the original array element layout used by already-built C/Swift + /// consumers. A field addition or reorder here is an ABI break even when + /// all Rust callers are recompiled together. + #[test] + #[cfg(target_pointer_width = "64")] + fn masternode_entry_v1_layout_is_frozen() { + assert_eq!(std::mem::size_of::(), 296); + assert_eq!(std::mem::align_of::(), 8); + assert_eq!( + std::mem::offset_of!(MasternodeEntryFFI, service_address), + 144 + ); + assert_eq!(std::mem::offset_of!(MasternodeEntryFFI, owner_address), 160); + assert_eq!( + std::mem::offset_of!(MasternodeEntryFFI, operator_public_key), + 176 + ); + assert_eq!( + std::mem::offset_of!(MasternodeEntryFFI, payout_address), + 248 + ); + assert_eq!( + std::mem::offset_of!(MasternodeEntryFFI, platform_key_index), + 284 + ); + assert_eq!( + std::mem::offset_of!(MasternodeEntryFFI, platform_ownership_checked), + 288 + ); + assert_eq!(std::mem::offset_of!(MasternodeEntryV2FFI, v1), 0); + } + + #[test] + fn masternode_entry_v2_carries_additive_fields_and_frees_them() { + let mut mn = MasternodeRecord::default(); + mn.source = platform_wallet::masternode::MasternodeSource::Tracked; + mn.label = Some("tracked label".to_string()); + let entry = masternode_entry_v2_ffi(&mn, dashcore::Network::Testnet); + assert_eq!(entry.source, 1); + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(entry.label) } + .to_str() + .unwrap(), + "tracked label" + ); + let entries = Box::into_raw(vec![entry].into_boxed_slice()) as *mut MasternodeEntryV2FFI; + unsafe { crate::wallet::platform_wallet_manager_free_masternodes_v2(entries, 1) }; + } + + #[test] + fn masternode_v1_and_v2_arrays_preserve_second_element_stride() { + let mut first = MasternodeRecord::default(); + first.pro_tx_hash = [1; 32]; + first.service_address = Some("1.1.1.1:9999".to_string()); + first.source = platform_wallet::masternode::MasternodeSource::Tracked; + first.label = Some("first".to_string()); + let mut second = MasternodeRecord::default(); + second.pro_tx_hash = [2; 32]; + second.service_address = Some("2.2.2.2:9999".to_string()); + second.source = platform_wallet::masternode::MasternodeSource::Tracked; + second.label = Some("second".to_string()); + + let v1 = vec![ + masternode_entry_ffi(&first, dashcore::Network::Testnet), + masternode_entry_ffi(&second, dashcore::Network::Testnet), + ]; + let v1 = Box::into_raw(v1.into_boxed_slice()) as *mut MasternodeEntryFFI; + let v1_slice = unsafe { std::slice::from_raw_parts(v1, 2) }; + assert_eq!(v1_slice[1].pro_tx_hash, [2; 32]); + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(v1_slice[1].service_address) } + .to_str() + .unwrap(), + "2.2.2.2:9999" + ); + unsafe { crate::wallet::platform_wallet_manager_free_masternodes(v1, 2) }; + + let v2 = vec![ + masternode_entry_v2_ffi(&first, dashcore::Network::Testnet), + masternode_entry_v2_ffi(&second, dashcore::Network::Testnet), + ]; + let v2 = Box::into_raw(v2.into_boxed_slice()) as *mut MasternodeEntryV2FFI; + let v2_slice = unsafe { std::slice::from_raw_parts(v2, 2) }; + assert_eq!(v2_slice[1].v1.pro_tx_hash, [2; 32]); + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(v2_slice[1].label) } + .to_str() + .unwrap(), + "second" + ); + unsafe { crate::wallet::platform_wallet_manager_free_masternodes_v2(v2, 2) }; + } } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3a50ff916e4..4180a774462 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,8 +8,10 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, - PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, + PersistTrackedMasternodesFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -187,9 +189,9 @@ unsafe fn persistence_extension_callbacks( /// Read one size-gated `Option` field: present only when the /// caller's `struct_size` proves the complete field exists. macro_rules! gated { - ($field:ident) => {{ + ($field:ident, $callback:ty) => {{ let end = std::mem::offset_of!(PersistenceCallbacksExtension, $field) - + std::mem::size_of_val(&(*extension).$field); + + std::mem::size_of::>(); if supplied_size < end { None } else { @@ -199,10 +201,16 @@ unsafe fn persistence_extension_callbacks( } PersistenceExtensionCallbacks { - dpns_name_states: gated!(on_persist_dpns_name_states_fn), - persist_tracked_masternodes: gated!(on_persist_tracked_masternodes_fn), - load_tracked_masternodes: gated!(on_load_tracked_masternodes_fn), - load_tracked_masternodes_free: gated!(on_load_tracked_masternodes_free_fn), + dpns_name_states: gated!(on_persist_dpns_name_states_fn, PersistDpnsNameStatesFn), + persist_tracked_masternodes: gated!( + on_persist_tracked_masternodes_fn, + PersistTrackedMasternodesFn + ), + load_tracked_masternodes: gated!(on_load_tracked_masternodes_fn, LoadTrackedMasternodesFn), + load_tracked_masternodes_free: gated!( + on_load_tracked_masternodes_free_fn, + FreeTrackedMasternodesFn + ), } } @@ -788,6 +796,16 @@ mod tests { 0 } + /// Exact allocation shape used by a host compiled before the tracked + /// callback trio was appended to `PersistenceCallbacksExtension`. + #[repr(C)] + struct DpnsOnlyPersistenceCallbacksExtension { + struct_size: usize, + version: u32, + reserved: u32, + on_persist_dpns_name_states_fn: Option, + } + fn persistence_callbacks() -> PersistenceCallbacks { PersistenceCallbacks { on_changeset_begin_fn: Some(begin_changeset), @@ -1120,16 +1138,24 @@ mod tests { /// yields the dpns callback and nothing else — additive size gating. #[test] fn dpns_only_sized_extension_reads_only_the_dpns_field() { - let dpns_only_size = std::mem::offset_of!( - PersistenceCallbacksExtension, - on_persist_tracked_masternodes_fn - ); - let ext = PersistenceCallbacksExtension { - struct_size: dpns_only_size, + let ext = DpnsOnlyPersistenceCallbacksExtension { + struct_size: std::mem::size_of::(), + version: PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + reserved: 0, on_persist_dpns_name_states_fn: Some(persist_dpns_name_states), - ..Default::default() }; - let read = unsafe { persistence_extension_callbacks(&ext) }; + assert_eq!( + ext.struct_size, + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_tracked_masternodes_fn + ) + ); + let read = unsafe { + persistence_extension_callbacks( + (&ext as *const DpnsOnlyPersistenceCallbacksExtension).cast(), + ) + }; assert!(read.dpns_name_states.is_some()); assert!(read.persist_tracked_masternodes.is_none()); assert!(read.load_tracked_masternodes.is_none()); diff --git a/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs index 2d93ab52d67..7f51820e6fe 100644 --- a/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs +++ b/packages/rs-platform-wallet-ffi/src/tracked_masternode.rs @@ -1,7 +1,7 @@ //! FFI for tracked (wallet-independent) masternodes: track / untrack / //! rename, list, refresh, capabilities, and withdraw with a host-supplied //! key. Thin marshalling over `platform_wallet::masternode::tracked`; the -//! records reuse [`MasternodeEntryFFI`] (`source == 1`) so hosts render +//! records reuse [`MasternodeEntryV2FFI`] (`source == 1`) so hosts render //! wallet and tracked masternodes with the same code. use std::ffi::{c_char, CStr}; @@ -11,7 +11,7 @@ use platform_wallet::masternode::{ capabilities_for_roles, LocatorSecret, MasternodeKeyRole, MasternodeRecord, }; -use crate::core_wallet_types::{masternode_entry_ffi, MasternodeEntryFFI}; +use crate::core_wallet_types::{masternode_entry_v2_ffi, MasternodeEntryV2FFI}; use crate::error::*; use crate::handle::*; use crate::runtime::block_on_worker; @@ -37,12 +37,12 @@ unsafe fn optional_string(ptr: *const c_char) -> Result, Platform unsafe fn write_records( records: Vec, network: dashcore::Network, - out_entries: *mut *const MasternodeEntryFFI, + out_entries: *mut *const MasternodeEntryV2FFI, out_count: *mut usize, ) { - let entries: Vec = records + let entries: Vec = records .iter() - .map(|record| masternode_entry_ffi(record, network)) + .map(|record| masternode_entry_v2_ffi(record, network)) .collect(); let count = entries.len(); if count == 0 { @@ -59,7 +59,7 @@ unsafe fn write_records( /// the current masternode list when available — local, no network; call /// [`platform_wallet_manager_refresh_tracked_masternode`] afterwards for the /// Platform / registration details. Returns the new record as a one-entry -/// array (free with `platform_wallet_manager_free_masternodes`). +/// array (free with `platform_wallet_manager_free_masternodes_v2`). /// /// Whether the row survives a restart depends on the configured persister — /// see `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES`. @@ -74,7 +74,7 @@ pub unsafe extern "C" fn platform_wallet_manager_track_masternode( manager_handle: Handle, pro_tx_hash: *const u8, label: *const c_char, - out_entry: *mut *const MasternodeEntryFFI, + out_entry: *mut *const MasternodeEntryV2FFI, out_count: *mut usize, ) -> PlatformWalletFFIResult { check_ptr!(pro_tx_hash); @@ -153,18 +153,18 @@ pub unsafe extern "C" fn platform_wallet_manager_set_tracked_masternode_label( PlatformWalletFFIResult::ok() } -/// Every tracked masternode as a [`MasternodeEntryFFI`] (`source == 1`, +/// Every tracked masternode as a [`MasternodeEntryV2FFI`] (`source == 1`, /// `label` set when named), with its status resolved against the CURRENT /// masternode list (Active / Inactive / Retired, `Unknown` while the list /// is unavailable). Sorted by when they were tracked. Free with -/// [`crate::wallet::platform_wallet_manager_free_masternodes`]. +/// [`crate::wallet::platform_wallet_manager_free_masternodes_v2`]. /// /// # Safety /// `out_entries` / `out_count` must be writable. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_list_tracked_masternodes( manager_handle: Handle, - out_entries: *mut *const MasternodeEntryFFI, + out_entries: *mut *const MasternodeEntryV2FFI, out_count: *mut usize, ) -> PlatformWalletFFIResult { check_ptr!(out_entries); @@ -188,7 +188,7 @@ pub unsafe extern "C" fn platform_wallet_manager_list_tracked_masternodes( /// keys). Blocks on the network round-trips. Partial results are kept and /// persisted even when a step fails (the error is still returned). On /// success returns the refreshed record as a one-entry array (free with -/// `platform_wallet_manager_free_masternodes`). +/// `platform_wallet_manager_free_masternodes_v2`). /// /// # Safety /// `pro_tx_hash` must point at 32 readable bytes; `out_entry` / `out_count` @@ -197,7 +197,7 @@ pub unsafe extern "C" fn platform_wallet_manager_list_tracked_masternodes( pub unsafe extern "C" fn platform_wallet_manager_refresh_tracked_masternode( manager_handle: Handle, pro_tx_hash: *const u8, - out_entry: *mut *const MasternodeEntryFFI, + out_entry: *mut *const MasternodeEntryV2FFI, out_count: *mut usize, ) -> PlatformWalletFFIResult { check_ptr!(pro_tx_hash); @@ -342,7 +342,7 @@ mod tests { #[test] fn unknown_handles_are_invalid_handles() { let hash = [0u8; 32]; - let mut entries: *const MasternodeEntryFFI = std::ptr::null(); + let mut entries: *const MasternodeEntryV2FFI = std::ptr::null(); let mut count = 5usize; let mut r = unsafe { platform_wallet_manager_track_masternode( diff --git a/packages/rs-platform-wallet-ffi/src/wallet.rs b/packages/rs-platform-wallet-ffi/src/wallet.rs index 49639627fe2..b27125b65da 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet.rs @@ -256,6 +256,60 @@ pub unsafe extern "C" fn platform_wallet_manager_list_masternodes( PlatformWalletFFIResult::ok() } +/// Version 2 of [`platform_wallet_manager_list_masternodes`]. It returns +/// [`MasternodeEntryV2FFI`](crate::core_wallet_types::MasternodeEntryV2FFI), +/// which adds record provenance and an optional tracked-node label without +/// changing the frozen V1 array element size. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_list_masternodes_v2( + manager_handle: Handle, + wallet_id: *const u8, + out_entries: *mut *const crate::core_wallet_types::MasternodeEntryV2FFI, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id); + check_ptr!(out_entries); + check_ptr!(out_count); + *out_entries = std::ptr::null(); + *out_count = 0; + + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { + manager.wallet_masternodes_blocking(&wid) + }); + let inner = unwrap_option_or_return!(option); + let masternodes = unwrap_option_or_return!(inner); + + let entries: Vec = masternodes + .records + .iter() + .map(|mn| crate::core_wallet_types::masternode_entry_v2_ffi(mn, masternodes.network)) + .collect(); + let count = entries.len(); + if count == 0 { + return PlatformWalletFFIResult::ok(); + } + + *out_entries = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +unsafe fn free_masternode_entry_strings(entry: &crate::core_wallet_types::MasternodeEntryFFI) { + for ptr in [ + entry.service_address, + entry.owner_address, + entry.voting_address, + entry.payout_address, + entry.operator_pseudo_address, + entry.platform_node_address, + ] { + if !ptr.is_null() { + let _ = std::ffi::CString::from_raw(ptr); + } + } +} + /// Free an array returned by [`platform_wallet_manager_list_masternodes`], /// including each entry's heap C strings. #[no_mangle] @@ -268,18 +322,27 @@ pub unsafe extern "C" fn platform_wallet_manager_free_masternodes( } let slice = std::slice::from_raw_parts_mut(entries, count); for entry in slice.iter() { - for ptr in [ - entry.service_address, - entry.owner_address, - entry.voting_address, - entry.payout_address, - entry.operator_pseudo_address, - entry.platform_node_address, - entry.label, - ] { - if !ptr.is_null() { - let _ = std::ffi::CString::from_raw(ptr); - } + free_masternode_entry_strings(entry); + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); +} + +/// Free an array returned by +/// [`platform_wallet_manager_list_masternodes_v2`] or any tracked-masternode +/// API returning `MasternodeEntryV2FFI`. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_free_masternodes_v2( + entries: *mut crate::core_wallet_types::MasternodeEntryV2FFI, + count: usize, +) { + if entries.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts_mut(entries, count); + for entry in slice.iter() { + free_masternode_entry_strings(&entry.v1); + if !entry.label.is_null() { + let _ = std::ffi::CString::from_raw(entry.label); } } let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(entries, count)); diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index e6f946f136a..d697bd15a54 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -392,11 +392,12 @@ pub struct PlatformWalletManager { pub(super) event_manager: Arc, pub(super) persister: Arc

, /// Tracked (wallet-independent) masternodes for this manager's - /// network, keyed by wire proTxHash. Hydrated from the persister at + /// network, keyed by wire proTxHash, plus the per-node gates that + /// serialize their refreshes. Hydrated from the persister at /// `load_from_persistor`; every mutation writes the whole set back /// (see `masternode::tracked`). pub(crate) tracked_masternodes: - std::sync::Arc>, + std::sync::Arc, /// Cancellation token + join handle for the wallet-event adapter /// task. Held so [`shutdown`] can stop it cleanly when the manager /// is torn down. @@ -544,7 +545,7 @@ impl PlatformWalletManager

{ #[cfg(feature = "shielded")] event_manager, persister, - tracked_masternodes: std::sync::Arc::new(std::sync::RwLock::new(Default::default())), + tracked_masternodes: std::sync::Arc::new(Default::default()), event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), registry, diff --git a/packages/rs-platform-wallet/src/masternode/tracked.rs b/packages/rs-platform-wallet/src/masternode/tracked.rs index 475f9cfd44d..5c0933aa8f5 100644 --- a/packages/rs-platform-wallet/src/masternode/tracked.rs +++ b/packages/rs-platform-wallet/src/masternode/tracked.rs @@ -443,6 +443,37 @@ fn number_records(records: &mut [MasternodeRecord]) { } } +/// The manager's tracked-masternode registry: the rows plus the per-node +/// refresh gates. Both live behind one `Arc`, so every +/// [`TrackedMasternodes`] handle a manager hands out shares the rows AND +/// the gates that order the refreshes writing to them. +#[derive(Default)] +pub(crate) struct TrackedMasternodeRegistry { + rows: std::sync::RwLock, + /// One gate per node, created on demand and dropped once no pass holds + /// it. Held for a whole refresh pass — see [`refresh_row_and_persist`]. + gates: std::sync::Mutex>>>, +} + +impl TrackedMasternodeRegistry { + /// Take `pro_tx_hash`'s refresh gate. The `std` lock over the gate map + /// is released before the `await`, so waiting for a gate never blocks + /// the runtime thread. + async fn refresh_gate(&self, pro_tx_hash: &[u8; 32]) -> tokio::sync::OwnedMutexGuard<()> { + let gate = { + let mut gates = self + .gates + .lock() + .expect("tracked masternode gate map lock poisoned"); + // Every pass keeps its own clone alive for its whole duration, + // so a gate the map alone references has no pass on it. + gates.retain(|_, gate| std::sync::Arc::strong_count(gate) > 1); + std::sync::Arc::clone(gates.entry(*pro_tx_hash).or_default()) + }; + gate.lock_owned().await + } +} + /// Shared handle to the tracked-masternode registry and everything its /// operations need (SPV for the list, the SDK for Platform / DAPI, the /// persister for durability). Cloneable and `Send + Sync`, so hosts can run @@ -452,13 +483,104 @@ fn number_records(records: &mut [MasternodeRecord]) { /// shares one registry. #[derive(Clone)] pub struct TrackedMasternodes { - registry: std::sync::Arc>, + registry: std::sync::Arc, spv: std::sync::Arc, sdk: std::sync::Arc, persister: std::sync::Arc, network: Network, } +/// Apply one registry mutation and durably replace the persisted set as one +/// linearizable operation. +fn mutate_registry_and_persist( + registry: &TrackedMasternodeRegistry, + persister: &dyn PlatformWalletPersistence, + network: Network, + mutation: impl FnOnce(&mut TrackedMasternodeMap) -> Result, +) -> Result { + let mut guard = registry + .rows + .write() + .expect("tracked masternode registry lock poisoned"); + let before = guard.clone(); + let result = match mutation(&mut guard) { + Ok(result) => result, + Err(error) => { + *guard = before; + return Err(error); + } + }; + let records: Vec = guard.values().cloned().collect(); + if let Err(e) = persister.persist_tracked_masternodes(network, &records) { + *guard = before; + return Err(PlatformWalletError::WalletCreation(format!( + "failed to persist tracked masternodes: {e}" + ))); + } + Ok(result) +} + +/// What a refresh pass's network half learned besides the snapshot: the +/// masternode list it read (the returned record renders the node's LIVE +/// status from it) and the first error it hit, if any. +#[derive(Default)] +struct RefreshOutcome { + list_now: Option>, + first_error: Option, +} + +/// One refresh pass over `pro_tx_hash`: take the node's refresh gate, read +/// the row under it, hand its snapshot to `learn`, then write the learned +/// snapshot back and durably replace the persisted set. +/// +/// The gate spans the read AND the write, so two passes over one node never +/// both start from the same snapshot: `learn` always sees everything +/// earlier passes learned, and keeps a field by simply not overwriting it — +/// otherwise the pass that finished last would put its own pre-read clone +/// back over the other's findings and persist the loss. Serializing also +/// spares the second pass the network work the first one already did. +/// +/// Only the snapshot is written: a relabel that raced the pass keeps its +/// label, and an untrack that raced it wins — the row is gone, so the pass +/// errors instead of resurrecting it. +async fn refresh_row_and_persist( + registry: &TrackedMasternodeRegistry, + persister: &dyn PlatformWalletPersistence, + network: Network, + pro_tx_hash: &[u8; 32], + learn: F, +) -> Result<(TrackedMasternode, RefreshOutcome), PlatformWalletError> +where + F: FnOnce(TrackedMasternodeSnapshot) -> Fut, + Fut: std::future::Future, +{ + let _gate = registry.refresh_gate(pro_tx_hash).await; + + let mut tracked = registry + .rows + .read() + .expect("tracked masternode registry lock poisoned") + .get(pro_tx_hash) + .cloned() + .ok_or_else(|| not_tracked(pro_tx_hash))?; + + let (snapshot, outcome) = learn(tracked.snapshot).await; + tracked.snapshot = snapshot; + + let label = mutate_registry_and_persist(registry, persister, network, |guard| { + match guard.get_mut(pro_tx_hash) { + Some(live) => { + live.snapshot = tracked.snapshot.clone(); + Ok(live.label.clone()) + } + None => Err(not_tracked(pro_tx_hash)), + } + })?; + tracked.label = label; + + Ok((tracked, outcome)) +} + impl std::fmt::Debug for TrackedMasternodes { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TrackedMasternodes") @@ -477,23 +599,25 @@ impl TrackedMasternodes { .contains(PersistenceCapabilities::TRACKED_MASTERNODES) } - /// Write the whole registry through the persister (whole-set replace - /// for this network — the set is small and user-curated). - fn persist(&self) -> Result<(), PlatformWalletError> { - let records: Vec = { - let guard = self - .registry - .read() - .expect("tracked masternode registry lock poisoned"); - guard.values().cloned().collect() - }; - self.persister - .persist_tracked_masternodes(self.network, &records) - .map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "failed to persist tracked masternodes: {e}" - )) - }) + /// Apply one registry mutation and durably replace the persisted set as + /// one linearizable operation. The write guard deliberately spans the + /// persistence call: every backend is bound by the trait's non-reentrant + /// callback contract, and releasing it earlier would let an older + /// snapshot overwrite a newer concurrent mutation. + /// + /// The registry is user-curated and small, so retaining a complete + /// before-image is cheap and lets a rejected write restore exactly the + /// in-memory state the caller observed before the operation. + fn mutate_and_persist( + &self, + mutation: impl FnOnce(&mut TrackedMasternodeMap) -> Result, + ) -> Result { + mutate_registry_and_persist( + self.registry.as_ref(), + self.persister.as_ref(), + self.network, + mutation, + ) } /// Hydrate the registry from the persister. A load failure logs and @@ -503,6 +627,7 @@ impl TrackedMasternodes { Ok(rows) => { let mut guard = self .registry + .rows .write() .expect("tracked masternode registry lock poisoned"); for row in rows { @@ -519,6 +644,7 @@ impl TrackedMasternodes { /// `already_tracked` mark). pub fn hashes(&self) -> std::collections::BTreeSet<[u8; 32]> { self.registry + .rows .read() .expect("tracked masternode registry lock poisoned") .keys() @@ -529,6 +655,7 @@ impl TrackedMasternodes { /// The tracked row itself (snapshot included), when present. pub fn get(&self, pro_tx_hash: &[u8; 32]) -> Option { self.registry + .rows .read() .expect("tracked masternode registry lock poisoned") .get(pro_tx_hash) @@ -549,11 +676,7 @@ impl TrackedMasternodes { .as_ref() .map(|summaries| summaries.iter().find(|s| s.pro_tx_hash == pro_tx_hash)); - let tracked = { - let mut guard = self - .registry - .write() - .expect("tracked masternode registry lock poisoned"); + let tracked = self.mutate_and_persist(|guard| { if guard.contains_key(&pro_tx_hash) { return Err(PlatformWalletError::InvalidParameter( "this masternode is already tracked".to_string(), @@ -571,10 +694,9 @@ impl TrackedMasternodes { }, }; guard.insert(pro_tx_hash, tracked.clone()); - tracked - }; + Ok(tracked) + })?; - self.persist()?; Ok(tracked.record(entry)) } @@ -582,19 +704,7 @@ impl TrackedMasternodes { /// host owns any attached keys (secure storage) and deletes them /// itself. Blocking. pub fn untrack_blocking(&self, pro_tx_hash: &[u8; 32]) -> Result { - let removed = { - let mut guard = self - .registry - .write() - .expect("tracked masternode registry lock poisoned"); - guard.remove(pro_tx_hash).is_some() - }; - // Persist unconditionally: an earlier call may have removed the row - // from memory and then failed the write, so a retry arrives with - // `removed == false` while the stale row still sits on disk — a - // skipped persist would resurrect the node on the next start. - self.persist()?; - Ok(removed) + self.mutate_and_persist(|guard| Ok(guard.remove(pro_tx_hash).is_some())) } /// Rename a tracked masternode (`None` / blank clears the label). @@ -604,17 +714,13 @@ impl TrackedMasternodes { pro_tx_hash: &[u8; 32], label: Option, ) -> Result<(), PlatformWalletError> { - { - let mut guard = self - .registry - .write() - .expect("tracked masternode registry lock poisoned"); + self.mutate_and_persist(|guard| { let tracked = guard .get_mut(pro_tx_hash) .ok_or_else(|| not_tracked(pro_tx_hash))?; tracked.label = label.filter(|l| !l.trim().is_empty()); - } - self.persist() + Ok(()) + }) } /// Every tracked masternode as a display record, with the CURRENT list @@ -630,6 +736,7 @@ impl TrackedMasternodes { let mut rows: Vec = { let guard = self .registry + .rows .read() .expect("tracked masternode registry lock poisoned"); guard.values().cloned().collect() @@ -656,22 +763,52 @@ impl TrackedMasternodes { /// error is returned, so a flaky step never discards what an earlier /// step learned; `refreshed_at` advances only on a fully successful /// pass. + /// + /// Serialized per masternode: a second refresh of the same node waits + /// for the one in flight instead of racing it — two passes that both + /// started from the same snapshot would end with the one finishing last + /// putting its own pre-read clone back over the other's findings. pub async fn refresh( &self, pro_tx_hash: &[u8; 32], ) -> Result { - let mut tracked = self - .get(pro_tx_hash) - .ok_or_else(|| not_tracked(pro_tx_hash))?; + let (tracked, outcome) = refresh_row_and_persist( + self.registry.as_ref(), + self.persister.as_ref(), + self.network, + pro_tx_hash, + |snapshot| self.learn(pro_tx_hash, snapshot), + ) + .await?; + let entry = outcome + .list_now + .as_ref() + .map(|s| s.iter().find(|e| e.pro_tx_hash == *pro_tx_hash)); + match outcome.first_error { + Some(e) => Err(e), + None => Ok(tracked.record(entry)), + } + } + + /// The network half of a [`Self::refresh`] pass: everything the wallet + /// layer can learn about `pro_tx_hash`, merged into `snapshot`. Runs + /// under the node's refresh gate, so `snapshot` already carries what + /// earlier passes learned and a step that comes back empty leaves its + /// field alone rather than clearing it. + async fn learn( + &self, + pro_tx_hash: &[u8; 32], + mut snapshot: TrackedMasternodeSnapshot, + ) -> (TrackedMasternodeSnapshot, RefreshOutcome) { // 1. Current list entry (local). let list_now = self.spv.masternode_list_summaries().await; - let entry = list_now + if let Some(Some(entry)) = list_now .as_ref() - .map(|s| s.iter().find(|e| e.pro_tx_hash == *pro_tx_hash)); - if let Some(Some(entry)) = &entry { - tracked.snapshot.list = Some((*entry).clone()); - tracked.snapshot.ever_listed = true; + .map(|s| s.iter().find(|e| e.pro_tx_hash == *pro_tx_hash)) + { + snapshot.list = Some(entry.clone()); + snapshot.ever_listed = true; } let mut display = *pro_tx_hash; @@ -682,7 +819,7 @@ impl TrackedMasternodes { // balance. match Identity::fetch(self.sdk.as_ref(), Identifier::from(display)).await { Ok(Some(identity)) => { - let mut platform = tracked.snapshot.platform.clone().unwrap_or_default(); + let mut platform = snapshot.platform.clone().unwrap_or_default(); for key in identity.public_keys().values() { let data: Option<[u8; 20]> = key.data().as_slice().try_into().ok(); match key.purpose() { @@ -696,7 +833,7 @@ impl TrackedMasternodes { } } platform.owner_identity_balance = Some(identity.balance()); - tracked.snapshot.platform = Some(platform); + snapshot.platform = Some(platform); } Ok(None) => { // No owner identity (node registered before Platform, or a @@ -711,13 +848,11 @@ impl TrackedMasternodes { // 3. Operator identity (needs the operator key — list, else // registration). - let operator_key = tracked - .snapshot + let operator_key = snapshot .list .as_ref() .map(|l| l.operator_public_key) - .or(tracked - .snapshot + .or(snapshot .registration .as_ref() .map(|r| r.operator_public_key)); @@ -725,14 +860,14 @@ impl TrackedMasternodes { let operator_id = Identifier::create_operator_identifier(&display, &operator_key); match Identity::fetch(self.sdk.as_ref(), operator_id).await { Ok(Some(identity)) => { - let mut platform = tracked.snapshot.platform.clone().unwrap_or_default(); + let mut platform = snapshot.platform.clone().unwrap_or_default(); platform.operator_payout_key_hash = identity .public_keys() .values() .find(|k| k.purpose() == Purpose::TRANSFER) .and_then(|k| k.data().as_slice().try_into().ok()) .or(platform.operator_payout_key_hash); - tracked.snapshot.platform = Some(platform); + snapshot.platform = Some(platform); } Ok(None) => {} Err(e) => { @@ -745,13 +880,13 @@ impl TrackedMasternodes { // 4. ProRegTx (once): registration height, collateral, original // keys / payout script. - if tracked.snapshot.registration.is_none() { + if snapshot.registration.is_none() { match self.sdk.get_transaction(&hex::encode(display)).await { Ok(Some(fetched)) => { if let Some(details) = registration_from_transaction(&fetched.transaction, fetched.height) { - tracked.snapshot.registration = Some(details); + snapshot.registration = Some(details); } } Ok(None) => {} @@ -764,36 +899,16 @@ impl TrackedMasternodes { } if first_error.is_none() { - tracked.snapshot.refreshed_at = Some(now_unix()); - } - - // Keep + persist whatever was learned, even on a partial failure — - // but only while the node is STILL tracked: an untrack that raced - // the network calls must win (no resurrection), and a concurrent - // relabel keeps its label (only the snapshot is refreshed here). - let still_tracked = { - let mut guard = self - .registry - .write() - .expect("tracked masternode registry lock poisoned"); - match guard.get_mut(pro_tx_hash) { - Some(live) => { - live.snapshot = tracked.snapshot.clone(); - tracked.label = live.label.clone(); - true - } - None => false, - } - }; - if !still_tracked { - return Err(not_tracked(pro_tx_hash)); + snapshot.refreshed_at = Some(now_unix()); } - self.persist()?; - match first_error { - Some(e) => Err(e), - None => Ok(tracked.record(entry)), - } + ( + snapshot, + RefreshOutcome { + list_now, + first_error, + }, + ) } /// Withdraw from a TRACKED masternode's owner identity with a @@ -871,7 +986,7 @@ impl TrackedMasternodes { _ => { return Err(PlatformWalletError::InvalidParameter( "a withdrawal signs with the owner key or the payout-address key".to_string(), - )) + )); } }; @@ -985,6 +1100,10 @@ pub(crate) type TrackedMasternodeMap = BTreeMap<[u8; 32], TrackedMasternode>; mod tests { use super::super::list::test_support::{evonode, masternode}; use super::*; + use crate::changeset::{ClientStartState, PersistenceError, PlatformWalletChangeSet}; + use crate::wallet::platform_wallet::WalletId; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex, RwLock}; fn snapshot_full() -> TrackedMasternodeSnapshot { TrackedMasternodeSnapshot { @@ -1049,6 +1168,207 @@ mod tests { } } + struct RegistryPersister { + registry: Arc, + reject: AtomicBool, + saw_exclusive_registry_lock: AtomicBool, + writes: Mutex>>, + } + + impl PlatformWalletPersistence for RegistryPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn persist_tracked_masternodes( + &self, + _network: Network, + records: &[TrackedMasternode], + ) -> Result<(), PersistenceError> { + self.saw_exclusive_registry_lock + .store(self.registry.rows.try_read().is_err(), Ordering::SeqCst); + if self.reject.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("tracked write rejected")); + } + self.writes.lock().unwrap().push(records.to_vec()); + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + fn registry_persister( + initial: TrackedMasternodeMap, + ) -> (Arc, Arc) { + let registry = Arc::new(TrackedMasternodeRegistry { + rows: RwLock::new(initial), + gates: Default::default(), + }); + let persister = Arc::new(RegistryPersister { + registry: Arc::clone(®istry), + reject: AtomicBool::new(false), + saw_exclusive_registry_lock: AtomicBool::new(false), + writes: Mutex::new(Vec::new()), + }); + (registry, persister) + } + + #[test] + fn registry_mutation_remains_exclusive_until_whole_set_is_persisted() { + let (registry, persister) = registry_persister(TrackedMasternodeMap::new()); + let row = tracked(); + + mutate_registry_and_persist( + registry.as_ref(), + persister.as_ref(), + Network::Testnet, + |rows| { + rows.insert(row.pro_tx_hash, row.clone()); + Ok(()) + }, + ) + .unwrap(); + + assert!(persister.saw_exclusive_registry_lock.load(Ordering::SeqCst)); + assert_eq!(persister.writes.lock().unwrap().as_slice(), &[vec![row]]); + } + + #[test] + fn rejected_whole_set_write_restores_the_complete_registry() { + let original = tracked(); + let mut initial = TrackedMasternodeMap::new(); + initial.insert(original.pro_tx_hash, original.clone()); + let (registry, persister) = registry_persister(initial.clone()); + persister.reject.store(true, Ordering::SeqCst); + + let error = mutate_registry_and_persist( + registry.as_ref(), + persister.as_ref(), + Network::Testnet, + |rows| { + rows.get_mut(&original.pro_tx_hash).unwrap().label = Some("changed".to_string()); + rows.insert( + [8; 32], + TrackedMasternode { + pro_tx_hash: [8; 32], + label: None, + added_at: 2, + snapshot: TrackedMasternodeSnapshot::default(), + }, + ); + Ok(()) + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("failed to persist tracked masternodes")); + assert_eq!(*registry.rows.read().unwrap(), initial); + } + + /// Two refresh passes over one node, interleaved so the pass that + /// learns nothing writes last. Its snapshot must not be the pre-read + /// clone that predates what the other pass learned — in memory or in + /// the persisted set. + #[tokio::test] + async fn interleaved_refresh_passes_keep_what_the_other_learned() { + let hash = [7u8; 32]; + let mut initial = TrackedMasternodeMap::new(); + initial.insert( + hash, + TrackedMasternode { + pro_tx_hash: hash, + label: Some("my node".to_string()), + added_at: 1, + snapshot: TrackedMasternodeSnapshot::default(), + }, + ); + let (registry, persister) = registry_persister(initial); + + let (learning_tx, learning_rx) = tokio::sync::oneshot::channel(); + let (release_learner_tx, release_learner_rx) = tokio::sync::oneshot::channel(); + let (release_blind_tx, release_blind_rx) = tokio::sync::oneshot::channel(); + + // The pass that learns the registration, held mid-pass so the other + // one can start while it is still in its network half. + let learner = { + let (registry, persister) = (Arc::clone(®istry), Arc::clone(&persister)); + tokio::spawn(async move { + refresh_row_and_persist( + registry.as_ref(), + persister.as_ref(), + Network::Testnet, + &hash, + move |mut snapshot| async move { + learning_tx.send(()).expect("the test awaits this"); + release_learner_rx.await.expect("the test releases this"); + snapshot.registration = snapshot_full().registration; + (snapshot, RefreshOutcome::default()) + }, + ) + .await + }) + }; + learning_rx + .await + .expect("the learner starts its network half"); + + // The pass whose network half comes back empty: it keeps a field by + // not overwriting it, so it may only write a snapshot it read AFTER + // the learner's. + let blind = { + let (registry, persister) = (Arc::clone(®istry), Arc::clone(&persister)); + tokio::spawn(async move { + refresh_row_and_persist( + registry.as_ref(), + persister.as_ref(), + Network::Testnet, + &hash, + move |snapshot| async move { + release_blind_rx.await.expect("the test releases this"); + (snapshot, RefreshOutcome::default()) + }, + ) + .await + }) + }; + // Let it get as far as it can while the learner still holds the row. + tokio::task::yield_now().await; + + release_learner_tx.send(()).expect("the learner waits"); + learner.await.expect("learner task").expect("learner pass"); + release_blind_tx.send(()).expect("the blind pass waits"); + blind.await.expect("blind task").expect("blind pass"); + + let live = registry + .rows + .read() + .unwrap() + .get(&hash) + .expect("still tracked") + .clone(); + assert_eq!(live.snapshot.registration, snapshot_full().registration); + let persisted = persister + .writes + .lock() + .unwrap() + .last() + .cloned() + .expect("both passes persisted the set"); + assert_eq!(persisted, vec![live]); + } + #[test] fn record_prefers_live_list_then_snapshot_then_registration() { let t = tracked(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 6d17cd115e1..f111121f922 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,8 +3,9 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { - /// All persistent model types for the Dash SDK - public static var modelTypes: [any PersistentModel.Type] { + /// The exact model set shipped as schema V1. Keep frozen: staged + /// migration identifies an existing store by this schema checksum. + fileprivate static var v1ModelTypes: [any PersistentModel.Type] { [ PersistentIdentity.self, PersistentDPNSName.self, @@ -39,14 +40,18 @@ public enum DashModelContainer { PersistentShieldedViewingKey.self, PersistentAssetLock.self, PersistentInvitation.self, - PersistentMasternode.self, - PersistentTrackedMasternode.self + PersistentMasternode.self ] } + /// All persistent model types in the current Dash SDK schema. + public static var modelTypes: [any PersistentModel.Type] { + v1ModelTypes + [PersistentTrackedMasternode.self] + } + /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(modelTypes) + Schema(versionedSchema: DashSchemaV2.self) } /// Create a persistent model container for storing data @@ -66,10 +71,8 @@ public enum DashModelContainer { cloudKitDatabase: cloudKit ? .automatic : .none ) - // Wire the migration plan even though V1 is the only shipped - // schema — future schema bumps just have to add a stage to - // `DashMigrationPlan.stages` without also having to remember - // to thread the plan into the container construction call. + // Always wire the migration plan so stores created by an older SDK + // advance through the registered versioned schemas. return try ModelContainer( for: schema, migrationPlan: DashMigrationPlan.self, @@ -96,11 +99,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self] + [DashSchemaV1.self, DashSchemaV2.self] } public static var stages: [MigrationStage] { - [] + [ + .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self) + ] } } @@ -231,6 +236,19 @@ public enum DashSchemaV1: VersionedSchema { Schema.Version(1, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v1ModelTypes + } +} + +/// Version 2 adds wallet-independent tracked masternodes. The new model has +/// no relationship or required-data dependency on V1 rows, so a lightweight +/// migration preserves every existing row and creates its table. +public enum DashSchemaV2: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(2, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index 1902c28b56f..0a8c08f4a15 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -123,12 +123,12 @@ extension PlatformWalletManager { return [] } - var outEntries: UnsafePointer? + var outEntries: UnsafePointer? var outCount: UInt = 0 let ffiResult = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) - return platform_wallet_manager_list_masternodes( + return platform_wallet_manager_list_masternodes_v2( handle, base, &outEntries, @@ -147,7 +147,7 @@ extension PlatformWalletManager { } defer { - platform_wallet_manager_free_masternodes( + platform_wallet_manager_free_masternodes_v2( UnsafeMutablePointer(mutating: entries), outCount ) @@ -156,16 +156,17 @@ extension PlatformWalletManager { return Self.masternodeModels(from: entries, count: Int(outCount)) } - /// Decode a Rust-owned `MasternodeEntryFFI` array into value models — + /// Decode a Rust-owned `MasternodeEntryV2FFI` array into value models — /// shared by the wallet list, the tracked list, and the tracked /// track/refresh calls. `nonisolated` so detached marshalling tasks can /// run it off the main actor. nonisolated static func masternodeModels( - from entries: UnsafePointer, + from entries: UnsafePointer, count: Int ) -> [PlatformMasternode] { (0.. [PlatformMasternode] { guard isConfigured, handle != NULL_HANDLE else { return [] } - var outEntries: UnsafePointer? + var outEntries: UnsafePointer? var outCount: UInt = 0 let ffiResult = platform_wallet_manager_list_tracked_masternodes( handle, &outEntries, &outCount) @@ -120,7 +120,7 @@ extension PlatformWalletManager { } guard let entries = outEntries, outCount > 0 else { return [] } defer { - platform_wallet_manager_free_masternodes( + platform_wallet_manager_free_masternodes_v2( UnsafeMutablePointer(mutating: entries), outCount) } return Self.masternodeModels(from: entries, count: Int(outCount)) @@ -140,7 +140,7 @@ extension PlatformWalletManager { } let handle = self.handle return try await Task.detached(priority: .userInitiated) { () -> PlatformMasternode in - var outEntries: UnsafePointer? + var outEntries: UnsafePointer? var outCount: UInt = 0 let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in platform_wallet_manager_refresh_tracked_masternode( @@ -157,7 +157,7 @@ extension PlatformWalletManager { throw PlatformWalletError.notFound("refresh returned no record") } defer { - platform_wallet_manager_free_masternodes( + platform_wallet_manager_free_masternodes_v2( UnsafeMutablePointer(mutating: entries), outCount) } guard let record = Self.masternodeModels(from: entries, count: Int(outCount)).first @@ -224,7 +224,7 @@ extension PlatformWalletManager { _ call: ( Handle, UnsafePointer?, - UnsafeMutablePointer?>, + UnsafeMutablePointer?>, UnsafeMutablePointer ) -> PlatformWalletFFIResult ) throws -> PlatformMasternode { @@ -232,7 +232,7 @@ extension PlatformWalletManager { throw PlatformWalletError.invalidParameter( "Manager not configured, or proTxHash not 32 bytes") } - var outEntries: UnsafePointer? + var outEntries: UnsafePointer? var outCount: UInt = 0 let ffiResult = proTxHash.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in call( @@ -249,7 +249,7 @@ extension PlatformWalletManager { throw PlatformWalletError.notFound("no record returned") } defer { - platform_wallet_manager_free_masternodes( + platform_wallet_manager_free_masternodes_v2( UnsafeMutablePointer(mutating: entries), outCount) } guard let record = Self.masternodeModels(from: entries, count: Int(outCount)).first else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 1347ce4d42d..6aae1f9cd0c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -94,6 +94,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `markUtxoSpent`, …) assume they are already on the queue. private let backgroundContext: ModelContext + /// Context dedicated to tracked-masternode whole-set writes. Those writes + /// are not part of a wallet changeset and must become durable before their + /// synchronous FFI callback reports success. Keeping them off + /// `backgroundContext` prevents an unrelated changeset rollback from + /// discarding a successful track/untrack/rename operation. + private let trackedMasternodeContext: ModelContext + /// Serial queue that owns `backgroundContext` and any other /// non-Sendable handler state (`loadAllocations`). All public /// entry points — both the FFI callback shims and the @@ -144,6 +151,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.network = network self.backgroundContext = ModelContext(modelContainer) self.backgroundContext.autosaveEnabled = true + self.trackedMasternodeContext = ModelContext(modelContainer) + self.trackedMasternodeContext.autosaveEnabled = false } /// Synchronously run `body` on `serialQueue`. @@ -6111,16 +6120,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Replace the stored tracked-masternode set for `networkRaw` with /// `rows` (whole-set semantics, mirroring the Rust trait contract). /// - /// Registry writes arrive OUTSIDE Rust `store()` rounds, so this - /// method saves immediately — unless a changeset round is open on the - /// shared context, in which case the round's `endChangeset` commits - /// (or rolls back) these rows together with the round. A rolled-back - /// registry write is re-issued by the next registry mutation (the - /// Rust side always writes the whole set). + /// Registry writes arrive OUTSIDE Rust `store()` rounds and use their own + /// context, so this method always saves before returning success. An + /// unrelated wallet changeset can therefore neither absorb nor roll back + /// a tracked-node mutation. func persistTrackedMasternodes(networkRaw: UInt32, rows: [TrackedMasternodeRow]) -> Bool { onQueue { do { - let existing = try backgroundContext.fetch( + let existing = try trackedMasternodeContext.fetch( FetchDescriptor( predicate: #Predicate { $0.networkRaw == networkRaw } ) @@ -6135,7 +6142,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { found.addedAt = row.addedAt found.snapshotJSON = row.snapshotJSON } else { - backgroundContext.insert(PersistentTrackedMasternode( + trackedMasternodeContext.insert(PersistentTrackedMasternode( networkRaw: networkRaw, proTxHash: row.proTxHash, label: row.label, @@ -6145,14 +6152,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } for removed in stale.values { - backgroundContext.delete(removed) - } - if !inChangeset { - try backgroundContext.save() + trackedMasternodeContext.delete(removed) } + try trackedMasternodeContext.save() return true } catch { print("⚠️ persistTrackedMasternodes: \(error)") + // A failed save leaves pending inserts/deletes registered on + // the context. Discard them before returning failure so a + // later load or successful mutation cannot expose/commit a + // registry state Rust has already rolled back. + trackedMasternodeContext.rollback() return false } } @@ -6173,7 +6183,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { descriptor.sortBy = [ SortDescriptor(\.addedAt, order: .forward) ] - rows = try backgroundContext.fetch(descriptor) + rows = try trackedMasternodeContext.fetch(descriptor) } catch { print("⚠️ loadTrackedMasternodes: \(error)") return (nil, 0, true) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift new file mode 100644 index 00000000000..efa5d73c80e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +final class DashModelMigrationTests: XCTestCase { + @MainActor + func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let v1Schema = Schema(versionedSchema: DashSchemaV1.self) + let v1Configuration = ModelConfiguration( + "DashMigrationTest", + schema: v1Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v1Container: ModelContainer? = try ModelContainer( + for: v1Schema, + configurations: [v1Configuration]) + v1Container?.mainContext.insert(PersistentKeyword( + keyword: "preserved", + contractId: "contract")) + try v1Container?.mainContext.save() + v1Container = nil + + let v2Schema = Schema(versionedSchema: DashSchemaV2.self) + let v2Configuration = ModelConfiguration( + "DashMigrationTest", + schema: v2Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v2Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v2Configuration]) + + let keywords = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) + + migrated.mainContext.insert(PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, + proTxHash: Data(repeating: 7, count: 32), + label: "new in V2", + addedAt: 1, + snapshotJSON: "{}")) + try migrated.mainContext.save() + XCTAssertEqual( + try migrated.mainContext.fetchCount( + FetchDescriptor()), + 1) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift index b9b93d6cbbe..531e1aebc27 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TrackedMasternodeTests.swift @@ -49,4 +49,46 @@ final class TrackedMasternodeTests: XCTestCase { XCTAssertEqual(rows.count, 2, "the same proTxHash may be tracked on both networks") XCTAssertEqual(Set(rows.compactMap(\.network)), [.mainnet, .testnet]) } + + @MainActor + func testTrackedWritesCommitOutsideAndSurviveChangesetRollback() throws { + let container = try ModelContainer( + for: PersistentTrackedMasternode.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + let walletId = Data(repeating: 1, count: 32) + let hash = Data(repeating: 9, count: 32) + let row = PlatformWalletPersistenceHandler.TrackedMasternodeRow( + proTxHash: hash, + label: "survives", + addedAt: 42, + snapshotJSON: #"{"v":1}"#) + + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(handler.persistTrackedMasternodes( + networkRaw: Network.testnet.rawValue, + rows: [row])) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + + var verificationContext = ModelContext(container) + var persisted = try verificationContext.fetch( + FetchDescriptor()) + XCTAssertEqual(persisted.count, 1) + XCTAssertEqual(persisted.first?.proTxHash, hash) + XCTAssertEqual(persisted.first?.label, "survives") + + // Whole-set removal has the same independent durability guarantee. + handler.beginChangeset(walletId: walletId) + XCTAssertTrue(handler.persistTrackedMasternodes( + networkRaw: Network.testnet.rawValue, + rows: [])) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + + verificationContext = ModelContext(container) + persisted = try verificationContext.fetch( + FetchDescriptor()) + XCTAssertTrue(persisted.isEmpty) + } + }