From 1a414b6bfc112d0da5a5814037e0217c5824ada6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:40:32 +0200 Subject: [PATCH] feat(platform-wallet-ffi)!: let hosts reset an outbound account's pool and keep its request digest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WYhtAUUCkvoBmp19LA8Z31 --- .../rs-platform-wallet-ffi/src/manager.rs | 22 +- .../rs-platform-wallet-ffi/src/persistence.rs | 518 +++++++++++++++++- .../Models/PersistentAccount.swift | 6 + .../PlatformWalletPersistenceHandler.swift | 142 ++++- ...ndAccountReplacementPersistenceTests.swift | 105 ++++ 5 files changed, 782 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/OutboundAccountReplacementPersistenceTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..8b4b2ae850b 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,12 +8,13 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, - PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, - PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + FFIPersister, FreeTrackedMasternodesFn, LoadAccountXpubDigestFn, LoadAssetLockOperationIdFn, + LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAccountXpubDigestFn, + PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, + PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, ResetAccountAddressPoolsFn, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -290,6 +291,15 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + reset_account_address_pools: slot!( + on_reset_account_address_pools_fn, + ResetAccountAddressPoolsFn + ), + persist_account_xpub_digest: slot!( + on_persist_account_xpub_digest_fn, + PersistAccountXpubDigestFn + ), + load_account_xpub_digest: slot!(on_load_account_xpub_digest_fn, LoadAccountXpubDigestFn), } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 19c430bc8e2..bbc30c5a0e2 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -10,7 +10,7 @@ //! same integers on a different callback family. use bincode::config; -use key_wallet::account::account_collection::AccountCollection; +use key_wallet::account::account_collection::{AccountCollection, DashpayAccountKey}; use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::DerivationPath; use key_wallet::bip32::ExtendedPubKey; @@ -319,6 +319,42 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; +/// Drop every stored address-pool row of the `count` accounts in `specs` +/// before the round's pool snapshots are written: each account is registered +/// again in the same round under another xpub, which derives other addresses +/// at the same `(pool_type, address_index)` keys. The account rows themselves +/// stay. Each spec's xpub pointer is null. Runs between the account +/// registrations and the pool snapshots of one atomic changeset. +pub type ResetAccountAddressPoolsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + specs: *const AccountSpecFFI, + count: usize, +) -> i32; + +/// Store on the account row `spec` names the 32-byte `digest` of the contact +/// request its xpub was decrypted from, replacing any previous one; a null +/// `digest` clears it, for an account registered again without one. Runs +/// after the account registrations of the same atomic changeset; the spec's +/// xpub pointer is null. +pub type PersistAccountXpubDigestFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + digest: *const u8, +) -> i32; + +/// Read the digest stored on the account row `spec` names into the 32 +/// writable bytes at `out_digest`, setting `out_found`. A row without one +/// reports `out_found = false`. +pub type LoadAccountXpubDigestFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_found: *mut bool, + out_digest: *mut u8, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -485,6 +521,39 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// See [`ResetAccountAddressPoolsFn`]. A host that persists address + /// pools must provide it: a round that replaces an account's xpub fails + /// without it, rather than leave the previous xpub's rows beside the new + /// ones. + pub on_reset_account_address_pools_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + specs: *const AccountSpecFFI, + count: usize, + ) -> i32, + >, + /// See [`PersistAccountXpubDigestFn`]. Wired together with the load slot + /// below; a host without them keeps no digest, and each outbound DashPay + /// account is then verified by decrypting after a launch. + pub on_persist_account_xpub_digest_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + digest: *const u8, + ) -> i32, + >, + /// See [`LoadAccountXpubDigestFn`]. + pub on_load_account_xpub_digest_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + out_found: *mut bool, + out_digest: *mut u8, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -505,6 +574,9 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_reset_account_address_pools_fn: None, + on_persist_account_xpub_digest_fn: None, + on_load_account_xpub_digest_fn: None, } } } @@ -526,6 +598,9 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub reset_account_address_pools: Option, + pub persist_account_xpub_digest: Option, + pub load_account_xpub_digest: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -2063,6 +2138,78 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Digests of the requests outbound DashPay accounts were built from: + // a sidecar on the account rows registered just above. It is written + // only where the round is atomic, so a digest can never land beside + // an xpub write that failed; a host without the slot keeps none. Every + // outbound registration writes it, and one without a digest clears + // the previous xpub's. + if let (Some(cb), Some(_), true) = ( + self.tracked_masternodes_callbacks + .persist_account_xpub_digest, + self.callbacks.on_persist_account_registrations_fn, + self.callback_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS), + ) { + for entry in &changeset.account_registrations { + if !matches!( + entry.account_type, + AccountType::DashpayExternalAccount { .. } + ) { + continue; + } + let spec = build_account_spec_ffi(&entry.account_type, &[]); + let digest = entry + .encrypted_xpub_digest + .as_ref() + .map_or(std::ptr::null(), |digest| digest.as_ptr()); + let rc = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &spec, digest) }; + if rc != 0 { + eprintln!("Account xpub digest persistence callback returned error code {rc}"); + outcome.record(rc); + } + } + } + + // Accounts registered again under another xpub drop their stored + // pool rows before this round's snapshots land. A host that keeps + // pools but cannot drop them fails the round instead of leaving the + // previous xpub's rows beside the new ones. + if !changeset.account_pool_resets.is_empty() { + match self + .tracked_masternodes_callbacks + .reset_account_address_pools + { + Some(cb) => { + let specs: Vec = changeset + .account_pool_resets + .iter() + .map(|account_type| build_account_spec_ffi(account_type, &[])) + .collect(); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + specs.as_ptr(), + specs.len(), + ) + }; + if rc != 0 { + eprintln!("Account pool reset callback returned error code {rc}"); + outcome.record(rc); + } + } + None if self.callbacks.on_persist_account_address_pools_fn.is_some() => { + eprintln!( + "Account pool reset requested, but the host persists address pools \ + without the reset callback" + ); + outcome.record(1); + } + None => {} + } + } + // Per-account address-pool snapshots. The `_string_storage` // Vec keeps every owned `CString` alive for the callback // window; `_address_storage` keeps every per-pool @@ -3465,6 +3612,14 @@ impl PlatformWalletPersistence for FFIPersister { )); } let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + if let Some(cb) = self.tracked_masternodes_callbacks.load_account_xpub_digest { + load_account_xpub_digests( + cb, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state, + ); + } if let Some(cb) = self .tracked_masternodes_callbacks .load_asset_lock_operation_id @@ -6185,8 +6340,7 @@ fn build_wallet_start_state( unused_asset_locks, unconfirmed_outgoing_txs, dashpay_backfill, - // This host keeps no outbound account digests: each outbound account - // is confirmed by decrypting after a launch. + // Filled from the digest sidecar by the caller when the host keeps it. outbound_account_digests: BTreeMap::new(), }; @@ -6208,6 +6362,60 @@ fn build_wallet_start_state( Ok((wallet_state, platform_address_state)) } +/// Read the digest each restored outbound DashPay account row carries into +/// `wallet_state`. The digest only spares a rebuild, so a read the host fails +/// does not fail the load: the account is handed over with an empty digest, +/// which proves nothing, and payments to the contact wait until it is +/// rebuilt or confirmed. +fn load_account_xpub_digests( + callback: LoadAccountXpubDigestFn, + context: *mut c_void, + wallet_id: &WalletId, + wallet_state: &mut ClientWalletStartState, +) { + let keys: Vec = wallet_state + .wallet + .accounts + .dashpay_external_accounts + .keys() + .copied() + .collect(); + for key in keys { + let spec = build_account_spec_ffi( + &AccountType::DashpayExternalAccount { + index: key.index, + user_identity_id: key.user_identity_id, + friend_identity_id: key.friend_identity_id, + }, + &[], + ); + let mut found = false; + let mut digest = [0u8; 32]; + let rc = unsafe { + callback( + context, + wallet_id.as_ptr(), + &spec, + &mut found, + digest.as_mut_ptr(), + ) + }; + if rc != 0 { + tracing::warn!( + code = rc, + "loading an outbound account's xpub digest failed; the account awaits a rebuild" + ); + wallet_state + .outbound_account_digests + .insert(key, Vec::new()); + } else if found { + wallet_state + .outbound_account_digests + .insert(key, digest.to_vec()); + } + } +} + /// Rehydrate immutable UUID sidecars after decoding the frozen legacy rows. /// A failed sidecar read or ambiguous association invalidates the whole load. fn load_asset_lock_operation_ids( @@ -9888,6 +10096,280 @@ mod tests { } } + /// A replaced outbound account reaches the host as one atomic round: + /// its row, then the digest beside it, then the reset of its stored pool, + /// then the new pool. A host that keeps pools without the reset slot fails + /// the round; one without the digest slot still stores the rest. + #[test] + fn should_store_a_replaced_outbound_account_in_order_and_refuse_without_a_reset() { + type Log = Mutex>; + unsafe extern "C" fn registrations( + context: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + count: usize, + ) -> i32 { + (*(context as *const Log)) + .lock() + .push(format!("registrations {count}")); + 0 + } + unsafe extern "C" fn pools( + context: *mut c_void, + _: *const u8, + _: *const AccountAddressPoolFFI, + count: usize, + ) -> i32 { + (*(context as *const Log)) + .lock() + .push(format!("pools {count}")); + 0 + } + unsafe extern "C" fn digest( + context: *mut c_void, + _: *const u8, + spec: *const AccountSpecFFI, + digest: *const u8, + ) -> i32 { + let digest = if digest.is_null() { + "none".to_owned() + } else { + format!("{:02x}", *digest) + }; + (*(context as *const Log)).lock().push(format!( + "digest {:02x} {digest}", + (*spec).friend_identity_id[0] + )); + 0 + } + unsafe extern "C" fn reset( + context: *mut c_void, + _: *const u8, + specs: *const AccountSpecFFI, + count: usize, + ) -> i32 { + let specs = slice::from_raw_parts(specs, count); + (*(context as *const Log)) + .lock() + .push(format!("reset {:02x}", specs[0].friend_identity_id[0])); + 0 + } + + let account_type = AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0x0A; 32], + friend_identity_id: [0x0B; 32], + }; + let xpub = key_wallet::Wallet::from_seed_bytes( + [0x41; 64], + key_wallet::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("wallet") + .accounts + .all_accounts()[0] + .account_xpub; + let round = PlatformWalletChangeSet { + account_registrations: vec![AccountRegistrationEntry { + account_type, + account_xpub: xpub, + encrypted_xpub_digest: Some([0xD1; 32]), + }], + account_address_pools: vec![platform_wallet::changeset::AccountAddressPoolEntry { + account_type, + pool_type: key_wallet::managed_account::address_pool::AddressPoolType::Absent, + addresses: Vec::new(), + }], + account_pool_resets: vec![account_type], + ..Default::default() + }; + let log = Log::default(); + let callbacks = || PersistenceCallbacks { + context: std::ptr::addr_of!(log).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_persist_account_registrations_fn: Some(registrations), + on_persist_account_address_pools_fn: Some(pools), + ..Default::default() + }; + let extensions = PersistenceExtensionCallbacks { + reset_account_address_pools: Some(reset), + persist_account_xpub_digest: Some(digest), + ..Default::default() + }; + + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks(), + PersistenceCapabilities::NONE, + extensions, + ) + .store([42; 32], round.clone()) + .expect("a replacement round stores"); + assert_eq!( + std::mem::take(&mut *log.lock()), + vec!["registrations 1", "digest 0b d1", "reset 0b", "pools 1"] + ); + + assert!( + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks(), + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + reset_account_address_pools: None, + ..extensions + }, + ) + .store([42; 32], round.clone()) + .is_err(), + "a host that keeps pools cannot drop a reset" + ); + log.lock().clear(); + + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks(), + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_account_xpub_digest: None, + ..extensions + }, + ) + .store([42; 32], round.clone()) + .expect("a host without the digest slot keeps no digest"); + assert_eq!( + std::mem::take(&mut *log.lock()), + vec!["registrations 1", "reset 0b", "pools 1"] + ); + + // A host whose rounds are not atomic could keep the digest beside an + // xpub write that failed, so it is never handed one. + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + on_changeset_begin_fn: None, + on_changeset_end_fn: None, + ..callbacks() + }, + PersistenceCapabilities::NONE, + extensions, + ) + .store([42; 32], round.clone()) + .expect("a non-atomic round stores"); + assert_eq!( + std::mem::take(&mut *log.lock()), + vec!["registrations 1", "reset 0b", "pools 1"] + ); + + // An outbound registration without a digest clears the stored one, + // and a host that keeps no pools has none to reset. + let mut without = round; + without.account_registrations[0].encrypted_xpub_digest = None; + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + on_persist_account_address_pools_fn: None, + ..callbacks() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + reset_account_address_pools: None, + ..extensions + }, + ) + .store([42; 32], without) + .expect("nothing to reset without pools"); + assert_eq!( + log.lock().clone(), + vec!["registrations 1", "digest 0b none"] + ); + } + + #[test] + fn should_read_the_digest_of_each_restored_outbound_account() { + unsafe extern "C" fn load( + _: *mut c_void, + wallet_id: *const u8, + spec: *const AccountSpecFFI, + found: *mut bool, + digest: *mut u8, + ) -> i32 { + assert_eq!(slice::from_raw_parts(wallet_id, 32), &[42; 32]); + assert_eq!( + (*spec).type_tag, + AccountTypeTagFFI::DashpayExternalAccount as u8 + ); + // Only the second contact's row carries one. + *found = (*spec).friend_identity_id == [0x0C; 32]; + if *found { + std::ptr::write_bytes(digest, 0xD2, 32); + } + 0 + } + let mut wallet = key_wallet::Wallet::from_seed_bytes( + [0x41; 64], + key_wallet::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::None, + ) + .expect("wallet"); + let xpub = key_wallet::Wallet::from_seed_bytes( + [0x42; 64], + key_wallet::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("wallet") + .accounts + .all_accounts()[0] + .account_xpub; + for friend in [0x0B, 0x0C] { + wallet + .add_account( + AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0x0A; 32], + friend_identity_id: [friend; 32], + }, + Some(xpub), + ) + .expect("contact account"); + } + let mut state = ClientWalletStartState { + wallet_info: key_wallet::wallet::ManagedWalletInfo::from_wallet(&wallet, 0), + wallet, + identity_manager: Default::default(), + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), + outbound_account_digests: BTreeMap::new(), + }; + + load_account_xpub_digests(load, std::ptr::null_mut(), &[42; 32], &mut state); + + let key = |friend: u8| DashpayAccountKey { + index: 0, + user_identity_id: [0x0A; 32], + friend_identity_id: [friend; 32], + }; + assert_eq!( + state.outbound_account_digests, + BTreeMap::from([(key(0x0C), vec![0xD2; 32])]) + ); + + // A read the host fails does not fail the load: the account is handed + // over with an empty digest, which proves nothing. + unsafe extern "C" fn failing( + _: *mut c_void, + _: *const u8, + _: *const AccountSpecFFI, + _: *mut bool, + _: *mut u8, + ) -> i32 { + -1 + } + state.outbound_account_digests.clear(); + load_account_xpub_digests(failing, std::ptr::null_mut(), &[42; 32], &mut state); + assert_eq!( + state.outbound_account_digests, + BTreeMap::from([(key(0x0B), Vec::new()), (key(0x0C), Vec::new())]) + ); + } + #[test] fn should_require_explicit_operation_id_persistence_attestation() { unsafe extern "C" fn persist( @@ -10260,6 +10742,36 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_reset_account_address_pools_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_reset_account_address_pools_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_account_xpub_digest_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_account_xpub_digest_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_account_xpub_digest_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_account_xpub_digest_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift index 32e01382277..17b7b9cfad7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift @@ -94,6 +94,12 @@ public final class PersistentAccount { /// `nil` values, so freshly-inserted unhydrated rows don't /// conflict. @Attribute(.unique) public var accountExtendedPubKeyBytes: Data? + /// For an outbound DashPay account (`accountType == 13`): the 32-byte + /// digest of the contact request its xpub was decrypted from, written in + /// the same round as the xpub. `nil` for every other account, and for + /// rows stored before the digest existed; Rust then verifies the account + /// by decrypting after a launch. + public var encryptedXpubDigest: Data? /// Record timestamps. public var createdAt: Date public var lastUpdated: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 8089d826022..06c939c1532 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -18,6 +18,12 @@ protocol ModelFetching: Sendable { ) throws -> [T] } +private enum AccountSidecarPersistenceError: Error { + case requiresChangeset + case missingAccount + case malformedDigest +} + private enum AssetLockPersistenceError: Error { case malformedRecord case duplicateOperationId @@ -697,6 +703,62 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// The account row `key` names. A failed fetch or a missing row throws: + /// every caller acts on a row registered earlier in the round, so its + /// absence is an error, never "nothing to do". + private func requireAccount(walletId: Data, key: AccountLookupKey) throws -> PersistentAccount { + guard let account = try matchingAccount(walletId: walletId, key: key) else { + throw AccountSidecarPersistenceError.missingAccount + } + return account + } + + /// Drop every stored pool row of the accounts `keys` names, before the + /// round's pool snapshots land: each account was registered again in the + /// same round under another xpub. TXOs linked to a dropped row are kept and + /// unlinked, so the reset never deletes funds history. + func resetAccountAddressPools(walletId: Data, keys: [AccountLookupKey]) throws { + try onQueue { + guard inChangeset else { throw AccountSidecarPersistenceError.requiresChangeset } + // The registration drops its write for a wallet removed meanwhile; + // so do the sidecars of its round. + guard findWalletRecord(walletId: walletId) != nil else { return } + for key in keys { + let account = try requireAccount(walletId: walletId, key: key) + for row in account.coreAddresses { + for txo in row.txos { + txo.coreAddress = nil + // Registered so a later read in this round sees the + // unlinked object, not the stored link to a deleted row. + roundIndex?.txosByOutpoint[txo.outpoint] = txo + } + roundIndex?.coreAddressesByAddress[row.address] = nil + backgroundContext.delete(row) + } + } + } + } + + /// Store the digest of the contact request an outbound DashPay account was + /// built from on its row, registered earlier in the same round; `nil` + /// clears it. + func persistAccountXpubDigest(walletId: Data, key: AccountLookupKey, digest: Data?) throws { + try onQueue { + guard inChangeset else { throw AccountSidecarPersistenceError.requiresChangeset } + if let digest, digest.count != 32 { throw AccountSidecarPersistenceError.malformedDigest } + guard findWalletRecord(walletId: walletId) != nil else { return } + try requireAccount(walletId: walletId, key: key).encryptedXpubDigest = digest + } + } + + /// The digest stored on the account row `key` names; `nil` when the row + /// has none. A missing row is a failed read. + func loadAccountXpubDigest(walletId: Data, key: AccountLookupKey) throws -> Data? { + try onQueue { + try requireAccount(walletId: walletId, key: key).encryptedXpubDigest + } + } + /// A missing base row is a failed read, not evidence of a legacy nil UUID. func loadAssetLockOperationId(walletId: Data, outPoint: Data) throws -> Data? { try onQueue { @@ -3296,6 +3358,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + extensionCallbacks.on_reset_account_address_pools_fn = resetAccountAddressPoolsCallback + extensionCallbacks.on_persist_account_xpub_digest_fn = persistAccountXpubDigestCallback + extensionCallbacks.on_load_account_xpub_digest_fn = loadAccountXpubDigestCallback return extensionCallbacks } @@ -5632,6 +5697,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: Data, key: AccountLookupKey ) -> PersistentAccount? { + (try? matchingAccount(walletId: walletId, key: key)) ?? nil + } + + /// The account row matching the full identity tuple; a failed fetch + /// throws. + private func matchingAccount( + walletId: Data, + key: AccountLookupKey + ) throws -> PersistentAccount? { let typeTag = key.typeTag let index = key.index let descriptor = FetchDescriptor( @@ -5641,8 +5715,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && $0.accountIndex == index } ) - let matches = (try? backgroundContext.fetch(descriptor)) ?? [] - return matches.first { acc in + return try backgroundContext.fetch(descriptor).first { acc in acc.standardTag == key.standardTag && acc.registrationIndex == key.registrationIndex && acc.keyClass == key.keyClass @@ -11859,6 +11932,71 @@ extension PlatformWalletPersistenceHandler { } } +private func accountLookupKey( + _ spec: AccountSpecFFI +) -> PlatformWalletPersistenceHandler.AccountLookupKey { + PlatformWalletPersistenceHandler.AccountLookupKey( + typeTag: UInt32(spec.type_tag), + index: spec.index, + standardTag: spec.standard_tag, + registrationIndex: spec.registration_index, + keyClass: spec.key_class, + userIdentityId: withUnsafeBytes(of: spec.user_identity_id) { Data($0) }, + friendIdentityId: withUnsafeBytes(of: spec.friend_identity_id) { Data($0) } + ) +} + +private func resetAccountAddressPoolsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + specs: UnsafePointer?, count: UInt +) -> Int32 { + guard let context, let walletId else { return -1 } + guard count > 0 else { return 0 } + guard let specs else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.resetAccountAddressPools( + walletId: Data(bytes: walletId, count: 32), + keys: (0..?, + spec: UnsafePointer?, digest: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let spec else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistAccountXpubDigest( + walletId: Data(bytes: walletId, count: 32), key: accountLookupKey(spec.pointee), + digest: digest.map { Data(bytes: $0, count: 32) }) + return 0 + } catch { return -1 } +} + +private func loadAccountXpubDigestCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + spec: UnsafePointer?, outFound: UnsafeMutablePointer?, + outDigest: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let spec, let outFound, let outDigest else { return -1 } + outFound.pointee = false + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + guard let digest = try handler.loadAccountXpubDigest( + walletId: Data(bytes: walletId, count: 32), key: accountLookupKey(spec.pointee)) + else { return 0 } + // A digest that does not fit the 32-byte slot is a failed read, which + // Rust treats as proving nothing about the account. + guard digest.count == 32 else { return -1 } + digest.copyBytes(to: outDigest, count: 32) + outFound.pointee = true + return 0 + } catch { return -1 } +} + private func persistAssetLockOperationIdCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, operationId: UnsafePointer? diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/OutboundAccountReplacementPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/OutboundAccountReplacementPersistenceTests.swift new file mode 100644 index 00000000000..d2ee63c5b08 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/OutboundAccountReplacementPersistenceTests.swift @@ -0,0 +1,105 @@ +import Foundation +import SwiftData +import XCTest +@testable import SwiftDashSDK + +/// An outbound DashPay account (`accountType == 13`) registered again under +/// another xpub drops its stored pool, and keeps the digest of the contact +/// request it was built from beside its xpub. +@MainActor +final class OutboundAccountReplacementPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 0x0A, count: 32) + private let outboundTypeTag: UInt32 = 13 + private let owner = Data(repeating: 0x01, count: 32) + private let contact = Data(repeating: 0x02, count: 32) + + private var key: PlatformWalletPersistenceHandler.AccountLookupKey { + .init(typeTag: outboundTypeTag, index: 0, standardTag: 0, registrationIndex: 0, + keyClass: 0, userIdentityId: owner, friendIdentityId: contact) + } + + /// A wallet with the outbound account, two pool rows of the previous + /// xpub, and a TXO linked to one of them. + private func seed(_ container: ModelContainer) throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, accountType: outboundTypeTag, accountIndex: 0, accountTypeName: "Contact") + account.userIdentityId = owner + account.friendIdentityId = contact + context.insert(account) + for index in 0..<2 { + let row = PersistentCoreAddress( + address: "yOldXpubAddress\(index)", poolTypeTag: 0, addressIndex: UInt32(index), + derivationPath: "m/\(index)", isUsed: index == 0) + row.account = account + context.insert(row) + } + let tx = PersistentTransaction(txid: Data(repeating: 7, count: 32), transactionData: Data()) + context.insert(tx) + context.insert(PersistentTxo(transaction: tx, vout: 0, amount: 1_000, address: "yOldXpubAddress0")) + try context.save() + let txo = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + txo.coreAddress = try XCTUnwrap(context.fetch(FetchDescriptor( + predicate: #Predicate { $0.address == "yOldXpubAddress0" })).first) + try context.save() + } + + func testResetDropsThePreviousXpubsRowsAndKeepsTheirTxos() throws { + let container = try DashModelContainer.createInMemory() + try seed(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + + XCTAssertThrowsError(try handler.resetAccountAddressPools(walletId: walletId, keys: [key]), + "a reset outside a changeset could not roll back with its round") + handler.beginChangeset(walletId: walletId) + try handler.resetAccountAddressPools(walletId: walletId, keys: [key]) + XCTAssertTrue(handler.persistAccountAddresses(walletId: walletId, accountKey: key, entries: [ + .init(address: "yNewXpubAddress0", publicKey: Data(count: 33), keyType: 0, poolTypeTag: 0, + addressIndex: 0, isUsed: false, balance: 0, derivationPath: "m/0") + ])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + let context = ModelContext(container) + XCTAssertEqual(try context.fetch(FetchDescriptor()).map(\.address), + ["yNewXpubAddress0"]) + let txos = try context.fetch(FetchDescriptor()) + XCTAssertEqual(txos.count, 1, "the reset keeps funds history") + XCTAssertNil(txos.first?.coreAddress) + } + + func testDigestSurvivesStoreReopenOnTheAccountRowAndFailsClosed() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let digest = Data(repeating: 0xD1, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + try seed(container) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertNil(try handler.loadAccountXpubDigest(walletId: walletId, key: key)) + XCTAssertThrowsError(try handler.persistAccountXpubDigest(walletId: walletId, key: key, digest: digest)) + handler.beginChangeset(walletId: walletId) + XCTAssertThrowsError(try handler.persistAccountXpubDigest( + walletId: walletId, key: key, digest: Data(repeating: 0xD1, count: 31))) + try handler.persistAccountXpubDigest(walletId: walletId, key: key, digest: digest) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + XCTAssertEqual(try handler.loadAccountXpubDigest(walletId: walletId, key: key), digest) + + // An account registered again without a digest clears the previous one. + handler.beginChangeset(walletId: walletId) + try handler.persistAccountXpubDigest(walletId: walletId, key: key, digest: nil) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertNil(try handler.loadAccountXpubDigest(walletId: walletId, key: key)) + let missing = PlatformWalletPersistenceHandler.AccountLookupKey(typeTag: outboundTypeTag, index: 0, standardTag: 0, registrationIndex: 0, + keyClass: 0, userIdentityId: owner, friendIdentityId: Data(repeating: 9, count: 32)) + XCTAssertThrowsError(try handler.loadAccountXpubDigest(walletId: walletId, key: missing), + "a missing account row is a failed read") + } +}