diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index a19288f4128..a21784eaf86 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -5,6 +5,8 @@ use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -14,6 +16,60 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; +/// Return the last certified Core height to keep when adding a contact account. +/// DIP-15 records height `H` so recovery resumes at `H + 1`; locally rotated +/// relationships fall back to wallet birth because their original `H` is gone. +pub(super) fn contact_scan_checkpoint( + info: &crate::wallet::PlatformWalletInfo, + owner: &Identifier, + contact: &Identifier, +) -> u32 { + let birth_checkpoint = info.core_wallet.birth_height().saturating_sub(1); + let Some(managed) = info.identity_manager.managed_identity(owner) else { + return birth_checkpoint; + }; + let dashpay = managed.dashpay(); + + let mut requests = Vec::with_capacity(2); + if let Some(established) = dashpay.established_contacts().get(contact) { + requests.push(&established.outgoing_request); + requests.push(&established.incoming_request); + } + requests.extend(dashpay.sent_contact_requests().get(contact)); + requests.extend(dashpay.incoming_contact_requests().get(contact)); + let request_checkpoint = (!requests.is_empty() + && requests + .iter() + .all(|request| request.account_reference >> 28 == 0)) + .then(|| { + requests + .iter() + .map(|request| request.core_height_created_at) + .min() + .unwrap_or(0) + }); + + request_checkpoint + .unwrap_or(birth_checkpoint) + .max(birth_checkpoint) +} + +fn add_managed_contact_account( + info: &mut crate::wallet::PlatformWalletInfo, + wallet: &key_wallet::Wallet, + account_type: AccountType, + scan_checkpoint: u32, +) -> key_wallet::Result<()> { + let previous_checkpoint = info.core_wallet.synced_height(); + // Upstream adds the account, bumps the scanner generation, and rewinds to + // wallet birth. Under this same manager write lock, restore only the range + // certified for the new account while preserving any deeper pending scan. + info.add_managed_account(wallet, account_type)?; + info.core_wallet + .update_synced_height(previous_checkpoint.min(scan_checkpoint)); + Ok(()) +} + /// Build the persistence round for a newly registered DashPay account /// (`DashpayReceivingFunds` / `DashpayExternalAccount`): the /// [`AccountRegistrationEntry`] plus the account's initial address-pool @@ -203,9 +259,9 @@ impl DashPayView<'_, B> { is_watch_only: false, }; - // DashPay accounts are funds-bearing; use the typed - // `insert_funds_bearing_account` API exposed by the post-split - // collection rather than wrapping in `OwnedManagedCoreAccount`. + // Build the initial funds-bearing state for persistence. The live + // insertion below goes through `ManagedAccountOperations` so upstream + // also invalidates the wallet's prior filter-scan generation. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); // Persist the registration BEFORE the in-memory inserts: a store @@ -227,6 +283,7 @@ impl DashPayView<'_, B> { let (wallet, info) = wm .get_wallet_mut_and_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let scan_checkpoint = contact_scan_checkpoint(info, our_identity_id, contact_identity_id); // Mirror the restored shape: the immutable `wallet.accounts` // collection holds the Account (like `build_wallet_start_state` @@ -239,14 +296,22 @@ impl DashPayView<'_, B> { "Failed to add contact account to wallet: {e}" )) })?; - info.core_wallet - .accounts - .insert_funds_bearing_account(managed) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register contact account: {e}" - )) - })?; + add_managed_contact_account(info, wallet, account_type, scan_checkpoint).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register contact account: {e}" + )) + })?; + if let Some(managed) = info.identity_manager.managed_identity_mut(our_identity_id) { + if managed + .dashpay() + .established_contacts() + .contains_key(contact_identity_id) + { + managed + .dashpay_rescan_triggered_mut() + .insert(*contact_identity_id); + } + } tracing::info!( our_identity = %our_identity_id, @@ -534,8 +599,9 @@ impl DashPayView<'_, B> { is_watch_only: true, }; - // DashpayExternalAccount is funds-bearing; insert via the - // typed `insert_funds` API after the upstream split. + // Build the initial funds-bearing state for persistence. The live + // insertion below goes through `ManagedAccountOperations` so upstream + // also invalidates the wallet's prior filter-scan generation. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); // Persist the registration BEFORE the in-memory inserts (same @@ -562,6 +628,7 @@ impl DashPayView<'_, B> { self.wallet_id, ))) })?; + let scan_checkpoint = contact_scan_checkpoint(info, our_identity_id, &contact_identity_id); // (a) Insert Account into the immutable wallet account collection so the // xpub is accessible by `send_payment`. @@ -574,16 +641,13 @@ impl DashPayView<'_, B> { ))) })?; - // (b) Insert ManagedCoreFundsAccount for address-pool tracking. - info.core_wallet - .accounts - .insert_funds_bearing_account(managed) - .map_err(|e| { - Transient(PlatformWalletError::InvalidIdentityData(format!( - "Failed to register external contact account: {}", - e - ))) - })?; + // (b) Insert the managed account and invalidate prior filter coverage. + add_managed_contact_account(info, wallet, account_type, scan_checkpoint).map_err(|e| { + Transient(PlatformWalletError::InvalidIdentityData(format!( + "Failed to register external contact account: {}", + e + ))) + })?; tracing::info!( our_identity = %our_identity_id, 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 89f982f0db5..4f236f8de8f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -83,7 +83,7 @@ impl DashPayView<'_, B> { /// block is silently missed. /// /// This lowers the wallet's SPV `synced_height` to the minimum - /// `$coreHeightCreatedAt` across established receival contacts that haven't + /// `$coreHeightCreatedAt` across registered receival contacts that haven't /// been rescanned yet — the filter manager (`dash-spv`) then re-downloads /// nothing it already has, re-matches the now-larger script set, and /// re-requests the matching blocks. Each contact is recorded in @@ -108,12 +108,10 @@ impl DashPayView<'_, B> { return Ok(None); }; + // A zero checkpoint already requests a scan from genesis; candidates are + // still processed so they are marked as covered and do not trigger a + // redundant funding-height rewind once that scan advances. let synced_height = info.core_wallet.synced_height(); - // 0 means "scan from genesis / not yet started" — already a full - // historical scan, nothing to backfill toward. - if synced_height == 0 { - return Ok(None); - } // (owner, contact) pairs that have a receival account — we can only // watch a contact's incoming addresses once its receival account exists. @@ -130,13 +128,11 @@ impl DashPayView<'_, B> { }) .collect(); - // Candidates: established receival contacts not yet rescanned this - // lifetime whose funding height is below our scan tip. The floor is the - // minimum funding height — one rewind covers them all (deeper-funded - // contacts are in the watch set, so the backfill matches them too). The - // funding height is `min(outgoing, incoming)` of the pair: the channel - // is payable only once both requests exist, so the earlier of the two is - // the conservative-correct lower bound. + // Candidates: receival contacts not yet rescanned this + // lifetime whose required checkpoint is below our scan tip. One rewind + // to the minimum checkpoint covers them all. Fresh relationships use + // the earliest request's DIP-15 Core height; rotations whose original + // request height is no longer present fall back to wallet birth. let mut floor: Option = None; let mut to_mark: Vec<(Identifier, Identifier)> = Vec::new(); for (owner, contact) in receival_pairs { @@ -146,13 +142,7 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } - let Some(established) = managed.dashpay().established_contacts().get(&contact) else { - continue; - }; - let funding = established - .outgoing_request - .core_height_created_at - .min(established.incoming_request.core_height_created_at); + let checkpoint = super::contacts::contact_scan_checkpoint(info, &owner, &contact); // Contacts funded below the tip need a backfill — their addresses // weren't watched when those blocks were first scanned. Contacts // funded at or after the tip are already covered by the ongoing @@ -161,8 +151,8 @@ impl DashPayView<'_, B> { // forward pointer later climbs past a still-forward-covered // contact's funding height, the recurring sweep must NOT then // rewind to it and redundantly re-scan an already-scanned range. - if funding < synced_height { - floor = Some(floor.map_or(funding, |cur| cur.min(funding))); + if checkpoint < synced_height { + floor = Some(floor.map_or(checkpoint, |cur| cur.min(checkpoint))); } to_mark.push((owner, contact)); } @@ -1689,6 +1679,7 @@ mod tests { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::Network; use crate::changeset::{ @@ -2322,10 +2313,41 @@ mod tests { /// (`load: ... dropped_no_account`). #[tokio::test] async fn register_contact_account_persists_account_registration() { + use crate::wallet::identity::ContactRequest; + let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity(owner.to_buffer()), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed owner") + .apply_sent_contact_request(ContactRequest::new( + owner, + contact, + 0, + 0, + 0, + vec![0u8; 96], + 100, + 0, + )); + info.core_wallet.update_synced_height(1_000); + } + persister.stores.lock().unwrap().clear(); { @@ -2343,6 +2365,25 @@ mod tests { .expect("register_contact_account"); } + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .account_generation(), + 1, + "registering a contact account must invalidate the prior filter-scan generation" + ); + assert_eq!( + wm.get_wallet_info(&wallet_id) + .expect("info") + .synced_height(), + 100, + "DIP-15 coreHeight is the certified checkpoint; scanning resumes at H + 1" + ); + } + { let stores = persister.stores.lock().unwrap(); let registered = stores.iter().any(|(_, cs)| { @@ -2367,6 +2408,7 @@ mod tests { // Re-registering must be a no-op (no duplicate persistence round). persister.stores.lock().unwrap().clear(); + set_synced_height(&manager, wallet_id, 800).await; { let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); wallet @@ -2381,15 +2423,106 @@ mod tests { .await .expect("re-register is a no-op"); } - let stores = persister.stores.lock().unwrap(); - assert!( - stores - .iter() - .all(|(_, cs)| cs.account_registrations.is_empty()), - "re-registering an existing contact account must not re-persist" + { + let stores = persister.stores.lock().unwrap(); + assert!( + stores + .iter() + .all(|(_, cs)| cs.account_registrations.is_empty()), + "re-registering an existing contact account must not re-persist" + ); + } + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.account_generation(), + 1, + "a duplicate must not bump generation" + ); + assert_eq!( + info.synced_height(), + 800, + "a duplicate must not rewind scanning" ); } + #[tokio::test] + async fn contact_registration_preserves_deeper_scan_and_falls_back_when_unknown() { + use crate::wallet::identity::ContactRequest; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let pending = Identifier::from([0xB1; 32]); + let unknown = Identifier::from([0xB2; 32]); + let rotated = Identifier::from([0xB3; 32]); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity(owner.to_buffer()), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed owner") + .apply_sent_contact_request(ContactRequest::new( + owner, + pending, + 0, + 0, + 0, + vec![0; 96], + 200, + 0, + )); + info.core_wallet.update_synced_height(50); + } + + iw.dashpay() + .register_contact_account(&owner, &pending, 0, test_receiving_xpub(&owner, &pending)) + .await + .expect("register during deeper rescan"); + assert_eq!(synced_height(&manager, wallet_id).await, 50); + + set_synced_height(&manager, wallet_id, 1_000).await; + iw.dashpay() + .register_contact_account(&owner, &unknown, 0, test_receiving_xpub(&owner, &unknown)) + .await + .expect("register without request height"); + assert_eq!(synced_height(&manager, wallet_id).await, 0); + + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed owner") + .apply_sent_contact_request(ContactRequest::new( + owner, + rotated, + 0, + 0, + 1 << 28, + vec![0; 96], + 900, + 0, + )); + info.core_wallet.update_synced_height(1_000); + } + iw.dashpay() + .register_contact_account(&owner, &rotated, 0, test_receiving_xpub(&owner, &rotated)) + .await + .expect("register rotated relationship"); + assert_eq!(synced_height(&manager, wallet_id).await, 0); + } + /// 2. Reconcile derives `Received` entries from receival-account /// UTXOs (restores payment history after relaunch / missed events), /// and 3. is idempotent across passes. @@ -2794,6 +2927,94 @@ mod tests { } } + /// A one-way outgoing request already publishes our receiving xpub. After + /// restore, its account therefore needs the same historical coverage even + /// before the contact reciprocates. Establishment must invalidate the + /// one-way guard so an older newly-known request height can deepen the + /// pending scan. + #[tokio::test] + async fn rescan_covers_restored_sent_only_account_and_reestablishment() { + use crate::wallet::identity::ContactRequest; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .apply_sent_contact_request(ContactRequest::new( + owner, + contact, + 0, + 0, + 0, + vec![0; 96], + 100, + 0, + )); + } + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register sent-only receival account"); + + // A restored high-water checkpoint must be lowered even though the + // reciprocal request has not arrived yet. + set_synced_height(&manager, wallet_id, 1_000).await; + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("sent-only rescan"), + Some(100) + ); + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("sent-only retry"), + None, + "the same pending relationship must not restart its backfill" + ); + + // Learning the reciprocal request changes the safe lower bound. The + // state transition clears the guard, allowing one deeper reconciliation. + { + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .add_incoming_contact_request( + ContactRequest::new(contact, owner, 0, 0, 0, vec![0; 96], 50, 0), + &p, + ) + .expect("establish contact"); + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .core_wallet + .update_synced_height(1_000); + } + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("established rescan"), + Some(50) + ); + } + /// Register a receival account for `(owner, contact)` and insert an /// established contact funded at `out_height`/`in_height`. The owner managed /// identity is added on first use. @@ -2952,8 +3173,16 @@ mod tests { "all candidates marked -> no re-trigger" ); - // A newly discovered, older-funded contact re-lowers exactly once... + // Adding another account now invalidates upstream filter coverage and + // rewinds directly to the wallet birth floor. Reconcile recognizes that + // this full-history scan already covers the contact and marks it without + // a second, shallower rewind. establish_receival_contact(&manager, &persister, wallet_id, owner, c_c, 50, 50).await; + assert_eq!( + synced_height(&manager, wallet_id).await, + 0, + "new account insertion rewinds filter coverage to the wallet birth floor" + ); assert_eq!( iw_wallet .identity() @@ -2961,10 +3190,10 @@ mod tests { .reconcile_dashpay_rescan() .await .expect("rescan 3"), - Some(50), - "a new older contact re-lowers to its funding height" + None, + "the already-scheduled full-history scan needs no second rewind" ); - // ...then settles. + // The contact was marked while the checkpoint was zero, so it settles. assert_eq!( iw_wallet .identity() @@ -2978,8 +3207,8 @@ mod tests { } /// `synced_height == 0` means "scan from genesis / not started" — already a - /// full historical scan, so the rescan is a no-op (the masking path the spec - /// warns about). + /// full historical scan. Reconcile leaves the height alone but marks the + /// contact so advancing that scan does not cause a redundant rewind. #[tokio::test] async fn rescan_is_a_noop_when_synced_height_is_zero() { let (manager, persister, wallet_id) = make_wallet().await; @@ -3001,6 +3230,19 @@ mod tests { "synced_height 0 -> no rescan" ); assert_eq!(synced_height(&manager, wallet_id).await, 0); + + set_synced_height(&manager, wallet_id, 200).await; + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan after forward progress"), + None, + "genesis-covered contact must stay settled after the scan advances" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 200); } /// A `Sent` payment must advance `Pending → Confirmed` once its @@ -4982,6 +5224,8 @@ mod tests { /// proving the `Some` path skips the peer-key derivation entirely. #[tokio::test] async fn register_external_with_precomputed_shared_key_builds_account() { + use crate::wallet::identity::ContactRequest; + let (manager, persister, wallet_id) = make_wallet().await; let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = wallet_arc.identity(); @@ -4999,6 +5243,20 @@ mod tests { &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), ) .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner_id) + .expect("managed owner") + .apply_incoming_contact_request(ContactRequest::new( + contact_id, + owner_id, + 0, + 0, + 0, + vec![0; 96], + 300, + 0, + )); + info.core_wallet.update_synced_height(1_000); } // A real 69-byte compact xpub encrypted under a known shared key — the @@ -5050,6 +5308,16 @@ mod tests { let wm = iw.wallet_manager.read().await; let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.account_generation(), + 1, + "registering an external account must invalidate the prior filter-scan generation" + ); + assert_eq!( + info.synced_height(), + 300, + "external account scanning resumes after the incoming request's DIP-15 height" + ); use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { index: 0, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index a5eb645450e..5fa2fc2ce3f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -109,6 +109,7 @@ impl ManagedIdentity { self.dashpay .established_contacts .insert(recipient_id, updated); + self.dashpay.rescan_triggered.remove(&recipient_id); return Ok(()); } // Already tracked as a pending sent request. Same outgoing @@ -141,6 +142,7 @@ impl ManagedIdentity { self.dashpay .sent_contact_requests .insert(recipient_id, request); + self.dashpay.rescan_triggered.remove(&recipient_id); return Ok(()); } @@ -189,6 +191,7 @@ impl ManagedIdentity { self.dashpay .established_contacts .insert(recipient_id, contact); + self.dashpay.rescan_triggered.remove(&recipient_id); } else { // No matching incoming request, just add as sent cs.sent_requests.insert( @@ -452,6 +455,7 @@ impl ManagedIdentity { persister.store(cs.into())?; self.dashpay.sent_contact_requests.remove(&sender_id); self.dashpay.established_contacts.insert(sender_id, contact); + self.dashpay.rescan_triggered.remove(&sender_id); } else { // No matching sent request, just add as incoming cs.incoming_requests.insert( @@ -633,6 +637,7 @@ impl ManagedIdentity { ); persister.store(cs.into())?; self.dashpay.established_contacts.insert(sender_id, updated); + self.dashpay.rescan_triggered.remove(&sender_id); true } else if tracked_pending { // Pending (not-yet-accepted) incoming request — replace it so @@ -650,6 +655,7 @@ impl ManagedIdentity { self.dashpay .incoming_contact_requests .insert(sender_id, request); + self.dashpay.rescan_triggered.remove(&sender_id); false } else { return Ok(false); @@ -714,6 +720,7 @@ impl ManagedIdentity { self.dashpay .established_contacts .insert(*sender_id, contact.clone()); + self.dashpay.rescan_triggered.remove(sender_id); // Per the ContactChangeSet auto-establishment contract, `established` // implies the matching pending requests are dropped — no separate @@ -817,6 +824,7 @@ impl ManagedIdentity { self.dashpay .established_contacts .insert(contact_id, contact); + self.dashpay.rescan_triggered.remove(&contact_id); } /// Reproduce a persisted sent contact request, keyed by its @@ -1583,6 +1591,7 @@ mod tests { .unwrap(); est.set_alias("Carol".to_string()); assert_eq!(est.outgoing_request.account_reference, 100); + managed.dashpay.rescan_triggered.insert(contact_id); // Rotation #1: re-send with a bumped reference R1. let mut rotation1 = create_contact_request(our_id, contact_id, 3); @@ -1601,6 +1610,10 @@ mod tests { 101, "rotation #1 must advance the tracked outgoing reference (not freeze at R0)" ); + assert!( + !managed.dashpay.rescan_triggered.contains(&contact_id), + "a changed request height must become eligible for rescan" + ); // Rotation #2: re-send with another bumped reference R2. let mut rotation2 = create_contact_request(our_id, contact_id, 4); @@ -1621,6 +1634,7 @@ mod tests { assert_eq!(est.alias, Some("Carol".to_string())); // Re-ingesting the SAME (newest) reference is a metadata-preserving // no-op (the same-reference guard). + managed.dashpay.rescan_triggered.insert(contact_id); let mut resend_same = create_contact_request(our_id, contact_id, 5); resend_same.account_reference = 102; managed @@ -1633,6 +1647,10 @@ mod tests { .unwrap(); assert_eq!(est.outgoing_request.account_reference, 102); assert_eq!(est.alias, Some("Carol".to_string())); + assert!( + managed.dashpay.rescan_triggered.contains(&contact_id), + "duplicate ingestion must preserve the completed-rescan guard" + ); } /// Pending-branch rotation supersede: re-sending to a recipient who 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..2034b4bfc10 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -148,6 +148,10 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.synced_height() } + fn account_generation(&self) -> u64 { + self.core_wallet.account_generation() + } + fn update_last_processed_height(&mut self, current_height: u32) { self.core_wallet .update_last_processed_height(current_height); diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index d86c0f13e05..7576fbf0258 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -380,6 +380,25 @@ class SendViewModel: ObservableObject { } } + static let coreFundingAccountIndex: UInt32 = 0 + + /// Balance of the BIP44 account used by the Core send builder. + static func coreFundingBalance(_ balances: [PlatformWalletManager.AccountBalance]) -> UInt64 { + balances.first { + $0.typeTag == 0 && $0.standardTag == 0 && $0.index == coreFundingAccountIndex + }?.confirmed ?? 0 + } + + /// The estimate is a UI preflight; Rust checks the finalized transaction fee. + func canSend(coreBalance: UInt64) -> Bool { + guard canSend else { return false } + guard detectedFlow == .coreToCore else { return true } + let (required, overflow) = coreSendTotalDuffs.addingReportingOverflow( + estimatedFee ?? SendFlow.coreToCore.estimatedFee + ) + return !overflow && coreBalance >= required + } + /// Determine which fund sources are available based on destination and balances. func availableSources( coreBalance: UInt64, @@ -496,6 +515,12 @@ class SendViewModel: ObservableObject { modelContext: ModelContext ) async { guard let flow = detectedFlow else { return } + if flow == .coreToCore && !canSend(coreBalance: Self.coreFundingBalance( + walletManager.accountBalances(for: wallet.walletId) + )) { + error = "BIP44 account 0 cannot cover the recipients and estimated fee" + return + } isSending = true error = nil @@ -535,7 +560,7 @@ class SendViewModel: ObservableObject { let signedTx = try builder.finalizeAtomic( wallet: platformWallet, accountType: .bip44, - accountIndex: senderAccountIndex + accountIndex: Self.coreFundingAccountIndex ) // Core acceptance, rather than a successful peer socket write, // is the boundary for showing payment success. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index de48fdf33bb..fca997624f8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -265,26 +265,8 @@ struct SendTransactionView: View { // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() - // Pick the account that will FUND a platform → - // platform transfer. The Rust Auto selector - // resolves the source via - // `platform_payment_managed_account_at_index` - // (key class 0) and selects its inputs WITHIN - // that single account — it does not span - // accounts. `canSend` only gates on the - // aggregate platform balance, so with multiple - // key-class-0 Platform Payment accounts we must - // choose an account whose OWN balance covers the - // requested amount + fee; otherwise we'd enable a - // send Rust rejects. The selection is factored - // into the pure, unit-tested - // `PlatformPaymentAccountSelection` helper. - // - // Only the platform → platform path needs this - // coverage-aware pick; every other flow ignores - // `senderAccountIndex`, so the prior - // "first key-class-0 positive balance, else 0" - // behaviour is preserved for them. + // Platform payments select one account with sufficient funds. + // Core sends use the view model's BIP44 funding account. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -293,10 +275,7 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = addressBalances - .filter { $0.account?.keyClass == 0 } - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 + senderAccountIndex = 0 } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the @@ -319,7 +298,7 @@ struct SendTransactionView: View { ) } } - .disabled(!viewModel.canSend) + .disabled(!viewModel.canSend(coreBalance: coreBalance)) } } .disabled(viewModel.isSending) @@ -475,15 +454,13 @@ struct SendTransactionView: View { // MARK: - Computed - /// Spendable Core balance, summed from Rust's in-memory per-account - /// totals. The persisted `PersistentWallet.balanceConfirmed` field - /// was removed; `accountBalances(for:)` is now the canonical - /// source (same path `BalanceCardView` uses). Exposed as a - /// function rather than a computed property so callers can - /// snapshot once per render and thread the value through. + /// Core sends display only the BIP44 account used by their builder. private func coreBalanceSnapshot() -> UInt64 { - walletManager.accountBalances(for: wallet.walletId) - .reduce(0) { $0 + $1.confirmed } + let balances = walletManager.accountBalances(for: wallet.walletId) + if viewModel.detectedFlow == .coreToCore { + return SendViewModel.coreFundingBalance(balances) + } + return balances.reduce(0) { $0 + $1.confirmed } } /// Per-wallet shielded balance: sum of THIS wallet's unspent diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift index 0cace941315..c4ddeecb59f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift @@ -1,5 +1,5 @@ import XCTest -import SwiftDashSDK +@testable import SwiftDashSDK @testable import SwiftExampleApp /// Behavioral tests for `SendViewModel`'s multi-recipient Core batch — @@ -36,6 +36,62 @@ final class SendViewModelCoreRecipientsTests: XCTestCase { return vm } + func test_coreFundingExcludesOtherAccountsAndAccountTypes() { + func balance( + type: UInt8 = 0, + standard: UInt8 = 0, + index: UInt32 = 0, + confirmed: UInt64 + ) -> PlatformWalletManager.AccountBalance { + PlatformWalletManager.AccountBalance( + typeTag: type, standardTag: standard, index: index, + registrationIndex: 0, keyClass: 0, userIdentityId: Data(), + friendIdentityId: Data(), confirmed: confirmed, unconfirmed: 0, + immature: 0, locked: 0, keysUsed: 0, keysTotal: 0 + ) + } + let otherAccounts = [ + balance(index: 1, confirmed: 1_000_000), + balance(type: 12, confirmed: 1_000_000), + balance(standard: 1, confirmed: 1_000_000) + ] + XCTAssertEqual(SendViewModel.coreFundingBalance(otherAccounts), 0) + XCTAssertEqual(SendViewModel.coreFundingBalance( + otherAccounts + [balance(confirmed: 0)] + ), 0) + XCTAssertEqual(SendViewModel.coreFundingBalance( + otherAccounts + [balance(confirmed: 100_500)] + ), 100_500) + } + + func test_coreFundingRequiresBatchAndEstimatedFeeInSelectedAccount() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + vm.estimatedFee = 500 + XCTAssertFalse(vm.canSend(coreBalance: 100_000)) + XCTAssertFalse(vm.canSend(coreBalance: 100_499)) + XCTAssertTrue(vm.canSend(coreBalance: 100_500)) + XCTAssertFalse(vm.canSend(coreBalance: 0)) + + vm.addCoreRecipient() + vm.additionalCoreRecipients[0].address = extraAddress + vm.additionalCoreRecipients[0].amountString = "0.002" + XCTAssertFalse(vm.canSend(coreBalance: 100_500)) + XCTAssertTrue(vm.canSend(coreBalance: 300_500)) + } + + func test_coreFundingRejectsFeeOverflow() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + vm.estimatedFee = UInt64.max + XCTAssertFalse(vm.canSend(coreBalance: UInt64.max)) + } + + func test_coreFundingUsesDisplayedFallbackFee() { + let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") + vm.estimatedFee = nil + XCTAssertFalse(vm.canSend(coreBalance: 100_000)) + XCTAssertTrue(vm.canSend(coreBalance: 100_000 + SendFlow.coreToCore.estimatedFee)) + } + // MARK: - Sanity: the fixtures really are Core addresses on testnet func test_fixtureAddresses_areTestnetCore() { diff --git a/packages/swift-sdk/run_tests.sh b/packages/swift-sdk/run_tests.sh index 47ca4b095d3..bc49c0e09bc 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -23,7 +23,18 @@ cd "$SCRIPT_DIR" || exit 1 # touches a developer's keychain configuration; the previous default and # search list are restored on exit. if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then - PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + # Only a missing default is recoverable; other failures leave its value unknown. + if PREV_DEFAULT_KEYCHAIN_OUTPUT="$(LC_ALL=C security default-keychain -d user 2>&1)"; then + PREV_DEFAULT_KEYCHAIN="$(printf '%s\n' "$PREV_DEFAULT_KEYCHAIN_OUTPUT" | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + else + lookup_status=$? + if [ "$lookup_status" -eq 1 ] && [ "$PREV_DEFAULT_KEYCHAIN_OUTPUT" = "security: SecKeychainCopyDomainDefault user: A default keychain could not be found." ]; then + PREV_DEFAULT_KEYCHAIN="" + else + printf '%s\n' "$PREV_DEFAULT_KEYCHAIN_OUTPUT" >&2 + exit "$lookup_status" + fi + fi PREV_USER_KEYCHAINS_OUTPUT="$(security list-keychains -d user)" PREV_USER_KEYCHAINS=() while IFS= read -r keychain_path; do @@ -48,7 +59,10 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then cleanup_status=0 trap - EXIT - if [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ]; then + # An empty PREV_DEFAULT_KEYCHAIN means the runner had no user default to + # begin with, so there is nothing to restore and `security -s ""` would + # only fail the cleanup. + if [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ] && [ -n "${PREV_DEFAULT_KEYCHAIN:-}" ]; then if ! security default-keychain -d user -s "$PREV_DEFAULT_KEYCHAIN"; then cleanup_status=1 fi diff --git a/packages/swift-sdk/tests/run_tests_keychain_test.sh b/packages/swift-sdk/tests/run_tests_keychain_test.sh new file mode 100644 index 00000000000..3adac9ce4ad --- /dev/null +++ b/packages/swift-sdk/tests/run_tests_keychain_test.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Exercise CI setup with stub commands; never access a real keychain. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT +mkdir "$test_dir/bin" +cat > "$test_dir/bin/security" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >> "$CALL_LOG" +case "$*" in + 'default-keychain -d user') + if [ -f "$KEYCHAIN_STATE" ]; then + cat "$KEYCHAIN_STATE" + elif [ "$LOOKUP_MODE" = absent ]; then + echo 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' >&2 + exit 1 + elif [ "$LOOKUP_MODE" = unexpected ]; then + echo 'security: SecKeychainCopyDomainDefault user: A default keychain could not be found.' >&2 + exit 42 + elif [ "$LOOKUP_MODE" = denied ]; then + echo 'security: SecKeychainCopyDomainDefault user: User interaction is not allowed.' >&2 + exit 1 + else + echo ' "/saved/login.keychain-db"' + fi ;; + 'default-keychain -d user -s '*) printf '%s\n' "$5" > "$KEYCHAIN_STATE" ;; + 'list-keychains -d user') echo ' "/saved/login.keychain-db"' ;; + 'find-generic-password '*) echo writable ;; +esac +STUB +cat > "$test_dir/bin/xcrun" <<'STUB' +#!/bin/bash +# Stop after setup, before any builds. +echo "iPhone 16 (test-device)" +exit 42 +STUB +chmod +x "$test_dir/bin/"* +for mode in denied unexpected absent present; do + export LOOKUP_MODE="$mode" CALL_LOG="$test_dir/$mode.calls" KEYCHAIN_STATE="$test_dir/$mode.state" + status=0 + CI=1 SIM_NAME='' PATH="$test_dir/bin:$PATH" RUNNER_TEMP="$test_dir" \ + bash "$script_dir/../run_tests.sh" > "$test_dir/$mode.output" 2>&1 || status=$? + if [ "$mode" = denied ] || [ "$mode" = unexpected ]; then + expected_status=1 + if [ "$mode" = unexpected ]; then expected_status=42; fi + if [ "$status" -ne "$expected_status" ] || [ "$(wc -l < "$CALL_LOG" | tr -d ' ')" -ne 1 ]; then + echo 'FAIL: unexpected lookup failure must abort before further keychain operations' >&2 + cat "$test_dir/$mode.output" >&2 + exit 1 + fi + grep -q 'security: SecKeychainCopyDomainDefault user:' "$test_dir/$mode.output" + else + [ "$status" -eq 42 ] + grep -q '^create-keychain ' "$CALL_LOG" + grep -q '^delete-keychain ' "$CALL_LOG" + if [ "$mode" = present ]; then + grep -q '^default-keychain -d user -s /saved/login.keychain-db$' "$CALL_LOG" + else + [ "$(grep -c '^default-keychain -d user -s ' "$CALL_LOG")" -eq 1 ] + fi + fi + echo "PASS: $mode" +done