From 53ca65b5d940561c2816749ce24590ce277a244c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:13:37 +0000 Subject: [PATCH 01/14] fix(platform-wallet): scan DashPay contact accounts from request height Rebuilt on v4.3-dev: carries only this PR's own changes, dropping the v4.2-dev commits pulled in by earlier base merges. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/contacts.rs | 110 ++++-- .../src/wallet/identity/network/payments.rs | 336 ++++++++++++++++-- .../managed_identity/contact_requests.rs | 18 + .../src/wallet/platform_wallet_traits.rs | 4 + .../Core/ViewModels/SendViewModel.swift | 27 +- .../Core/Views/SendTransactionView.swift | 43 +-- .../SendViewModelCoreRecipientsTests.swift | 58 ++- packages/swift-sdk/run_tests.sh | 18 +- .../tests/run_tests_keychain_test.sh | 64 ++++ 9 files changed, 584 insertions(+), 94 deletions(-) create mode 100644 packages/swift-sdk/tests/run_tests_keychain_test.sh 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 940b8da96ec..b130d5c0100 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)); } @@ -1651,6 +1641,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::{ @@ -2284,10 +2275,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(); { @@ -2305,6 +2327,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)| { @@ -2329,6 +2370,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 @@ -2343,15 +2385,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. @@ -2756,6 +2889,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. @@ -2914,8 +3135,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() @@ -2923,10 +3152,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() @@ -2940,8 +3169,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; @@ -2963,6 +3192,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 @@ -4934,6 +5176,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(); @@ -4951,6 +5195,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 @@ -5002,6 +5260,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 From c2b8c7f8234ee370b1d787d8a81c45ac48033654 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:52:40 +0000 Subject: [PATCH 02/14] fix(platform-wallet): scope DashPay contact scan checkpoint to the account's own request The contact scan checkpoint pooled both directions' requests, so a contact could drag our receiving account back to the wallet-birth floor by publishing a rotated or older request, and clear its rescan guard with every re-key. - `contact_scan_checkpoint` takes a `ContactAccountSide`: the receiving account follows only our outgoing request, the external account only the contact's incoming request. It still falls back to the birth floor for an unknown, height-less or rotated request, and is still combined with the previous checkpoint under the same lock. - `register_contact_account` marks the rescan guard for every relationship it covered, pending ones included, so the next DashPay sync does not rewind the same range again. Only changes to our outgoing request clear the guard; incoming-side events no longer do. - Docs name the Platform-assigned `$createdAtCoreBlockHeight` as the trusted checkpoint source (it raises `synced_height`, which prunes state up to that height) and describe registration at send time instead of the removed funding-height model. - platform-encryption exposes `account_reference_version` / `ACCOUNT_REFERENCE_VERSION_SHIFT` in place of the magic `>> 28`. Co-Authored-By: Claude Opus 5.5 --- .../src/account_reference.rs | 27 +- packages/rs-platform-encryption/src/lib.rs | 5 +- .../src/manager/dashpay_sync.rs | 6 +- .../src/wallet/identity/network/contacts.rs | 121 ++++-- .../src/wallet/identity/network/payments.rs | 360 +++++++++++++----- .../managed_identity/contact_requests.rs | 10 +- .../state/managed_identity/dashpay.rs | 19 +- .../identity/types/dashpay/contact_request.rs | 4 +- 8 files changed, 395 insertions(+), 157 deletions(-) diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index 82f6a2b7324..78ef57f3a0d 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -31,6 +31,21 @@ fn extract_ask28(ask_bytes: &[u8; 32]) -> u32 { u32::from_be_bytes([ask_bytes[28], ask_bytes[29], ask_bytes[30], ask_bytes[31]]) >> 4 } +/// Bit position of the rotation `version` nibble in a DIP-15 `accountReference` +/// (`version << 28 | masked_index`). +pub const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; + +/// Mask of the low 28 bits carrying the masked account index. +const ACCOUNT_REFERENCE_INDEX_MASK: u32 = (1 << ACCOUNT_REFERENCE_VERSION_SHIFT) - 1; + +/// Rotation `version` of a DIP-15 `accountReference` (its top 4 bits). +/// +/// Readable without the sender's secret: the version is not masked, so a +/// recipient can tell a re-keyed (rotated) request from a first-generation one. +pub fn account_reference_version(account_reference: u32) -> u32 { + account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT +} + /// Calculate the masked DIP-15 `accountReference`: /// `result = (version << 28) | (ASK28 ^ (account_index & 0x0FFF_FFFF))`. /// @@ -45,8 +60,8 @@ pub fn calculate_account_reference( version: u32, ) -> u32 { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let shortened_account_bits = account_index & 0x0FFF_FFFF; - let version_bits = version << 28; + let shortened_account_bits = account_index & ACCOUNT_REFERENCE_INDEX_MASK; + let version_bits = version << ACCOUNT_REFERENCE_VERSION_SHIFT; version_bits | (ask28 ^ shortened_account_bits) } @@ -60,8 +75,8 @@ pub fn unmask_account_reference( compact_xpub: &[u8], ) -> (u32, u32) { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let version = account_reference >> 28; - let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; + let version = account_reference_version(account_reference); + let account_index = (account_reference & ACCOUNT_REFERENCE_INDEX_MASK) ^ ask28; (version, account_index) } @@ -81,11 +96,11 @@ mod tests { let secret_key = [1u8; 32]; let compact = test_compact_xpub(); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 0) >> 28, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 0)), 0 ); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 1) >> 28, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 1)), 1 ); assert_eq!( diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 6a6f9c4cc93..747c0528d0f 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -24,7 +24,10 @@ mod ecdh; mod error; pub use account_label::{decrypt_account_label, encrypt_account_label}; -pub use account_reference::{calculate_account_reference, unmask_account_reference}; +pub use account_reference::{ + account_reference_version, calculate_account_reference, unmask_account_reference, + ACCOUNT_REFERENCE_VERSION_SHIFT, +}; pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; pub use compact_xpub::{ compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 2f08ef19e78..88fe1421fab 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -485,9 +485,9 @@ impl DashPaySyncManager { // Local-only: DIP-15 §12.6 coreHeight backfill — lower SPV synced_height // to re-scan for incoming payments that landed on a contact's receival // address before it was watched (restore-from-seed / 2nd device / - // offline-accept→pay). After the reconcile above so newly established - // receival accounts are visible; a per-contact guard prevents - // re-triggering and thrashing the in-flight backfill. + // offline-accept→pay). After the reconcile above so newly registered + // receival accounts (sent or established) are visible; a per-contact + // guard prevents re-triggering and thrashing the in-flight backfill. if let Err(e) = identity.dashpay().reconcile_dashpay_rescan().await { tracing::warn!( wallet_id = %hex::encode(wallet_id), 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 a21784eaf86..b77017f3115 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -7,6 +7,8 @@ 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 key_wallet::Wallet; +use platform_encryption::account_reference_version; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -16,47 +18,70 @@ 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. +/// Which side of a DashPay relationship a contact account serves. +/// +/// Each side exposes a different xpub in a different request, so each has its +/// own scan checkpoint: see [`contact_scan_checkpoint`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ContactAccountSide { + /// `DashpayReceivingFunds`: our receiving xpub, published only in OUR + /// outgoing request. + Receiving, + /// `DashpayExternalAccount`: the contact's xpub, published only in THEIR + /// incoming request. + External, +} + +/// Return the last Core height already covered for a new contact account's side. +/// +/// Scanning resumes at `H + 1`, where `H` is the Platform-assigned +/// `$createdAtCoreBlockHeight` of the request that published this side's xpub: +/// no payment to (or from) that xpub can predate the document exposing it. +/// Only that one request counts, so the other party's request (which they +/// control) can never lower this side's checkpoint. +/// +/// `H` must come from Platform, never from a client-written field: the caller +/// raises `synced_height` to it, and `update_synced_height` prunes spend state +/// up to that height. It is also bounded by the previous checkpoint (see +/// [`add_managed_contact_account`]), so it never exceeds a range already +/// scanned. +/// +/// Falls back to the wallet birth floor when the owner is unknown, the side's +/// request is not tracked or has no height, or the request is a rotation +/// (non-zero `accountReference` version): a re-key may reuse the xpub, and the +/// original request's height is gone. pub(super) fn contact_scan_checkpoint( - info: &crate::wallet::PlatformWalletInfo, + info: &PlatformWalletInfo, owner: &Identifier, contact: &Identifier, + side: ContactAccountSide, ) -> 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); + let established = dashpay.established_contacts().get(contact); + let request = match side { + ContactAccountSide::Receiving => established + .map(|contact| &contact.outgoing_request) + .or_else(|| dashpay.sent_contact_requests().get(contact)), + ContactAccountSide::External => established + .map(|contact| &contact.incoming_request) + .or_else(|| dashpay.incoming_contact_requests().get(contact)), + }; + let Some(request) = request else { + return birth_checkpoint; + }; + if account_reference_version(request.account_reference) != 0 { + return birth_checkpoint; } - 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) + request.core_height_created_at.max(birth_checkpoint) } fn add_managed_contact_account( - info: &mut crate::wallet::PlatformWalletInfo, - wallet: &key_wallet::Wallet, + info: &mut PlatformWalletInfo, + wallet: &Wallet, account_type: AccountType, scan_checkpoint: u32, ) -> key_wallet::Result<()> { @@ -203,7 +228,15 @@ impl DashPayView<'_, B> { /// /// Creates a `DashpayReceivingFunds` managed account with address pools /// so the SPV adapter monitors incoming payments from this contact. - /// Call this when a contact is established (mutual requests exist). + /// Call this as soon as our outgoing request is known (sent or + /// established): it publishes our receiving xpub, so the contact may pay + /// before reciprocating. + /// + /// Rewinds the filter scan to the account's contact scan checkpoint + /// (`contact_scan_checkpoint`; never above the current checkpoint) and + /// marks the contact in + /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) + /// so [`Self::reconcile_dashpay_rescan`] does not rewind a second time. /// /// No-op if the account already exists for this contact relationship. pub async fn register_contact_account( @@ -283,7 +316,12 @@ 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); + let scan_checkpoint = contact_scan_checkpoint( + info, + our_identity_id, + contact_identity_id, + ContactAccountSide::Receiving, + ); // Mirror the restored shape: the immutable `wallet.accounts` // collection holds the Account (like `build_wallet_start_state` @@ -301,16 +339,14 @@ impl DashPayView<'_, B> { "Failed to register contact account: {e}" )) })?; + // The checkpoint just applied already schedules this account's + // backfill, pending or established alike. Mark it so the next + // `reconcile_dashpay_rescan` does not rewind over the same range again; + // a later change to our outgoing request clears the mark. 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); - } + managed + .dashpay_rescan_triggered_mut() + .insert(*contact_identity_id); } tracing::info!( @@ -628,7 +664,12 @@ impl DashPayView<'_, B> { self.wallet_id, ))) })?; - let scan_checkpoint = contact_scan_checkpoint(info, our_identity_id, &contact_identity_id); + let scan_checkpoint = contact_scan_checkpoint( + info, + our_identity_id, + &contact_identity_id, + ContactAccountSide::External, + ); // (a) Insert Account into the immutable wallet account collection so the // xpub is accessible by `send_payment`. 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 b130d5c0100..188479e1a1b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -2,6 +2,7 @@ use dpp::prelude::Identifier; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use std::sync::Arc; @@ -9,6 +10,7 @@ use tokio::sync::RwLock; use key_wallet_manager::WalletManager; +use super::contacts::{contact_scan_checkpoint, ContactAccountSide}; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -75,23 +77,26 @@ impl DashPayView<'_, B> { /// receival address **before** that address was being watched (DIP-15 §8.7 /// + §12.6). /// - /// Receival accounts are built lazily — after restore-from-seed, on a second - /// device, or in the offline-accept→pay window the account appears only once - /// the contact is established, by which point SPV has already scanned past - /// the contact's funding height. Those addresses then enter the compact + /// A receival account can be registered after SPV has already scanned past + /// the height at which our outgoing request published its xpub — after + /// restore-from-seed, on a second device, or when the account was missing + /// from memory at the last scan. Its addresses then enter the compact /// filter match set **forward-only**, so a payment in an already-scanned - /// block is silently missed. + /// block is silently missed. Accounts exist as soon as our outgoing request + /// is known, whether or not the contact has reciprocated. /// /// This lowers the wallet's SPV `synced_height` to the minimum - /// `$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 + /// contact scan checkpoint (`contact_scan_checkpoint`) across registered + /// receival accounts 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 /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does /// not re-lower the height every pass (which would reset the in-flight - /// backfill and keep it from ever completing). The guard is in-memory, so a - /// relaunch — where `synced_height` is restored at its high-water — safely - /// re-triggers an interrupted backfill. + /// backfill and keep it from ever completing). Registration sets the same + /// mark, since it applies the checkpoint itself. The guard is in-memory, + /// so a relaunch — where `synced_height` is restored at its high-water — + /// safely re-triggers an interrupted backfill. /// /// `synced_height` may regress here: that is safe because it is the /// filter-scan checkpoint, decoupled from the monotonic @@ -101,8 +106,6 @@ impl DashPayView<'_, B> { /// is outbound and never receives. Returns the floor the height was lowered /// to, or `None`. pub async fn reconcile_dashpay_rescan(&self) -> Result, PlatformWalletError> { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let mut wm = self.wallet_manager.write().await; let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { return Ok(None); @@ -110,7 +113,7 @@ impl DashPayView<'_, B> { // 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. + // redundant checkpoint rewind once that scan advances. let synced_height = info.core_wallet.synced_height(); // (owner, contact) pairs that have a receival account — we can only @@ -128,11 +131,9 @@ impl DashPayView<'_, B> { }) .collect(); - // 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. + // Candidates: receival contacts not yet rescanned this lifetime whose + // checkpoint is below our scan tip. One rewind to the minimum + // checkpoint covers them all. let mut floor: Option = None; let mut to_mark: Vec<(Identifier, Identifier)> = Vec::new(); for (owner, contact) in receival_pairs { @@ -142,15 +143,16 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } - 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 - // forward scan (their addresses are watched from establishment). - // EITHER way the contact is now handled, so mark it: once the - // 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. + let checkpoint = + contact_scan_checkpoint(info, &owner, &contact, ContactAccountSide::Receiving); + // Contacts whose checkpoint is below the tip need a backfill — + // their addresses weren't watched when those blocks were first + // scanned. Contacts at or after the tip are already covered by the + // ongoing forward scan. EITHER way the contact is now handled, so + // mark it: once the forward pointer later climbs past a + // still-forward-covered contact's checkpoint, the recurring sweep + // must NOT then rewind to it and redundantly re-scan an + // already-scanned range. if checkpoint < synced_height { floor = Some(floor.map_or(checkpoint, |cur| cur.min(checkpoint))); } @@ -161,8 +163,8 @@ impl DashPayView<'_, B> { return Ok(None); } - // Lower the filter-scan checkpoint only when a contact is funded below - // the tip (otherwise every handled contact is forward-covered and we + // Lower the filter-scan checkpoint only when a contact's checkpoint is + // below the tip (otherwise every handled contact is forward-covered and we // record the guard without rewinding). The engine clamps `floor` to its // own header/birth floor, so no double-clamp here. if let Some(floor) = floor { @@ -1650,9 +1652,11 @@ mod tests { }; use crate::error::PlatformWalletError; use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; use crate::PlatformWalletManager; + use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; @@ -2275,8 +2279,6 @@ 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]); @@ -2342,7 +2344,8 @@ mod tests { .expect("info") .synced_height(), 100, - "DIP-15 coreHeight is the certified checkpoint; scanning resumes at H + 1" + "the sent request's Platform-assigned $createdAtCoreBlockHeight is the \ + checkpoint; scanning resumes at H + 1" ); } @@ -2411,8 +2414,6 @@ mod tests { #[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]); @@ -2471,7 +2472,7 @@ mod tests { rotated, 0, 0, - 1 << 28, + 1 << ACCOUNT_REFERENCE_VERSION_SHIFT, vec![0; 96], 900, 0, @@ -2808,14 +2809,15 @@ mod tests { } /// DIP-15 §12.6: when a contact's receival account is registered after SPV - /// has already scanned past the contact's funding height, the rescan - /// reconcile lowers `synced_height` to `min(outgoing, incoming)` funding so - /// the filter manager backfills the missed range — then a per-contact guard + /// has already scanned past our outgoing request's height, the rescan + /// reconcile lowers `synced_height` to that request's + /// `$createdAtCoreBlockHeight` so the filter manager backfills the missed + /// range — then a per-contact guard /// makes it single-shot per lifetime, so the recurring sweep does NOT /// re-lower the height and reset the in-flight backfill (which would keep it /// from ever completing). #[tokio::test] - async fn rescan_lowers_synced_height_to_funding_floor_then_is_idempotent() { + async fn rescan_lowers_synced_height_to_outgoing_request_height_then_is_idempotent() { use crate::wallet::identity::{ContactRequest, EstablishedContact}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -2837,9 +2839,10 @@ mod tests { info.identity_manager .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) .expect("add owner"); - // outgoing funded at 200, incoming at 100 -> floor 100. - let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 200, 0); - let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0); + // Outgoing at 100 -> floor 100. The older incoming request (50) + // exposes the contact's xpub, not ours, so it is ignored. + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 50, 0); info.identity_manager .managed_identity_mut(&owner) .expect("managed") @@ -2854,7 +2857,7 @@ mod tests { .await .expect("rescan"), Some(100), - "first pass lowers to min(outgoing, incoming) funding height" + "first pass lowers to the outgoing request's height" ); { let wm = iw.wallet_manager.read().await; @@ -2889,57 +2892,91 @@ 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; + /// Add `owner` as a wallet identity and record `requests` for it (sent when + /// `owner` is the sender, incoming otherwise), via the apply path. + async fn seed_owner_requests( + manager: &Arc>, + persister: &Arc, + wallet_id: WalletId, + owner: Identifier, + requests: Vec, + ) { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + if info.identity_manager.managed_identity(&owner).is_none() { + let p = WalletPersister::new(wallet_id, Arc::clone(persister) as _); + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + } + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed owner"); + for request in requests { + if request.sender_id == owner { + managed.apply_sent_contact_request(request); + } else { + managed.apply_incoming_contact_request(request); + } + } + } + /// A one-way outgoing request already publishes our receiving xpub, so a + /// restored sent-only receival account needs the same historical coverage + /// as an established one. A relaunch loses the in-memory guard while + /// `synced_height` comes back at its high-water, so reconcile must rewind + /// once — and only once. + #[tokio::test] + async fn rescan_covers_restored_sent_only_account() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); + seed_owner_requests( + &manager, + &persister, + wallet_id, + owner, + vec![ContactRequest::new( + owner, + contact, + 0, + 0, + 0, + vec![0; 96], + 100, + 0, + )], + ) + .await; let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = wallet.identity(); - let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register sent-only receival account"); + // Model the relaunch: guard gone, scan restored at its high-water. { 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, - )); + .dashpay_rescan_triggered_mut() + .clear(); + info.core_wallet.update_synced_height(1_000); } - 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) + .expect("restored sent-only rescan"), + Some(100), + "a restored sent-only account must be backfilled from its sent request" ); + set_synced_height(&manager, wallet_id, 1_000).await; assert_eq!( iw.dashpay() .reconcile_dashpay_rescan() @@ -2949,9 +2986,10 @@ mod tests { "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. + // The contact reciprocating with an OLDER request does not change what + // our receiving account needs: only our outgoing request exposes it. { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); let mut wm = iw.wallet_manager.write().await; wm.get_wallet_info_mut(&wallet_id) .expect("info") @@ -2963,22 +3001,145 @@ mod tests { &p, ) .expect("establish contact"); - wm.get_wallet_info_mut(&wallet_id) + } + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("established reconcile"), + None, + "the contact's request must not re-arm our receiving account's rescan" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_000); + } + + /// `send_contact_request` registers the receival account right after + /// sending, and registration already applies the checkpoint. Once the scan + /// has moved past it, the next DashPay sync must not rewind over the same + /// range a second time. + #[tokio::test] + async fn should_not_rewind_again_after_fresh_send_registration() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + seed_owner_requests( + &manager, + &persister, + wallet_id, + owner, + vec![ContactRequest::new( + owner, + contact, + 0, + 0, + 0, + vec![0; 96], + 100, + 0, + )], + ) + .await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register after send"); + assert_eq!(synced_height(&manager, wallet_id).await, 100); + + set_synced_height(&manager, wallet_id, 500).await; + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after send"), + None, + "registration already covered the sent-only account" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 500); + } + + /// The receiving account's checkpoint depends only on OUR outgoing request. + /// A contact-controlled incoming request — rotated or older — must neither + /// lower it at registration nor re-arm its rescan when it changes later. + #[tokio::test] + async fn receiving_checkpoint_ignores_contact_request() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let rotated = 1 << ACCOUNT_REFERENCE_VERSION_SHIFT; + seed_owner_requests( + &manager, + &persister, + wallet_id, + owner, + vec![ + ContactRequest::new(owner, contact, 0, 0, 0, vec![0; 96], 200, 0), + ContactRequest::new(contact, owner, 0, 0, rotated, vec![0; 96], 10, 0), + ], + ) + .await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receiving account"); + assert_eq!( + synced_height(&manager, wallet_id).await, + 200, + "a rotated, older incoming request must not pull the receiving \ + checkpoint down to wallet birth" + ); + + // Establish, let the scan advance, then have the contact re-key. + set_synced_height(&manager, wallet_id, 1_000).await; + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet_id) .expect("info") - .core_wallet - .update_synced_height(1_000); + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + let incoming = managed + .dashpay() + .incoming_contact_requests() + .get(&contact) + .cloned() + .expect("incoming"); + let sent = managed + .dashpay() + .sent_contact_requests() + .get(&contact) + .cloned() + .expect("sent"); + managed.apply_established_contact(EstablishedContact::new(contact, sent, incoming)); + managed.dashpay_rescan_triggered_mut().insert(contact); + let rekeyed = managed + .apply_rotated_incoming_request( + ContactRequest::new(contact, owner, 0, 0, rotated + 1, vec![0; 96], 5, 0), + &p, + ) + .expect("apply contact rotation"); + assert!(rekeyed, "established contact must be re-keyed"); } assert_eq!( iw.dashpay() .reconcile_dashpay_rescan() .await - .expect("established rescan"), - Some(50) + .expect("reconcile after contact rotation"), + None, + "a contact's rotation must not force a rescan of our receiving account" ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_000); } /// Register a receival account for `(owner, contact)` and insert an - /// established contact funded at `out_height`/`in_height`. The owner managed + /// established contact whose requests sit at `out_height`/`in_height`. The owner managed /// identity is added on first use. async fn establish_receival_contact( manager: &Arc>, @@ -3039,7 +3200,7 @@ mod tests { .synced_height() } - /// A contact established while the wallet was still catching up (funded at or + /// A contact established while the wallet was still catching up (checkpoint at or /// after the current tip) is covered by the ongoing forward scan, so the /// rescan leaves `synced_height` alone — but it must MARK the contact so /// that, once the forward pointer later climbs past the contact's funding @@ -3073,7 +3234,7 @@ mod tests { "height unchanged" ); - // Forward sync climbs past the funding height. The contact was marked, + // Forward sync climbs past the request checkpoint. The contact was marked, // so the next sweep must not re-lower to 500. set_synced_height(&manager, wallet_id, 600).await; assert_eq!( @@ -3093,7 +3254,7 @@ mod tests { ); } - /// Multiple contacts: the floor is the MINIMUM funding height across all + /// Multiple contacts: the floor is the MINIMUM request checkpoint across all /// not-yet-rescanned receival contacts (one rewind covers them all), and a /// later-discovered, older-funded contact re-lowers exactly once before the /// per-contact guard quiesces (drip-feed must not thrash). @@ -3119,7 +3280,7 @@ mod tests { .await .expect("rescan"), Some(100), - "floor is the minimum funding height across all candidates" + "floor is the minimum request checkpoint across all candidates" ); assert_eq!(synced_height(&manager, wallet_id).await, 100); @@ -5176,8 +5337,6 @@ 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(); @@ -5208,6 +5367,22 @@ mod tests { 300, 0, )); + // Our own outgoing request is rotated and older. It exposes OUR + // receiving xpub, not the contact's, so it must not pull the + // external account's checkpoint down to wallet birth. + info.identity_manager + .managed_identity_mut(&owner_id) + .expect("managed owner") + .apply_sent_contact_request(ContactRequest::new( + owner_id, + contact_id, + 0, + 0, + 1 << ACCOUNT_REFERENCE_VERSION_SHIFT, + vec![0; 96], + 50, + 0, + )); info.core_wallet.update_synced_height(1_000); } @@ -5268,7 +5443,8 @@ mod tests { assert_eq!( info.synced_height(), 300, - "external account scanning resumes after the incoming request's DIP-15 height" + "external account scanning resumes after the incoming request's \ + $createdAtCoreBlockHeight; our rotated, older outgoing request is irrelevant" ); use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { 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 5fa2fc2ce3f..c5d05c1ddbf 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,10 @@ impl ManagedIdentity { self.dashpay .established_contacts .insert(recipient_id, updated); + // Our receiving account's scan checkpoint is derived from this + // outgoing request alone, so a new one invalidates its rescan + // guard. Incoming-side changes never touch the guard: the contact + // must not be able to force rescans of our receiving account. self.dashpay.rescan_triggered.remove(&recipient_id); return Ok(()); } @@ -455,7 +459,6 @@ 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( @@ -637,7 +640,6 @@ 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 @@ -655,7 +657,6 @@ impl ManagedIdentity { self.dashpay .incoming_contact_requests .insert(sender_id, request); - self.dashpay.rescan_triggered.remove(&sender_id); false } else { return Ok(false); @@ -720,7 +721,6 @@ 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 @@ -1612,7 +1612,7 @@ mod tests { ); assert!( !managed.dashpay.rescan_triggered.contains(&contact_id), - "a changed request height must become eligible for rescan" + "a new outgoing request must make the receiving account eligible for rescan" ); // Rotation #2: re-send with another bumped reference R2. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index fb981b0ebd4..d3af708bdac 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -126,19 +126,20 @@ pub struct DashPayState { /// `sync_contact_profiles`; public-data only (never `contactInfo`-derived). pub contact_profiles: BTreeMap, - /// Contacts for which a historical L1 rescan has already been triggered this - /// process lifetime (DIP-15 §12.6 coreHeight backfill). When the rescan - /// reconcile lowers the wallet's SPV `synced_height` to a contact's funding - /// height so the filter manager re-scans for payments that landed before the - /// receival address was watched, the contact is recorded here so the - /// recurring sweep does not re-lower the height every pass — which would - /// reset the in-flight backfill and prevent it from ever completing. + /// Contacts whose receival account's historical L1 rescan has already been + /// scheduled this process lifetime (DIP-15 §12.6 backfill). Registering the + /// account, or the rescan reconcile lowering the wallet's SPV + /// `synced_height` to the account's scan checkpoint, records the contact + /// here so the recurring sweep does not re-lower the height every pass — + /// which would reset the in-flight backfill and prevent it from ever + /// completing. Only a change to OUR outgoing request (the sole input of + /// that checkpoint) clears the mark; the contact's own requests never do. /// /// In-memory only (never persisted): a relaunch clears it, and because /// `synced_height` is restored at its monotonic high-water, an interrupted /// backfill is re-triggered on the next launch — self-healing. The cost of - /// that reset is one historical re-match per launch while any contact is - /// funded below the tip; the compact filters are reused from disk (not + /// that reset is one historical re-match per launch while any contact's + /// checkpoint is below the tip; the compact filters are reused from disk (not /// re-downloaded), so it is cheap. A persisted breadcrumb could make the /// backfill durable across a crash if that ever becomes necessary. pub rescan_triggered: BTreeSet, diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs index d0b1540a3ce..c039823fa34 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs @@ -34,7 +34,9 @@ pub struct ContactRequest { /// Auto accept proof (optional) pub auto_accept_proof: Option>, - /// Core height when the contact request was created + /// Platform-assigned `$createdAtCoreBlockHeight` of the request document. + /// + /// Trusted as a scan checkpoint because Platform, not the sender, sets it. pub core_height_created_at: CoreBlockHeight, /// Timestamp when the contact request was created (milliseconds) From 01cc3c1d2926fdc0e926eed82159c56d855c4275 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:52:40 +0000 Subject: [PATCH 03/14] fix(swift-example-app): fund every Core send from one pooled source set Core payments funded from BIP44 account 0 while shielding from Core used the largest BIP44 account. The displayed Core balance also changed with the typed destination, and the insufficient-funds alert said "BIP44 account 0". Both Core-funded flows now use the pooled Rust default at `coreFundingAccountIndex`: BIP44 and BIP32 at that index plus every DashPay receiving account (`SEND_FUNDING_SOURCES` == `ASSET_LOCK_FUNDING_SOURCES`). The Core balance shown is exactly that set's confirmed total, for every flow. The account predicate lives in `isCoreFundingSource`, the view no longer passes a literal 0, and the alert reads in plain language. Co-Authored-By: Claude Opus 5.5 --- .../Core/ViewModels/SendViewModel.swift | 102 ++++++++++++------ .../Core/Views/SendTransactionView.swift | 16 +-- .../SendViewModelCoreRecipientsTests.swift | 76 ++++++++----- 3 files changed, 132 insertions(+), 62 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 7576fbf0258..774bf5c1c6d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -380,23 +380,72 @@ class SendViewModel: ObservableObject { } } + // MARK: - Core funding + + /// Standard-account index every Core-funded flow draws from. + /// + /// One rule for every Core-funded flow: both Rust builders pool the same + /// default source set at this index (`SEND_FUNDING_SOURCES` == + /// `ASSET_LOCK_FUNDING_SOURCES` in rs-platform-wallet) — the BIP44 and + /// BIP32 accounts at the index plus every DashPay receiving account. + /// `coreToCore` reaches it through `.allSpendable`, `coreToShielded` + /// through the asset lock's `fundingAccountIndex`. static let coreFundingAccountIndex: UInt32 = 0 - /// Balance of the BIP44 account used by the Core send builder. + /// Shown when the pooled Core funding set can't cover a Core-funded send. + static let insufficientCoreFundsMessage = + "Not enough confirmed Core funds to cover this amount plus the network fee." + + /// `AccountBalance.typeTag` values (Rust `AccountType` discriminants). + private enum AccountTypeTag { + static let standard: UInt8 = 0 + static let dashpayReceivingFunds: UInt8 = 12 + } + + /// Whether `balance` belongs to the pooled Core funding set described on + /// `coreFundingAccountIndex`. Standard accounts are BIP44 and BIP32 alike + /// (`standardTag` 0 and 1); CoinJoin, DashPay external (watch-only) and + /// every non-funds account type are excluded, as they are in Rust. + static func isCoreFundingSource(_ balance: PlatformWalletManager.AccountBalance) -> Bool { + switch balance.typeTag { + case AccountTypeTag.standard: + return balance.index == coreFundingAccountIndex + case AccountTypeTag.dashpayReceivingFunds: + return true + default: + return false + } + } + + /// Confirmed balance the Core builders can spend — the "Core" balance the + /// send screen shows for every flow, so it never changes with the typed + /// destination. static func coreFundingBalance(_ balances: [PlatformWalletManager.AccountBalance]) -> UInt64 { - balances.first { - $0.typeTag == 0 && $0.standardTag == 0 && $0.index == coreFundingAccountIndex - }?.confirmed ?? 0 + balances.lazy + .filter(isCoreFundingSource) + .reduce(UInt64(0)) { total, balance in + let (sum, overflow) = total.addingReportingOverflow(balance.confirmed) + return overflow ? UInt64.max : sum + } } - /// The estimate is a UI preflight; Rust checks the finalized transaction fee. + /// Send gate including the Core funding balance. A UI preflight only: + /// 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 + switch detectedFlow { + case .coreToCore: + let (required, overflow) = coreSendTotalDuffs.addingReportingOverflow( + estimatedFee ?? SendFlow.coreToCore.estimatedFee + ) + return !overflow && coreBalance >= required + case .coreToShielded: + // The typed amount is the lock size; its L1 fee is only known + // once Rust selects inputs, so gate on the lock alone. + return (amountDuffs ?? 0) <= coreBalance + default: + return true + } } /// Determine which fund sources are available based on destination and balances. @@ -515,11 +564,14 @@ 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 + if flow == .coreToCore || flow == .coreToShielded { + let coreBalance = Self.coreFundingBalance( + walletManager.accountBalances(for: wallet.walletId) + ) + if !canSend(coreBalance: coreBalance) { + error = Self.insufficientCoreFundsMessage + return + } } isSending = true @@ -559,7 +611,7 @@ class SendViewModel: ObservableObject { } let signedTx = try builder.finalizeAtomic( wallet: platformWallet, - accountType: .bip44, + accountType: .allSpendable, accountIndex: Self.coreFundingAccountIndex ) // Core acceptance, rather than a successful peer socket write, @@ -829,22 +881,12 @@ class SendViewModel: ObservableObject { error = "Recipient is not a shielded address" return } - // Asset locks draw UTXOs from a SINGLE Core account, so - // fund from the standard BIP44 account (typeTag/standardTag - // 0) with the largest confirmed balance. The screen's - // "Core" total sums across accounts, so an amount under - // the displayed total can still fail here when the balance - // is split across several accounts. - let funding = walletManager.accountBalances(for: wallet.walletId) - .filter { $0.typeTag == 0 && $0.standardTag == 0 } - .max(by: { $0.confirmed < $1.confirmed }) - guard let funding, funding.confirmed > 0 else { - error = "No spendable Core account to fund the shield" - return - } + // The asset lock pools the same Core funding set as a Core + // payment (see `coreFundingAccountIndex`); the preflight at + // the top of this method checked its balance. try await walletManager.shieldedFundFromAssetLock( walletId: wallet.walletId, - fundingAccountIndex: funding.index, + fundingAccountIndex: Self.coreFundingAccountIndex, amountDuffs: amountDuffs, recipients: [ ShieldedFundFromAssetLockRecipient(recipientRaw43: recipientRaw) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index fca997624f8..bcd8fafae9d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -266,7 +266,7 @@ struct SendTransactionView: View { let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() // Platform payments select one account with sufficient funds. - // Core sends use the view model's BIP44 funding account. + // Core-funded sends use the view model's funding index. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -275,7 +275,7 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = 0 + senderAccountIndex = SendViewModel.coreFundingAccountIndex } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the @@ -454,13 +454,13 @@ struct SendTransactionView: View { // MARK: - Computed - /// Core sends display only the BIP44 account used by their builder. + /// Confirmed Core balance the send builders can spend + /// (`SendViewModel.coreFundingBalance`). Independent of the typed + /// destination, so it does not change while the user types. A function + /// rather than a computed property so callers snapshot the blocking FFI + /// read once per render and thread the value through. private func coreBalanceSnapshot() -> UInt64 { - let balances = walletManager.accountBalances(for: wallet.walletId) - if viewModel.detectedFlow == .coreToCore { - return SendViewModel.coreFundingBalance(balances) - } - return balances.reduce(0) { $0 + $1.confirmed } + SendViewModel.coreFundingBalance(walletManager.accountBalances(for: wallet.walletId)) } /// 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 c4ddeecb59f..b946bf641e0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift @@ -36,35 +36,63 @@ 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) + private 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 + ) + } + + /// The Core balance mirrors Rust's pooled funding set: BIP44 + BIP32 at + /// the funding index plus every DashPay receiving account (type 12). + func test_coreFundingPoolsTheRustSendSources() { + let pooled = [ + balance(confirmed: 100_000), // BIP44 #0 + balance(standard: 1, confirmed: 20_000), // BIP32 #0 + balance(type: 12, index: 3, confirmed: 3_000) // DashPay receiving ] - XCTAssertEqual(SendViewModel.coreFundingBalance(otherAccounts), 0) - XCTAssertEqual(SendViewModel.coreFundingBalance( - otherAccounts + [balance(confirmed: 0)] - ), 0) + XCTAssertEqual(SendViewModel.coreFundingBalance(pooled), 123_000) + } + + func test_coreFundingExcludesOtherIndicesAndAccountTypes() { + let excluded = [ + balance(index: 1, confirmed: 1_000_000), // BIP44 #1 + balance(standard: 1, index: 1, confirmed: 1_000_000), // BIP32 #1 + balance(type: 1, confirmed: 1_000_000), // CoinJoin + balance(type: 13, confirmed: 1_000_000), // DashPay external + balance(type: 14, confirmed: 1_000_000) // Platform payment + ] + XCTAssertEqual(SendViewModel.coreFundingBalance(excluded), 0) XCTAssertEqual(SendViewModel.coreFundingBalance( - otherAccounts + [balance(confirmed: 100_500)] + excluded + [balance(confirmed: 100_500)] ), 100_500) } - func test_coreFundingRequiresBatchAndEstimatedFeeInSelectedAccount() { + func test_coreFundingBalanceSaturatesInsteadOfOverflowing() { + XCTAssertEqual(SendViewModel.coreFundingBalance([ + balance(confirmed: UInt64.max), + balance(type: 12, confirmed: 1) + ]), UInt64.max) + } + + func test_insufficientCoreFundsMessageIsPlainLanguage() { + let message = SendViewModel.insufficientCoreFundsMessage + XCTAssertEqual( + message, + "Not enough confirmed Core funds to cover this amount plus the network fee." + ) + XCTAssertFalse(message.contains("BIP44")) + XCTAssertFalse(message.contains("account 0")) + } + + func test_coreFundingRequiresBatchAndEstimatedFeeInFundingBalance() { let vm = makeCoreToCoreViewModel(primaryAmount: "0.001") vm.estimatedFee = 500 XCTAssertFalse(vm.canSend(coreBalance: 100_000)) From 6189113a326f58d936ff74262bb1b7ae405e580a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:53:40 +0000 Subject: [PATCH 04/14] chore(swift-sdk): drop out-of-scope Swift and CI keychain changes The SwiftExampleApp send-funding changes and the CI keychain script changes are unrelated to the DashPay contact scan fix; restore them to the base branch so this PR only touches rs-platform-wallet. Co-Authored-By: Claude Opus 5.5 --- .../Core/ViewModels/SendViewModel.swift | 99 +++---------------- .../Core/Views/SendTransactionView.swift | 43 ++++++-- .../SendViewModelCoreRecipientsTests.swift | 86 +--------------- packages/swift-sdk/run_tests.sh | 18 +--- .../tests/run_tests_keychain_test.sh | 64 ------------ 5 files changed, 52 insertions(+), 258 deletions(-) delete mode 100644 packages/swift-sdk/tests/run_tests_keychain_test.sh diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 774bf5c1c6d..d86c0f13e05 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -380,74 +380,6 @@ class SendViewModel: ObservableObject { } } - // MARK: - Core funding - - /// Standard-account index every Core-funded flow draws from. - /// - /// One rule for every Core-funded flow: both Rust builders pool the same - /// default source set at this index (`SEND_FUNDING_SOURCES` == - /// `ASSET_LOCK_FUNDING_SOURCES` in rs-platform-wallet) — the BIP44 and - /// BIP32 accounts at the index plus every DashPay receiving account. - /// `coreToCore` reaches it through `.allSpendable`, `coreToShielded` - /// through the asset lock's `fundingAccountIndex`. - static let coreFundingAccountIndex: UInt32 = 0 - - /// Shown when the pooled Core funding set can't cover a Core-funded send. - static let insufficientCoreFundsMessage = - "Not enough confirmed Core funds to cover this amount plus the network fee." - - /// `AccountBalance.typeTag` values (Rust `AccountType` discriminants). - private enum AccountTypeTag { - static let standard: UInt8 = 0 - static let dashpayReceivingFunds: UInt8 = 12 - } - - /// Whether `balance` belongs to the pooled Core funding set described on - /// `coreFundingAccountIndex`. Standard accounts are BIP44 and BIP32 alike - /// (`standardTag` 0 and 1); CoinJoin, DashPay external (watch-only) and - /// every non-funds account type are excluded, as they are in Rust. - static func isCoreFundingSource(_ balance: PlatformWalletManager.AccountBalance) -> Bool { - switch balance.typeTag { - case AccountTypeTag.standard: - return balance.index == coreFundingAccountIndex - case AccountTypeTag.dashpayReceivingFunds: - return true - default: - return false - } - } - - /// Confirmed balance the Core builders can spend — the "Core" balance the - /// send screen shows for every flow, so it never changes with the typed - /// destination. - static func coreFundingBalance(_ balances: [PlatformWalletManager.AccountBalance]) -> UInt64 { - balances.lazy - .filter(isCoreFundingSource) - .reduce(UInt64(0)) { total, balance in - let (sum, overflow) = total.addingReportingOverflow(balance.confirmed) - return overflow ? UInt64.max : sum - } - } - - /// Send gate including the Core funding balance. A UI preflight only: - /// Rust checks the finalized transaction fee. - func canSend(coreBalance: UInt64) -> Bool { - guard canSend else { return false } - switch detectedFlow { - case .coreToCore: - let (required, overflow) = coreSendTotalDuffs.addingReportingOverflow( - estimatedFee ?? SendFlow.coreToCore.estimatedFee - ) - return !overflow && coreBalance >= required - case .coreToShielded: - // The typed amount is the lock size; its L1 fee is only known - // once Rust selects inputs, so gate on the lock alone. - return (amountDuffs ?? 0) <= coreBalance - default: - return true - } - } - /// Determine which fund sources are available based on destination and balances. func availableSources( coreBalance: UInt64, @@ -564,15 +496,6 @@ class SendViewModel: ObservableObject { modelContext: ModelContext ) async { guard let flow = detectedFlow else { return } - if flow == .coreToCore || flow == .coreToShielded { - let coreBalance = Self.coreFundingBalance( - walletManager.accountBalances(for: wallet.walletId) - ) - if !canSend(coreBalance: coreBalance) { - error = Self.insufficientCoreFundsMessage - return - } - } isSending = true error = nil @@ -611,8 +534,8 @@ class SendViewModel: ObservableObject { } let signedTx = try builder.finalizeAtomic( wallet: platformWallet, - accountType: .allSpendable, - accountIndex: Self.coreFundingAccountIndex + accountType: .bip44, + accountIndex: senderAccountIndex ) // Core acceptance, rather than a successful peer socket write, // is the boundary for showing payment success. @@ -881,12 +804,22 @@ class SendViewModel: ObservableObject { error = "Recipient is not a shielded address" return } - // The asset lock pools the same Core funding set as a Core - // payment (see `coreFundingAccountIndex`); the preflight at - // the top of this method checked its balance. + // Asset locks draw UTXOs from a SINGLE Core account, so + // fund from the standard BIP44 account (typeTag/standardTag + // 0) with the largest confirmed balance. The screen's + // "Core" total sums across accounts, so an amount under + // the displayed total can still fail here when the balance + // is split across several accounts. + let funding = walletManager.accountBalances(for: wallet.walletId) + .filter { $0.typeTag == 0 && $0.standardTag == 0 } + .max(by: { $0.confirmed < $1.confirmed }) + guard let funding, funding.confirmed > 0 else { + error = "No spendable Core account to fund the shield" + return + } try await walletManager.shieldedFundFromAssetLock( walletId: wallet.walletId, - fundingAccountIndex: Self.coreFundingAccountIndex, + fundingAccountIndex: funding.index, amountDuffs: amountDuffs, recipients: [ ShieldedFundFromAssetLockRecipient(recipientRaw43: recipientRaw) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index bcd8fafae9d..de48fdf33bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -265,8 +265,26 @@ struct SendTransactionView: View { // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() - // Platform payments select one account with sufficient funds. - // Core-funded sends use the view model's funding index. + // 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. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -275,7 +293,10 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = SendViewModel.coreFundingAccountIndex + senderAccountIndex = addressBalances + .filter { $0.account?.keyClass == 0 } + .first(where: { $0.balance > 0 })? + .accountIndex ?? 0 } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the @@ -298,7 +319,7 @@ struct SendTransactionView: View { ) } } - .disabled(!viewModel.canSend(coreBalance: coreBalance)) + .disabled(!viewModel.canSend) } } .disabled(viewModel.isSending) @@ -454,13 +475,15 @@ struct SendTransactionView: View { // MARK: - Computed - /// Confirmed Core balance the send builders can spend - /// (`SendViewModel.coreFundingBalance`). Independent of the typed - /// destination, so it does not change while the user types. A function - /// rather than a computed property so callers snapshot the blocking FFI - /// read once per render and thread the value through. + /// 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. private func coreBalanceSnapshot() -> UInt64 { - SendViewModel.coreFundingBalance(walletManager.accountBalances(for: wallet.walletId)) + walletManager.accountBalances(for: wallet.walletId) + .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 b946bf641e0..0cace941315 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift @@ -1,5 +1,5 @@ import XCTest -@testable import SwiftDashSDK +import SwiftDashSDK @testable import SwiftExampleApp /// Behavioral tests for `SendViewModel`'s multi-recipient Core batch — @@ -36,90 +36,6 @@ final class SendViewModelCoreRecipientsTests: XCTestCase { return vm } - private 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 - ) - } - - /// The Core balance mirrors Rust's pooled funding set: BIP44 + BIP32 at - /// the funding index plus every DashPay receiving account (type 12). - func test_coreFundingPoolsTheRustSendSources() { - let pooled = [ - balance(confirmed: 100_000), // BIP44 #0 - balance(standard: 1, confirmed: 20_000), // BIP32 #0 - balance(type: 12, index: 3, confirmed: 3_000) // DashPay receiving - ] - XCTAssertEqual(SendViewModel.coreFundingBalance(pooled), 123_000) - } - - func test_coreFundingExcludesOtherIndicesAndAccountTypes() { - let excluded = [ - balance(index: 1, confirmed: 1_000_000), // BIP44 #1 - balance(standard: 1, index: 1, confirmed: 1_000_000), // BIP32 #1 - balance(type: 1, confirmed: 1_000_000), // CoinJoin - balance(type: 13, confirmed: 1_000_000), // DashPay external - balance(type: 14, confirmed: 1_000_000) // Platform payment - ] - XCTAssertEqual(SendViewModel.coreFundingBalance(excluded), 0) - XCTAssertEqual(SendViewModel.coreFundingBalance( - excluded + [balance(confirmed: 100_500)] - ), 100_500) - } - - func test_coreFundingBalanceSaturatesInsteadOfOverflowing() { - XCTAssertEqual(SendViewModel.coreFundingBalance([ - balance(confirmed: UInt64.max), - balance(type: 12, confirmed: 1) - ]), UInt64.max) - } - - func test_insufficientCoreFundsMessageIsPlainLanguage() { - let message = SendViewModel.insufficientCoreFundsMessage - XCTAssertEqual( - message, - "Not enough confirmed Core funds to cover this amount plus the network fee." - ) - XCTAssertFalse(message.contains("BIP44")) - XCTAssertFalse(message.contains("account 0")) - } - - func test_coreFundingRequiresBatchAndEstimatedFeeInFundingBalance() { - 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 bc49c0e09bc..47ca4b095d3 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -23,18 +23,7 @@ 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 - # 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_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" PREV_USER_KEYCHAINS_OUTPUT="$(security list-keychains -d user)" PREV_USER_KEYCHAINS=() while IFS= read -r keychain_path; do @@ -59,10 +48,7 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then cleanup_status=0 trap - EXIT - # 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 [ "${CI_DEFAULT_MAY_HAVE_CHANGED:-0}" -eq 1 ]; 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 deleted file mode 100644 index 3adac9ce4ad..00000000000 --- a/packages/swift-sdk/tests/run_tests_keychain_test.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/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 From eb37d584b124b52b08cf667169d7bf86943d3639 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:54:56 +0000 Subject: [PATCH 05/14] refactor(platform-wallet): keep accountReference version shift local Revert the rs-platform-encryption API addition and name the DIP-15 accountReference version bit position with a private constant in rs-platform-wallet, documented against account_reference.rs, so this PR stays within one package. Co-Authored-By: Claude Opus 5.5 --- .../src/account_reference.rs | 27 +++++-------------- packages/rs-platform-encryption/src/lib.rs | 5 +--- .../src/wallet/identity/network/contacts.rs | 8 ++++-- .../src/wallet/identity/network/payments.rs | 2 +- 4 files changed, 14 insertions(+), 28 deletions(-) diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index 78ef57f3a0d..82f6a2b7324 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -31,21 +31,6 @@ fn extract_ask28(ask_bytes: &[u8; 32]) -> u32 { u32::from_be_bytes([ask_bytes[28], ask_bytes[29], ask_bytes[30], ask_bytes[31]]) >> 4 } -/// Bit position of the rotation `version` nibble in a DIP-15 `accountReference` -/// (`version << 28 | masked_index`). -pub const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; - -/// Mask of the low 28 bits carrying the masked account index. -const ACCOUNT_REFERENCE_INDEX_MASK: u32 = (1 << ACCOUNT_REFERENCE_VERSION_SHIFT) - 1; - -/// Rotation `version` of a DIP-15 `accountReference` (its top 4 bits). -/// -/// Readable without the sender's secret: the version is not masked, so a -/// recipient can tell a re-keyed (rotated) request from a first-generation one. -pub fn account_reference_version(account_reference: u32) -> u32 { - account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT -} - /// Calculate the masked DIP-15 `accountReference`: /// `result = (version << 28) | (ASK28 ^ (account_index & 0x0FFF_FFFF))`. /// @@ -60,8 +45,8 @@ pub fn calculate_account_reference( version: u32, ) -> u32 { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let shortened_account_bits = account_index & ACCOUNT_REFERENCE_INDEX_MASK; - let version_bits = version << ACCOUNT_REFERENCE_VERSION_SHIFT; + let shortened_account_bits = account_index & 0x0FFF_FFFF; + let version_bits = version << 28; version_bits | (ask28 ^ shortened_account_bits) } @@ -75,8 +60,8 @@ pub fn unmask_account_reference( compact_xpub: &[u8], ) -> (u32, u32) { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let version = account_reference_version(account_reference); - let account_index = (account_reference & ACCOUNT_REFERENCE_INDEX_MASK) ^ ask28; + let version = account_reference >> 28; + let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; (version, account_index) } @@ -96,11 +81,11 @@ mod tests { let secret_key = [1u8; 32]; let compact = test_compact_xpub(); assert_eq!( - account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 0)), + calculate_account_reference(&secret_key, &compact, 0, 0) >> 28, 0 ); assert_eq!( - account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 1)), + calculate_account_reference(&secret_key, &compact, 0, 1) >> 28, 1 ); assert_eq!( diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 747c0528d0f..6a6f9c4cc93 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -24,10 +24,7 @@ mod ecdh; mod error; pub use account_label::{decrypt_account_label, encrypt_account_label}; -pub use account_reference::{ - account_reference_version, calculate_account_reference, unmask_account_reference, - ACCOUNT_REFERENCE_VERSION_SHIFT, -}; +pub use account_reference::{calculate_account_reference, unmask_account_reference}; pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; pub use compact_xpub::{ compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, 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 b77017f3115..0f4948685a7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -8,7 +8,6 @@ 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 key_wallet::Wallet; -use platform_encryption::account_reference_version; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -18,6 +17,11 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; +/// Bit position of the rotation `version` in a DIP-15 `accountReference` +/// (`version << 28 | masked_index`); layout owned by +/// `rs-platform-encryption/src/account_reference.rs`. +pub(super) const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; + /// Which side of a DashPay relationship a contact account serves. /// /// Each side exposes a different xpub in a different request, so each has its @@ -73,7 +77,7 @@ pub(super) fn contact_scan_checkpoint( let Some(request) = request else { return birth_checkpoint; }; - if account_reference_version(request.account_reference) != 0 { + if request.account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT != 0 { return birth_checkpoint; } request.core_height_created_at.max(birth_checkpoint) 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 188479e1a1b..6b50cd48c44 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1652,11 +1652,11 @@ mod tests { }; use crate::error::PlatformWalletError; use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::contacts::ACCOUNT_REFERENCE_VERSION_SHIFT; use crate::wallet::identity::{ContactRequest, EstablishedContact}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; use crate::PlatformWalletManager; - use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; From a45ce652497241ae9d44bd9c56a22b11ad927f8d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:39:27 +0000 Subject: [PATCH 06/14] refactor(platform-encryption): expose the accountReference version shift Name the DIP-15 accountReference version bit position once in platform-encryption and use it in calculate/unmask and in platform-wallet's contact scan checkpoint instead of a duplicated 28. Co-Authored-By: Claude Opus 5.5 --- .../src/account_reference.rs | 17 ++++++++++++----- packages/rs-platform-encryption/src/lib.rs | 4 +++- .../src/wallet/identity/network/contacts.rs | 6 +----- .../src/wallet/identity/network/payments.rs | 2 +- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index 82f6a2b7324..aabb02861d4 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -10,6 +10,10 @@ /// the original sender un-masks it on re-send), every convention round-trips for /// its own sender; we match iOS so our sent requests are bit-identical to the /// incumbent wallet's. +/// Bit position of the rotation `version` in a masked `accountReference`: +/// the top 4 bits carry the version, the low 28 bits the masked account index. +pub const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; + fn account_secret_key_28(sender_secret_key: &[u8; 32], compact_xpub: &[u8]) -> u32 { use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -46,7 +50,7 @@ pub fn calculate_account_reference( ) -> u32 { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); let shortened_account_bits = account_index & 0x0FFF_FFFF; - let version_bits = version << 28; + let version_bits = version << ACCOUNT_REFERENCE_VERSION_SHIFT; version_bits | (ask28 ^ shortened_account_bits) } @@ -60,7 +64,7 @@ pub fn unmask_account_reference( compact_xpub: &[u8], ) -> (u32, u32) { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let version = account_reference >> 28; + let version = account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT; let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; (version, account_index) } @@ -81,15 +85,18 @@ mod tests { let secret_key = [1u8; 32]; let compact = test_compact_xpub(); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 0) >> 28, + calculate_account_reference(&secret_key, &compact, 0, 0) + >> ACCOUNT_REFERENCE_VERSION_SHIFT, 0 ); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 1) >> 28, + calculate_account_reference(&secret_key, &compact, 0, 1) + >> ACCOUNT_REFERENCE_VERSION_SHIFT, 1 ); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 15) >> 28, + calculate_account_reference(&secret_key, &compact, 0, 15) + >> ACCOUNT_REFERENCE_VERSION_SHIFT, 15 ); } diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 6a6f9c4cc93..4192d01223d 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -24,7 +24,9 @@ mod ecdh; mod error; pub use account_label::{decrypt_account_label, encrypt_account_label}; -pub use account_reference::{calculate_account_reference, unmask_account_reference}; +pub use account_reference::{ + calculate_account_reference, unmask_account_reference, ACCOUNT_REFERENCE_VERSION_SHIFT, +}; pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; pub use compact_xpub::{ compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, 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 0f4948685a7..36088bf382d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -8,6 +8,7 @@ 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 key_wallet::Wallet; +use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -17,11 +18,6 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Bit position of the rotation `version` in a DIP-15 `accountReference` -/// (`version << 28 | masked_index`); layout owned by -/// `rs-platform-encryption/src/account_reference.rs`. -pub(super) const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; - /// Which side of a DashPay relationship a contact account serves. /// /// Each side exposes a different xpub in a different request, so each has its 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 6b50cd48c44..188479e1a1b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1652,11 +1652,11 @@ mod tests { }; use crate::error::PlatformWalletError; use crate::events::{EventHandler, PlatformEventHandler}; - use crate::wallet::identity::network::contacts::ACCOUNT_REFERENCE_VERSION_SHIFT; use crate::wallet::identity::{ContactRequest, EstablishedContact}; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; use crate::PlatformWalletManager; + use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; From 01f88df58d88b22cbc4b92a3c26678ee6f87c37d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:42:28 +0000 Subject: [PATCH 07/14] refactor(platform-encryption): add secret-free account_reference_version accessor Callers that only need to know whether a request is a re-key read the version through a const fn instead of shifting the raw accountReference. Co-Authored-By: Claude Opus 5.5 --- .../src/account_reference.rs | 18 +++++++++++------- packages/rs-platform-encryption/src/lib.rs | 3 ++- .../src/wallet/identity/network/contacts.rs | 4 ++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index aabb02861d4..95f40ef4965 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -14,6 +14,13 @@ /// the top 4 bits carry the version, the low 28 bits the masked account index. pub const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; +/// Rotation `version` of a masked `accountReference`. Unlike +/// [`unmask_account_reference`] it needs no secret: the version bits are not +/// masked, so any party can tell whether a request is a re-key (`version > 0`). +pub const fn account_reference_version(account_reference: u32) -> u32 { + account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT +} + fn account_secret_key_28(sender_secret_key: &[u8; 32], compact_xpub: &[u8]) -> u32 { use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -64,7 +71,7 @@ pub fn unmask_account_reference( compact_xpub: &[u8], ) -> (u32, u32) { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let version = account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT; + let version = account_reference_version(account_reference); let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; (version, account_index) } @@ -85,18 +92,15 @@ mod tests { let secret_key = [1u8; 32]; let compact = test_compact_xpub(); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 0) - >> ACCOUNT_REFERENCE_VERSION_SHIFT, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 0)), 0 ); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 1) - >> ACCOUNT_REFERENCE_VERSION_SHIFT, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 1)), 1 ); assert_eq!( - calculate_account_reference(&secret_key, &compact, 0, 15) - >> ACCOUNT_REFERENCE_VERSION_SHIFT, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 15)), 15 ); } diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 4192d01223d..747c0528d0f 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -25,7 +25,8 @@ mod error; pub use account_label::{decrypt_account_label, encrypt_account_label}; pub use account_reference::{ - calculate_account_reference, unmask_account_reference, ACCOUNT_REFERENCE_VERSION_SHIFT, + account_reference_version, calculate_account_reference, unmask_account_reference, + ACCOUNT_REFERENCE_VERSION_SHIFT, }; pub use aes::{decrypt_aes_256_cbc, encrypt_aes_256_cbc}; pub use compact_xpub::{ 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 36088bf382d..b77017f3115 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -8,7 +8,7 @@ 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 key_wallet::Wallet; -use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; +use platform_encryption::account_reference_version; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -73,7 +73,7 @@ pub(super) fn contact_scan_checkpoint( let Some(request) = request else { return birth_checkpoint; }; - if request.account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT != 0 { + if account_reference_version(request.account_reference) != 0 { return birth_checkpoint; } request.core_height_created_at.max(birth_checkpoint) From 6dccd398fa72515a99f87689630ec6832bb45202 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:28:10 +0000 Subject: [PATCH 08/14] docs(platform-encryption): restore ASK28 rustdoc and name the account index mask The version-shift constant and accessor had been inserted between the ASK28 doc block and `account_secret_key_28`, which leaked that doc into the public `ACCOUNT_REFERENCE_VERSION_SHIFT` rustdoc and left the helper without one. Move them up to the top of the file so each doc sits on its own item. Also name the 28-bit index mask (`ACCOUNT_INDEX_MASK`, derived from the shift) and use it in both places that relied on the `0x0FFF_FFFF` literal. Co-Authored-By: Claude Opus 5.5 --- .../src/account_reference.rs | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index 95f40ef4965..ea03b51f044 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -1,19 +1,13 @@ //! DIP-15 `accountReference` (masked account index). -/// `ASK28 = (HMAC-SHA256(sender_secret_key, compact_xpub))[28..32] big-endian >> 4`. -/// -/// HMAC input is the 69-byte DIP-15 compact form (the `encryptedPublicKey` -/// plaintext). The ASK28 byte order matches iOS dash-shared-core -/// (`be(ASK[28..32]) >> 4`); see [`extract_ask28`] for the full four-convention -/// split (Android, dash-evo-tool, and the DIP literal all differ). Since -/// `accountReference` is a one-time-pad obfuscation that recipients ignore (only -/// the original sender un-masks it on re-send), every convention round-trips for -/// its own sender; we match iOS so our sent requests are bit-identical to the -/// incumbent wallet's. /// Bit position of the rotation `version` in a masked `accountReference`: /// the top 4 bits carry the version, the low 28 bits the masked account index. pub const ACCOUNT_REFERENCE_VERSION_SHIFT: u32 = 28; +/// Mask selecting the low 28 bits (the masked account index) of an +/// `accountReference`; the complement of the version bits. +const ACCOUNT_INDEX_MASK: u32 = (1 << ACCOUNT_REFERENCE_VERSION_SHIFT) - 1; + /// Rotation `version` of a masked `accountReference`. Unlike /// [`unmask_account_reference`] it needs no secret: the version bits are not /// masked, so any party can tell whether a request is a re-key (`version > 0`). @@ -21,6 +15,16 @@ pub const fn account_reference_version(account_reference: u32) -> u32 { account_reference >> ACCOUNT_REFERENCE_VERSION_SHIFT } +/// `ASK28 = (HMAC-SHA256(sender_secret_key, compact_xpub))[28..32] big-endian >> 4`. +/// +/// HMAC input is the 69-byte DIP-15 compact form (the `encryptedPublicKey` +/// plaintext). The ASK28 byte order matches iOS dash-shared-core +/// (`be(ASK[28..32]) >> 4`); see [`extract_ask28`] for the full four-convention +/// split (Android, dash-evo-tool, and the DIP literal all differ). Since +/// `accountReference` is a one-time-pad obfuscation that recipients ignore (only +/// the original sender un-masks it on re-send), every convention round-trips for +/// its own sender; we match iOS so our sent requests are bit-identical to the +/// incumbent wallet's. fn account_secret_key_28(sender_secret_key: &[u8; 32], compact_xpub: &[u8]) -> u32 { use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -56,7 +60,7 @@ pub fn calculate_account_reference( version: u32, ) -> u32 { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); - let shortened_account_bits = account_index & 0x0FFF_FFFF; + let shortened_account_bits = account_index & ACCOUNT_INDEX_MASK; let version_bits = version << ACCOUNT_REFERENCE_VERSION_SHIFT; version_bits | (ask28 ^ shortened_account_bits) } @@ -72,7 +76,7 @@ pub fn unmask_account_reference( ) -> (u32, u32) { let ask28 = account_secret_key_28(sender_secret_key, compact_xpub); let version = account_reference_version(account_reference); - let account_index = (account_reference & 0x0FFF_FFFF) ^ ask28; + let account_index = (account_reference & ACCOUNT_INDEX_MASK) ^ ask28; (version, account_index) } @@ -134,13 +138,13 @@ mod tests { let reference = calculate_account_reference(&secret_key, &compact, 0, 0); assert_eq!( - reference & 0x0FFF_FFFF, + reference & ACCOUNT_INDEX_MASK, expected_ask28, "ASK28 must be digest bytes [28..32] big-endian >> 4 (iOS dash-shared-core)" ); let old_ask28 = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]) >> 4; assert_ne!( - reference & 0x0FFF_FFFF, + reference & ACCOUNT_INDEX_MASK, old_ask28, "head-of-digest extraction is the old bug" ); From 66187ba5c7cf0f584de57f7afdf730b29c68e854 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:28:10 +0000 Subject: [PATCH 09/14] fix(platform-wallet): stop contact-driven rescans and bound receiving rescans by the earliest request - A contact could force rescans by rotating their request. The rotation rebuilds our watch-only `DashpayExternalAccount`, and that rebuild rewound `synced_height` to wallet birth and bumped the account generation. External accounts now go back to `insert_funds_bearing_account`, so a rebuild neither rewinds the scan nor bumps the generation. Upstream key-wallet already leaves external accounts out of balance and coin selection, so the #649 spend pruning cannot reach our funds through them. Our payments to the contact spend our own inputs, so they are still matched. - The receiving checkpoint now uses the earliest `$createdAtCoreBlockHeight` of any of our sent docs to that contact. Before, it used the newest tracked request. The sweep records these heights before collapsing docs to the newest one per recipient. They live in memory only: the sent cursor also resets on a cold start, so every process's first sweep refetches all sent docs before `reconcile_dashpay_rescan` runs. A version-0 re-send no longer raises the checkpoint, and a rotated relationship now has a real lower bound instead of wallet birth. When no earliest height is known, the old birth-floor fallback still applies. If a sweep finds an older publication than the checkpoint already applied, it clears the rescan guard so the gap gets backfilled. - Replaying an established contact (`apply_established_contact`) no longer clears the rescan guard, so a same-process replay after registration cannot trigger a second rewind. - Test hygiene: new tests use `should_` names, drip-feed and forward-covered test docs describe the current behaviour, and the leftover function-local duplicate imports are gone. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 177 ++++++++++- .../src/wallet/identity/network/contacts.rs | 109 +++---- .../src/wallet/identity/network/payments.rs | 300 ++++++++++++++---- .../managed_identity/contact_requests.rs | 31 +- .../state/managed_identity/dashpay.rs | 36 ++- 5 files changed, 526 insertions(+), 127 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 753cf5db9ff..f050c63cc3b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -882,6 +882,24 @@ fn newest_sent_per_recipient( newest } +/// Record the earliest `$createdAtCoreBlockHeight` per recipient over ALL of +/// our fetched sent docs, then collapse them to the newest per recipient. +/// +/// The collapse keeps only the newest request, but the receiving scan +/// checkpoint needs the OLDEST publication of our receiving xpub (see +/// `receiving_scan_checkpoint`), so it must be read before the older docs are +/// dropped. +fn record_and_collapse_sent_requests( + managed: &mut crate::wallet::identity::ManagedIdentity, + requests: impl IntoIterator, +) -> std::collections::BTreeMap { + let requests: Vec = requests.into_iter().collect(); + for request in &requests { + managed.note_sent_request_core_height(request.recipient_id, request.core_height_created_at); + } + newest_sent_per_recipient(requests) +} + /// Ingest one identity's collapsed **received** contact requests into local /// state, returning whether every write reached disk. /// @@ -1523,7 +1541,9 @@ impl DashPayView<'_, B> { // leaves the old + bumped docs on-chain and the fetch is // `$createdAt`-ASC, so ingesting raw would establish // against the stale OLDEST reference on a restore-from-seed - // and collide on the next rotation. + // and collide on the next rotation. The oldest doc's core + // height is recorded before the collapse: it bounds the + // receiving account's rescan. let parsed_sent = sent_docs.iter().filter_map(|(_doc_id, maybe_doc)| { let doc = maybe_doc.as_ref()?; // For a sent request the recipient is `toUserId`. @@ -1533,7 +1553,7 @@ impl DashPayView<'_, B> { .and_then(|v: &Value| v.to_identifier().ok())?; Self::parse_sent_contact_request_doc(doc, identity_id, recipient_id) }); - let newest_by_recipient = newest_sent_per_recipient(parsed_sent); + let newest_by_recipient = record_and_collapse_sent_requests(managed, parsed_sent); let sent_persist_ok = ingest_sent_requests( managed, @@ -4129,6 +4149,7 @@ mod sweep_tests { use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; + use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; use std::collections::BTreeMap; use std::sync::Arc; @@ -5015,6 +5036,158 @@ mod sweep_tests { ) } + /// One of our sent docs to `recipient` with an explicit Platform-assigned + /// `$createdAtCoreBlockHeight`. + fn sent_at_core_height( + our: u8, + recipient: u8, + account_reference: u32, + created_at: u64, + core_height: u32, + ) -> ContactRequest { + let mut request = test_request_at(our, recipient, account_reference, created_at); + request.core_height_created_at = core_height; + request + } + + /// Run the sweep's sent-side pipeline (record earliest heights, collapse, + /// ingest) over `docs` for identity `our`. + fn sweep_ingest_sent(info: &mut PlatformWalletInfo, our: u8, docs: Vec) { + let our_id = Identifier::from([our; 32]); + let managed = info + .identity_manager + .managed_identity_mut(&our_id) + .expect("managed identity"); + let newest = record_and_collapse_sent_requests(managed, docs); + assert!(ingest_sent_requests( + managed, + &noop_persister(), + our_id, + newest + )); + } + + /// Every request we send to a contact carries the same receiving xpub, so + /// the receiving checkpoint must come from the OLDEST sent doc. A later + /// version-0 re-send (another client's masking convention, or a device + /// that did not know the prior request) must not raise it. + #[test] + fn should_base_receiving_checkpoint_on_earliest_sent_doc_ingested_by_sweep() { + let mut info = info_with_bare_identity(1); + sweep_ingest_sent( + &mut info, + 1, + vec![ + sent_at_core_height(1, 2, 100, 100, 100), + sent_at_core_height(1, 2, 101, 200, 500), + ], + ); + let our = Identifier::from([1u8; 32]); + let recipient = Identifier::from([2u8; 32]); + let tracked = info + .identity_manager + .managed_identity(&our) + .and_then(|m| m.dashpay().sent_contact_requests().get(&recipient).cloned()) + .expect("newest sent doc tracked"); + assert_eq!(tracked.core_height_created_at, 500, "collapse keeps newest"); + assert_eq!( + super::super::contacts::receiving_scan_checkpoint(&info, &our, &recipient), + 100, + "the oldest publication of our receiving xpub bounds the rescan" + ); + } + + /// A rotated relationship (version bits set on the newest request) with a + /// known earliest publication rescans from that height, not wallet birth. + #[test] + fn should_use_earliest_known_height_for_rotated_relationship() { + let mut info = info_with_bare_identity(1); + info.core_wallet = ManagedWalletInfo::from_wallet(&build_test_wallet(), 50); + sweep_ingest_sent( + &mut info, + 1, + vec![ + sent_at_core_height(1, 2, 100, 100, 300), + sent_at_core_height(1, 2, (1 << ACCOUNT_REFERENCE_VERSION_SHIFT) | 7, 200, 900), + ], + ); + assert_eq!( + super::super::contacts::receiving_scan_checkpoint( + &info, + &Identifier::from([1u8; 32]), + &Identifier::from([2u8; 32]) + ), + 300 + ); + } + + /// Without a known earliest publication (no sweep has seen the sent docs + /// this process), a rotated request falls back to the wallet birth floor. + #[test] + fn should_fall_back_to_birth_for_rotated_request_without_known_earliest_height() { + let mut info = info_with_bare_identity(1); + info.core_wallet = ManagedWalletInfo::from_wallet(&build_test_wallet(), 50); + let our = Identifier::from([1u8; 32]); + info.identity_manager + .managed_identity_mut(&our) + .expect("managed identity") + .apply_sent_contact_request(sent_at_core_height( + 1, + 2, + (1 << ACCOUNT_REFERENCE_VERSION_SHIFT) | 7, + 200, + 900, + )); + assert_eq!( + super::super::contacts::receiving_scan_checkpoint( + &info, + &our, + &Identifier::from([2u8; 32]) + ), + 49 + ); + } + + /// A sweep that learns of an older publication than the one a registration + /// already used re-arms the rescan guard, so the next reconcile covers the + /// gap; a sweep that learns nothing older leaves the guard alone. + #[test] + fn should_rearm_rescan_only_when_sweep_learns_an_older_publication() { + let mut info = info_with_bare_identity(1); + let our = Identifier::from([1u8; 32]); + let recipient = Identifier::from([2u8; 32]); + let managed = info + .identity_manager + .managed_identity_mut(&our) + .expect("managed identity"); + managed.apply_sent_contact_request(sent_at_core_height(1, 2, 101, 200, 500)); + managed.dashpay_rescan_triggered_mut().insert(recipient); + + sweep_ingest_sent(&mut info, 1, vec![sent_at_core_height(1, 2, 101, 200, 500)]); + let guarded = |info: &PlatformWalletInfo| { + info.identity_manager + .managed_identity(&our) + .expect("managed identity") + .dashpay() + .rescan_triggered + .contains(&recipient) + }; + assert!(guarded(&info), "same publication must keep the guard"); + + sweep_ingest_sent( + &mut info, + 1, + vec![ + sent_at_core_height(1, 2, 100, 100, 100), + sent_at_core_height(1, 2, 101, 200, 500), + ], + ); + assert!( + !guarded(&info), + "an older publication must re-arm the rescan" + ); + } + /// **Sweep idempotency (the multi-doc thrash fix).** /// `contactRequest` docs are immutable and never deleted, so a sender /// who rotated leaves BOTH their old (ref=0) and bumped (ref=7) docs 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 b77017f3115..4b963db3b48 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -18,27 +18,19 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Which side of a DashPay relationship a contact account serves. -/// -/// Each side exposes a different xpub in a different request, so each has its -/// own scan checkpoint: see [`contact_scan_checkpoint`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ContactAccountSide { - /// `DashpayReceivingFunds`: our receiving xpub, published only in OUR - /// outgoing request. - Receiving, - /// `DashpayExternalAccount`: the contact's xpub, published only in THEIR - /// incoming request. - External, -} - -/// Return the last Core height already covered for a new contact account's side. +/// Return the last Core height already covered for our new receiving account. /// /// Scanning resumes at `H + 1`, where `H` is the Platform-assigned -/// `$createdAtCoreBlockHeight` of the request that published this side's xpub: -/// no payment to (or from) that xpub can predate the document exposing it. -/// Only that one request counts, so the other party's request (which they -/// control) can never lower this side's checkpoint. +/// `$createdAtCoreBlockHeight` of the earliest request that published our +/// receiving xpub to `contact`: no payment to it can predate that document. +/// Only OUR outgoing requests count, so the contact (who controls their own +/// requests) can never lower this checkpoint. +/// +/// Every request we send to one contact carries the same receiving xpub, so +/// the earliest sent doc a sweep saw this process is used when known, whatever +/// its version bits. Otherwise the tracked (newest) outgoing request is used, +/// unless it is a rotation (non-zero `accountReference` version): the +/// original request's height is then unknown. /// /// `H` must come from Platform, never from a client-written field: the caller /// raises `synced_height` to it, and `update_synced_height` prunes spend state @@ -46,37 +38,29 @@ pub(super) enum ContactAccountSide { /// [`add_managed_contact_account`]), so it never exceeds a range already /// scanned. /// -/// Falls back to the wallet birth floor when the owner is unknown, the side's -/// request is not tracked or has no height, or the request is a rotation -/// (non-zero `accountReference` version): a re-key may reuse the xpub, and the -/// original request's height is gone. -pub(super) fn contact_scan_checkpoint( +/// Falls back to the wallet birth floor when the owner is unknown, or no +/// earliest height is known and the tracked request is missing or rotated. +pub(super) fn receiving_scan_checkpoint( info: &PlatformWalletInfo, owner: &Identifier, contact: &Identifier, - side: ContactAccountSide, ) -> 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 established = dashpay.established_contacts().get(contact); - let request = match side { - ContactAccountSide::Receiving => established - .map(|contact| &contact.outgoing_request) - .or_else(|| dashpay.sent_contact_requests().get(contact)), - ContactAccountSide::External => established - .map(|contact| &contact.incoming_request) - .or_else(|| dashpay.incoming_contact_requests().get(contact)), - }; - let Some(request) = request else { - return birth_checkpoint; + let tracked = dashpay.outgoing_request(contact); + let checkpoint = match (dashpay.earliest_sent_core_height(contact), tracked) { + (Some(earliest), tracked) => tracked.map_or(earliest, |request| { + earliest.min(request.core_height_created_at) + }), + (None, Some(request)) if account_reference_version(request.account_reference) == 0 => { + request.core_height_created_at + } + (None, _) => return birth_checkpoint, }; - if account_reference_version(request.account_reference) != 0 { - return birth_checkpoint; - } - request.core_height_created_at.max(birth_checkpoint) + checkpoint.max(birth_checkpoint) } fn add_managed_contact_account( @@ -233,7 +217,7 @@ impl DashPayView<'_, B> { /// before reciprocating. /// /// Rewinds the filter scan to the account's contact scan checkpoint - /// (`contact_scan_checkpoint`; never above the current checkpoint) and + /// (`receiving_scan_checkpoint`; never above the current checkpoint) and /// marks the contact in /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) /// so [`Self::reconcile_dashpay_rescan`] does not rewind a second time. @@ -316,12 +300,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, - ContactAccountSide::Receiving, - ); + let scan_checkpoint = receiving_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` @@ -635,9 +614,8 @@ impl DashPayView<'_, B> { is_watch_only: true, }; - // 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. + // DashpayExternalAccount is funds-bearing; insert via the typed + // `insert_funds_bearing_account` API after the upstream split. let managed = key_wallet::managed_account::ManagedCoreFundsAccount::from_account(&account); // Persist the registration BEFORE the in-memory inserts (same @@ -664,12 +642,6 @@ impl DashPayView<'_, B> { self.wallet_id, ))) })?; - let scan_checkpoint = contact_scan_checkpoint( - info, - our_identity_id, - &contact_identity_id, - ContactAccountSide::External, - ); // (a) Insert Account into the immutable wallet account collection so the // xpub is accessible by `send_payment`. @@ -682,13 +654,24 @@ impl DashPayView<'_, B> { ))) })?; - // (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 - ))) - })?; + // (b) Insert ManagedCoreFundsAccount for address-pool tracking. Unlike + // the receiving account, this neither rewinds the filter scan nor + // bumps the account generation: the contact controls when this + // account is rebuilt (by rotating their request), so doing either + // would let them force rescans of our wallet. Nothing is lost by + // skipping it. External accounts are watch-only and excluded from + // balance and coin selection, so the #649 spend pruning cannot + // touch our funds through them. Our payments to the contact spend + // our own inputs, so they are matched anyway. + info.core_wallet + .accounts + .insert_funds_bearing_account(managed) + .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 188479e1a1b..a9125be9ade 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -10,7 +10,7 @@ use tokio::sync::RwLock; use key_wallet_manager::WalletManager; -use super::contacts::{contact_scan_checkpoint, ContactAccountSide}; +use super::contacts::receiving_scan_checkpoint; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -86,7 +86,7 @@ impl DashPayView<'_, B> { /// is known, whether or not the contact has reciprocated. /// /// This lowers the wallet's SPV `synced_height` to the minimum - /// contact scan checkpoint (`contact_scan_checkpoint`) across registered + /// receiving scan checkpoint (`receiving_scan_checkpoint`) across registered /// receival accounts that /// haven't been rescanned yet — the filter manager (`dash-spv`) then /// re-downloads nothing it already has, re-matches the now-larger script @@ -143,8 +143,7 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } - let checkpoint = - contact_scan_checkpoint(info, &owner, &contact, ContactAccountSide::Receiving); + let checkpoint = receiving_scan_checkpoint(info, &owner, &contact); // Contacts whose checkpoint is below the tip need a backfill — // their addresses weren't watched when those blocks were first // scanned. Contacts at or after the tip are already covered by the @@ -1092,7 +1091,6 @@ impl DashPayView<'_, B> { use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; let account_index: u32 = 0; @@ -2413,7 +2411,7 @@ mod tests { } #[tokio::test] - async fn contact_registration_preserves_deeper_scan_and_falls_back_when_unknown() { + async fn should_preserve_deeper_scan_and_fall_back_when_registration_checkpoint_unknown() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let pending = Identifier::from([0xB1; 32]); @@ -2818,9 +2816,6 @@ mod tests { /// from ever completing). #[tokio::test] async fn rescan_lowers_synced_height_to_outgoing_request_height_then_is_idempotent() { - use crate::wallet::identity::{ContactRequest, EstablishedContact}; - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); @@ -2929,7 +2924,7 @@ mod tests { /// `synced_height` comes back at its high-water, so reconcile must rewind /// once — and only once. #[tokio::test] - async fn rescan_covers_restored_sent_only_account() { + async fn should_cover_restored_sent_only_account_in_rescan() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); @@ -3060,11 +3055,70 @@ mod tests { assert_eq!(synced_height(&manager, wallet_id).await, 500); } + /// Replaying persisted state (`PlatformWallet::apply`, idempotent by + /// contract) in the same process must reproduce the established contact + /// without clearing the rescan guard that registration set; otherwise the + /// next sync rewinds over a range the registration already scheduled. + #[tokio::test] + async fn should_not_rewind_again_when_established_contact_is_replayed_after_registration() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0; 96], 100, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0; 96], 100, 0); + seed_owner_requests( + &manager, + &persister, + wallet_id, + owner, + vec![outgoing.clone(), incoming.clone()], + ) + .await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + wallet + .identity() + .dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("bootstrap registration"); + assert_eq!(synced_height(&manager, wallet_id).await, 100); + + let mut contacts = crate::changeset::ContactChangeSet::default(); + contacts.established.insert( + crate::changeset::SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }, + EstablishedContact::new(contact, outgoing, incoming), + ); + wallet + .apply(PlatformWalletChangeSet { + contacts: Some(contacts), + ..Default::default() + }) + .await + .expect("replay persisted established contact"); + + set_synced_height(&manager, wallet_id, 500).await; + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after replay"), + None, + "replay must not re-arm the rescan registration already scheduled" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 500); + } + /// The receiving account's checkpoint depends only on OUR outgoing request. /// A contact-controlled incoming request — rotated or older — must neither /// lower it at registration nor re-arm its rescan when it changes later. #[tokio::test] - async fn receiving_checkpoint_ignores_contact_request() { + async fn should_ignore_contact_request_in_receiving_checkpoint() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); @@ -3140,7 +3194,9 @@ mod tests { /// Register a receival account for `(owner, contact)` and insert an /// established contact whose requests sit at `out_height`/`in_height`. The owner managed - /// identity is added on first use. + /// identity is added on first use. The rescan guard is left clear, modelling + /// an account restored after a relaunch (the guard is in-memory only), so + /// the reconcile under test decides the rewind. async fn establish_receival_contact( manager: &Arc>, persister: &Arc, @@ -3150,7 +3206,6 @@ mod tests { out_height: u32, in_height: u32, ) { - use crate::wallet::identity::{ContactRequest, EstablishedContact}; let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = wallet.identity(); let p = WalletPersister::new(wallet_id, Arc::clone(persister) as _); @@ -3171,6 +3226,11 @@ mod tests { .managed_identity_mut(&owner) .expect("managed") .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .dashpay_rescan_triggered_mut() + .remove(&contact); } async fn set_synced_height( @@ -3178,7 +3238,6 @@ mod tests { wallet_id: WalletId, height: u32, ) { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let mut wm = wallet.identity().wallet_manager.write().await; wm.get_wallet_info_mut(&wallet_id) @@ -3191,7 +3250,6 @@ mod tests { manager: &Arc>, wallet_id: WalletId, ) -> u32 { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let wm = wallet.identity().wallet_manager.read().await; wm.get_wallet_info(&wallet_id) @@ -3203,8 +3261,8 @@ mod tests { /// A contact established while the wallet was still catching up (checkpoint at or /// after the current tip) is covered by the ongoing forward scan, so the /// rescan leaves `synced_height` alone — but it must MARK the contact so - /// that, once the forward pointer later climbs past the contact's funding - /// height, the recurring sweep does NOT redundantly rewind to an + /// that, once the forward pointer later climbs past the contact's request + /// checkpoint, the recurring sweep does NOT redundantly rewind to an /// already-scanned range. #[tokio::test] async fn rescan_does_not_redundantly_rewind_a_forward_covered_contact() { @@ -3212,8 +3270,8 @@ mod tests { let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); - // Funded at 500, but we have only synced to 450 — below the funding - // height, so a forward scan will cover it. + // Request checkpoint at 500, but we have only synced to 450 — below the + // checkpoint, so a forward scan will cover it. establish_receival_contact(&manager, &persister, wallet_id, owner, contact, 500, 500).await; set_synced_height(&manager, wallet_id, 450).await; @@ -3226,7 +3284,7 @@ mod tests { .await .expect("rescan"), None, - "funded above the tip -> no backfill" + "request checkpoint above the tip -> no backfill" ); assert_eq!( synced_height(&manager, wallet_id).await, @@ -3255,18 +3313,19 @@ mod tests { } /// Multiple contacts: the floor is the MINIMUM request checkpoint across all - /// not-yet-rescanned receival contacts (one rewind covers them all), and a - /// later-discovered, older-funded contact re-lowers exactly once before the - /// per-contact guard quiesces (drip-feed must not thrash). + /// not-yet-rescanned receival contacts (one rewind covers them all). A + /// later-discovered contact registered after the first rewind is covered by + /// the full-history scan that its registration triggered, so it is marked + /// without a second rewind and the drip-feed settles (no thrash). #[tokio::test] - async fn rescan_uses_min_funding_across_contacts_and_drip_feed_settles() { + async fn should_rewind_to_min_checkpoint_across_contacts_and_settle_drip_feed() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let c_a = Identifier::from([0xA1; 32]); let c_b = Identifier::from([0xB2; 32]); let c_c = Identifier::from([0xC3; 32]); - // Two contacts present at once (funded 300 and 100); tip at 1000. + // Two contacts present at once (request checkpoints 300 and 100); tip at 1000. establish_receival_contact(&manager, &persister, wallet_id, owner, c_a, 300, 300).await; establish_receival_contact(&manager, &persister, wallet_id, owner, c_b, 100, 100).await; set_synced_height(&manager, wallet_id, 1000).await; @@ -5389,30 +5448,7 @@ mod tests { // A real 69-byte compact xpub encrypted under a known shared key — the // wire shape a contact would have sent us. let shared_key = [0x55u8; 32]; - let iv = [0x11u8; 16]; - let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC) - .expect("mnemonic") - .to_seed(""); - let w = key_wallet::wallet::Wallet::from_seed_bytes( - seed, - Network::Testnet, - WalletAccountCreationOptions::None, - ) - .expect("seed wallet"); - crate::wallet::identity::crypto::dip14::derive_contact_xpub( - &w, - Network::Testnet, - 0, - &owner_id, - &contact_id, - ) - .expect("derive a valid compact xpub") - .compact - .to_bytes() - }; - let encrypted = - platform_encryption::encrypt_extended_public_key(&shared_key, &iv, &compact); + let encrypted = encrypted_contact_xpub(&owner_id, &contact_id, &shared_key); // Bare contact identity: the `Some` path must NOT touch the contact's // encryption key (the signer derives the secret out-of-crate). @@ -5437,14 +5473,13 @@ mod tests { 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" + 0, + "a watch-only external account must not invalidate in-flight filter scans" ); assert_eq!( info.synced_height(), - 300, - "external account scanning resumes after the incoming request's \ - $createdAtCoreBlockHeight; our rotated, older outgoing request is irrelevant" + 1_000, + "a watch-only external account must not rewind the filter scan" ); use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { @@ -5461,6 +5496,158 @@ mod tests { ); } + /// Encrypt the DIP-15 compact contact xpub for `(owner, contact)` under + /// `shared_key` — the `encryptedPublicKey` wire shape of a contact request. + fn encrypted_contact_xpub( + owner: &Identifier, + contact: &Identifier, + shared_key: &[u8; 32], + ) -> Vec { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) + .expect("mnemonic") + .to_seed(""); + let w = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"); + let compact = crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &w, + Network::Testnet, + 0, + owner, + contact, + ) + .expect("derive a valid compact xpub") + .compact + .to_bytes(); + platform_encryption::encrypt_extended_public_key(shared_key, &[0x11u8; 16], &compact) + } + + /// A contact controls when their incoming request rotates, which tears + /// down and rebuilds our watch-only `DashpayExternalAccount`. That rebuild + /// must neither rewind the filter scan nor invalidate an in-flight scan, + /// or every rotation (one document fee) would force a rescan of our + /// wallet. Our payments to the contact are still found through our own + /// spent inputs, and external accounts carry no balance of ours. + #[tokio::test] + async fn should_not_rewind_or_invalidate_scan_when_contact_rotation_rebuilds_external_account() + { + use key_wallet::account::account_collection::DashpayAccountKey; + + let (manager, persister, wallet_id) = make_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + let owner_id = Identifier::from([0x11; 32]); + let contact_id = Identifier::from([0x22; 32]); + let outgoing = ContactRequest::new(owner_id, contact_id, 0, 0, 0, vec![0; 96], 100, 0); + let incoming = ContactRequest::new(contact_id, owner_id, 0, 0, 0, vec![0; 96], 100, 0); + { + 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([0x11; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner_id) + .expect("managed owner") + .apply_established_contact(EstablishedContact::new(contact_id, outgoing, incoming)); + } + let shared_key = [0x55u8; 32]; + let contact = bare_identity([0x22; 32]); + iw.dashpay() + .register_external_contact_account( + &owner_id, + &contact, + &encrypted_contact_xpub(&owner_id, &contact_id, &shared_key), + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("initial external registration"); + + set_synced_height(&manager, wallet_id, 1_000).await; + let generation_before = { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .account_generation() + }; + + // The contact rotates (version bits set, like a DIP-15 re-key), and + // the sweep tears the stale external account down for a rebuild. + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_mut_and_info_mut(&wallet_id) + .expect("wallet and info"); + let rekeyed = info + .identity_manager + .managed_identity_mut(&owner_id) + .expect("managed owner") + .apply_rotated_incoming_request( + ContactRequest::new( + contact_id, + owner_id, + 0, + 0, + 1 << ACCOUNT_REFERENCE_VERSION_SHIFT, + vec![0; 96], + 900, + 0, + ), + &p, + ) + .expect("apply contact rotation"); + assert!(rekeyed, "established contact must be re-keyed"); + let key = DashpayAccountKey { + index: 0, + user_identity_id: owner_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } + + let rebuild_key = [0x66u8; 32]; + let registration = iw + .dashpay() + .register_external_contact_account( + &owner_id, + &contact, + &encrypted_contact_xpub(&owner_id, &contact_id, &rebuild_key), + zeroize::Zeroizing::new(rebuild_key), + ) + .await + .expect("rebuild external account after rotation"); + assert_eq!( + registration, + crate::wallet::identity::network::contacts::ExternalAccountRegistration::Built + ); + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.synced_height(), + 1_000, + "a contact-driven external rebuild must not rewind the filter scan" + ); + assert_eq!( + info.account_generation(), + generation_before, + "a contact-driven external rebuild must not invalidate an in-flight scan" + ); + } + /// Build a wallet with one owner identity (`[0x11;32]`) and one established /// contact (`[0x22;32]`) whose incoming / outgoing requests carry the given /// already-encrypted account-label ciphertexts. Returns the manager + the @@ -5474,8 +5661,6 @@ mod tests { Identifier, Identifier, ) { - use crate::wallet::identity::{ContactRequest, EstablishedContact}; - let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0x11; 32]); let contact = Identifier::from([0x22; 32]); @@ -5920,7 +6105,6 @@ mod tests { async fn unaccepted_recipient_purpose_never_fetches_and_stays_recoverable() { use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; - use crate::wallet::identity::{ContactRequest, EstablishedContact}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel}; @@ -6079,7 +6263,6 @@ mod tests { async fn drain_decides_our_own_key_fault_without_fetching_the_contact() { use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; - use crate::wallet::identity::{ContactRequest, EstablishedContact}; use dpp::identity::{KeyType, Purpose}; let (manager, persister, wallet_id) = make_wallet().await; @@ -6288,7 +6471,6 @@ mod tests { async fn sender_only_legacy_shape_is_not_charged_for_a_decrypt_failure() { use crate::changeset::{PendingContactCrypto, PendingContactCryptoOp}; use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; - use crate::wallet::identity::{ContactRequest, EstablishedContact}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel}; 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 c5d05c1ddbf..d40c6a34188 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 @@ -13,6 +13,7 @@ use crate::wallet::identity::crypto::contact_info::ContactInfoPrivateData; use crate::wallet::persister::WalletPersister; use crate::{ContactRequest, EstablishedContact}; use dpp::prelude::Identifier; +use platform_encryption::account_reference_version; impl ManagedIdentity { /// The masked `accountReference` of the most recent request WE sent @@ -796,6 +797,35 @@ impl ManagedIdentity { self.dashpay.high_water_sent_ms = advance_if_unchanged(self.dashpay.high_water_sent_ms, snapshot, max_fetched); } + + /// Record the Platform-assigned `$createdAtCoreBlockHeight` of one of our + /// sent requests to `recipient`, as fetched by a sync sweep. Keeps the + /// minimum; see `DashPayState::earliest_sent_core_heights`. + /// + /// When the height predates the receiving checkpoint already applied (the + /// previously known earliest height, else a version-0 tracked request's + /// own height), the rescan guard is cleared so the next + /// `reconcile_dashpay_rescan` backfills the gap. A rotated tracked request + /// was checkpointed at wallet birth, which nothing predates. + pub fn note_sent_request_core_height(&mut self, recipient: Identifier, core_height: u32) { + let applied = self + .dashpay + .earliest_sent_core_height(&recipient) + .or_else(|| { + self.dashpay + .outgoing_request(&recipient) + .filter(|request| account_reference_version(request.account_reference) == 0) + .map(|request| request.core_height_created_at) + }); + if applied.is_some_and(|applied| core_height < applied) { + self.dashpay.rescan_triggered.remove(&recipient); + } + self.dashpay + .earliest_sent_core_heights + .entry(recipient) + .and_modify(|earliest| *earliest = (*earliest).min(core_height)) + .or_insert(core_height); + } } // --- Apply (restore from changeset / cold load) --- @@ -824,7 +854,6 @@ 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 diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index d3af708bdac..362275b6718 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -79,6 +79,21 @@ pub struct DashPayState { /// High-water mark for the sent direction (`$ownerId == me`). pub(super) high_water_sent_ms: Option, + /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent + /// `contactRequest` docs to each recipient seen by a sweep this process. + /// + /// Every request to one recipient carries the same receiving xpub, so this + /// is the earliest height a payment to it can appear at — the receiving + /// account's scan checkpoint. The tracked outgoing request is only the + /// newest one, so its height can be too high. + /// + /// In-memory only (never persisted), like [`Self::high_water_sent_ms`]: + /// that cursor resets on cold start, so each process's first successful + /// sent fetch returns every sent doc and refills this map before + /// `reconcile_dashpay_rescan` runs. Only sweeps fill it, because replayed + /// or live-sent state knows only the newest request. + pub(super) earliest_sent_core_heights: BTreeMap, + /// DashPay profile (display name, bio, avatar, public message) /// published via the DashPay data contract. `None` until the /// profile has been fetched or set. @@ -132,8 +147,10 @@ pub struct DashPayState { /// `synced_height` to the account's scan checkpoint, records the contact /// here so the recurring sweep does not re-lower the height every pass — /// which would reset the in-flight backfill and prevent it from ever - /// completing. Only a change to OUR outgoing request (the sole input of - /// that checkpoint) clears the mark; the contact's own requests never do. + /// completing. Only a change to OUR outgoing requests (the sole input of + /// that checkpoint) clears the mark: a new outgoing request, or a sweep + /// finding an older sent doc than the checkpoint already applied. The + /// contact's own requests never clear it. /// /// In-memory only (never persisted): a relaunch clears it, and because /// `synced_height` is restored at its monotonic high-water, an interrupted @@ -199,4 +216,19 @@ impl DashPayState { pub fn high_water_sent_ms(&self) -> Option { self.high_water_sent_ms } + + /// Our tracked outgoing request to `contact`: the established contact's + /// outgoing side, else a pending sent request. + pub fn outgoing_request(&self, contact: &Identifier) -> Option<&ContactRequest> { + self.established_contacts + .get(contact) + .map(|established| &established.outgoing_request) + .or_else(|| self.sent_contact_requests.get(contact)) + } + + /// Earliest `$createdAtCoreBlockHeight` a sweep saw this process among our + /// sent requests to `recipient`; see the field doc. + pub fn earliest_sent_core_height(&self, recipient: &Identifier) -> Option { + self.earliest_sent_core_heights.get(recipient).copied() + } } From e56d946a130abc7251e8e88b7103ab9e9a39743b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:32:08 +0000 Subject: [PATCH 10/14] fix(platform-wallet): defer receiving rescans until a sent sweep completes The earliest-height map is filled only by sync sweeps. So before this process has fetched and ingested its sent requests, a receival contact missing from the map has only the tracked (newest) request to go on. A version-0 re-send there would put the checkpoint above the first publication of our receiving xpub. Each identity now keeps an in-memory `sent_sweep_completed` flag, set when the sent fetch succeeds and every ingest reaches disk. Until it is set, `reconcile_dashpay_rescan` defers contacts that have no earliest-height entry: it neither rewinds nor sets the guard, and the next successful sweep reconciles them against the real earliest height. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 5 +- .../src/wallet/identity/network/payments.rs | 144 +++++++++++++++++- .../managed_identity/contact_requests.rs | 7 + .../state/managed_identity/dashpay.rs | 18 ++- 4 files changed, 163 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index f050c63cc3b..fb589bc4a60 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -889,7 +889,7 @@ fn newest_sent_per_recipient( /// checkpoint needs the OLDEST publication of our receiving xpub (see /// `receiving_scan_checkpoint`), so it must be read before the older docs are /// dropped. -fn record_and_collapse_sent_requests( +pub(super) fn record_and_collapse_sent_requests( managed: &mut crate::wallet::identity::ManagedIdentity, requests: impl IntoIterator, ) -> std::collections::BTreeMap { @@ -999,7 +999,7 @@ fn ingest_received_requests( /// /// `add_sent_contact_request` carries its own duplicate / metadata-loss guard, /// so re-ingesting the same range on the next sweep is safe. -fn ingest_sent_requests( +pub(super) fn ingest_sent_requests( managed: &mut crate::wallet::identity::ManagedIdentity, persister: &crate::wallet::persister::WalletPersister, identity_id: Identifier, @@ -1639,6 +1639,7 @@ impl DashPayView<'_, B> { } if sent_ok && sent_persist_ok { managed.advance_high_water_sent(hw_sent, max_sent); + managed.mark_sent_sweep_completed(); } // A held-back cursor and a report that says "complete" cannot 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 a9125be9ade..c25e8d059cf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -93,7 +93,12 @@ impl DashPayView<'_, B> { /// set, and re-requests the matching blocks. Each contact is recorded in /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does /// not re-lower the height every pass (which would reset the in-flight - /// backfill and keep it from ever completing). Registration sets the same + /// backfill and keep it from ever completing). Contacts are deferred + /// (neither rewound nor marked) until this process has completed a + /// sent-request sweep, unless that sweep already recorded their earliest + /// sent-request height. Before that, the checkpoint could come from a newer + /// request than the one that first published our receiving xpub. + /// Registration sets the same /// mark, since it applies the checkpoint itself. The guard is in-memory, /// so a relaunch — where `synced_height` is restored at its high-water — /// safely re-triggers an interrupted backfill. @@ -143,6 +148,19 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } + // Until a sent sweep completes this process, a contact missing from + // the earliest-height map may have an older publication of our + // receiving xpub than the tracked (newest) request. Defer it: no + // rewind and no mark, so the next successful sweep reconciles it + // with the real earliest height. + if !managed.dashpay().sent_sweep_completed() + && managed + .dashpay() + .earliest_sent_core_height(&contact) + .is_none() + { + continue; + } let checkpoint = receiving_scan_checkpoint(info, &owner, &contact); // Contacts whose checkpoint is below the tip need a backfill — // their addresses weren't watched when those blocks were first @@ -2842,6 +2860,11 @@ mod tests { .managed_identity_mut(&owner) .expect("managed") .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); + // The DashPay sync's sent sweep has run this process. + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .mark_sent_sweep_completed(); // Simulate a forward sync to height 1000. info.core_wallet.update_synced_height(1000); } @@ -2952,15 +2975,17 @@ mod tests { .await .expect("register sent-only receival account"); - // Model the relaunch: guard gone, scan restored at its high-water. + // Model the relaunch: guard gone, scan restored at its high-water, and + // the first DashPay sync's sent sweep completed. { let mut wm = iw.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - info.identity_manager + let managed = info + .identity_manager .managed_identity_mut(&owner) - .expect("managed") - .dashpay_rescan_triggered_mut() - .clear(); + .expect("managed"); + managed.dashpay_rescan_triggered_mut().clear(); + managed.mark_sent_sweep_completed(); info.core_wallet.update_synced_height(1_000); } assert_eq!( @@ -3055,6 +3080,79 @@ mod tests { assert_eq!(synced_height(&manager, wallet_id).await, 500); } + /// Before this process has completed a sent-request sweep, the earliest + /// publication of our receiving xpub is unknown: the tracked request is + /// only the newest one. Reconcile must defer such a contact (no rewind, no + /// guard mark) and pick it up after the sweep, rewinding to the earliest + /// sent doc's height. + #[tokio::test] + async fn should_defer_rescan_until_sent_sweep_completes_then_rewind_to_earliest_height() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let older = ContactRequest::new(owner, contact, 0, 0, 100, vec![0; 96], 100, 1); + let newer = ContactRequest::new(owner, contact, 0, 0, 101, vec![0; 96], 500, 2); + establish_receival_contact_unswept( + &manager, &persister, wallet_id, owner, contact, 500, 500, + ) + .await; + set_synced_height(&manager, wallet_id, 1_000).await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let guarded = || async { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .identity_manager + .managed_identity(&owner) + .expect("managed") + .dashpay() + .rescan_triggered + .contains(&contact) + }; + + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile before sweep"), + None, + "no completed sent sweep yet -> defer" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_000); + assert!(!guarded().await, "a deferred contact must not be marked"); + + { + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + let newest = super::super::contact_requests::record_and_collapse_sent_requests( + managed, + vec![older, newer], + ); + assert!(super::super::contact_requests::ingest_sent_requests( + managed, &p, owner, newest + )); + managed.mark_sent_sweep_completed(); + } + + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after sweep"), + Some(100), + "after the sweep the rescan starts at the earliest sent doc" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 100); + assert!(guarded().await); + } + /// Replaying persisted state (`PlatformWallet::apply`, idempotent by /// contract) in the same process must reproduce the established contact /// without clearing the rescan guard that registration set; otherwise the @@ -3205,6 +3303,40 @@ mod tests { contact: Identifier, out_height: u32, in_height: u32, + ) { + establish_receival_contact_unswept( + manager, persister, wallet_id, owner, contact, out_height, in_height, + ) + .await; + mark_sent_sweep_completed(manager, wallet_id, owner).await; + } + + /// Mark `owner`'s sent-request sweep as completed this process, as a + /// successful DashPay sync would, so reconcile stops deferring its contacts. + async fn mark_sent_sweep_completed( + manager: &Arc>, + wallet_id: WalletId, + owner: Identifier, + ) { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .mark_sent_sweep_completed(); + } + + /// [`establish_receival_contact`] without a completed sent sweep. + async fn establish_receival_contact_unswept( + manager: &Arc>, + persister: &Arc, + wallet_id: WalletId, + owner: Identifier, + contact: Identifier, + out_height: u32, + in_height: u32, ) { let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = wallet.identity(); 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 d40c6a34188..ee91c1833fa 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 @@ -798,6 +798,13 @@ impl ManagedIdentity { advance_if_unchanged(self.dashpay.high_water_sent_ms, snapshot, max_fetched); } + /// Record that a sync sweep fetched and ingested all of this identity's + /// sent requests, making the earliest-height map authoritative. Call only + /// when the sent fetch succeeded and every ingest reached disk. + pub(crate) fn mark_sent_sweep_completed(&mut self) { + self.dashpay.sent_sweep_completed = true; + } + /// Record the Platform-assigned `$createdAtCoreBlockHeight` of one of our /// sent requests to `recipient`, as fetched by a sync sweep. Keeps the /// minimum; see `DashPayState::earliest_sent_core_heights`. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 362275b6718..9536bc63212 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -89,11 +89,18 @@ pub struct DashPayState { /// /// In-memory only (never persisted), like [`Self::high_water_sent_ms`]: /// that cursor resets on cold start, so each process's first successful - /// sent fetch returns every sent doc and refills this map before - /// `reconcile_dashpay_rescan` runs. Only sweeps fill it, because replayed - /// or live-sent state knows only the newest request. + /// sent fetch returns every sent doc and refills this map. Only sweeps fill + /// it, because replayed or live-sent state knows only the newest request. + /// Until [`Self::sent_sweep_completed`] is set the map is not + /// authoritative, and `reconcile_dashpay_rescan` defers contacts missing + /// from it. pub(super) earliest_sent_core_heights: BTreeMap, + /// Whether a sync sweep fetched and ingested this identity's sent requests + /// this process, filling [`Self::earliest_sent_core_heights`]. + /// In-memory only: a cold start clears it along with the map. + pub(super) sent_sweep_completed: bool, + /// DashPay profile (display name, bio, avatar, public message) /// published via the DashPay data contract. `None` until the /// profile has been fetched or set. @@ -226,6 +233,11 @@ impl DashPayState { .or_else(|| self.sent_contact_requests.get(contact)) } + /// Whether a sent-request sweep completed this process; see the field doc. + pub fn sent_sweep_completed(&self) -> bool { + self.sent_sweep_completed + } + /// Earliest `$createdAtCoreBlockHeight` a sweep saw this process among our /// sent requests to `recipient`; see the field doc. pub fn earliest_sent_core_height(&self, recipient: &Identifier) -> Option { From e2566077ca0d78b915eaed300d8eda7040191389 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:27:34 +0000 Subject: [PATCH 11/14] refactor(platform-wallet): tighten sent-height API and cover multi-owner rescans - note_sent_request_core_height is pub(crate): only the sent sweep may feed Platform-assigned heights into the receiving checkpoint. - Rename add_managed_contact_account to add_managed_receiving_account and document its contract (generation bump, checkpoint restore, receiving only). - Hoist inline crate/std/super paths in new code to imports. - Test reconcile_dashpay_rescan with two owner identities sharing one wallet. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 17 ++-- .../src/wallet/identity/network/contacts.rs | 32 +++++--- .../src/wallet/identity/network/payments.rs | 81 +++++++++++++++++++ .../managed_identity/contact_requests.rs | 6 +- 4 files changed, 116 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index fb589bc4a60..1281fbfad0a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -20,6 +20,8 @@ use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; +use crate::wallet::identity::ManagedIdentity; +use std::collections::BTreeMap; // --------------------------------------------------------------------------- // Deferred-crypto drain provider @@ -890,9 +892,9 @@ fn newest_sent_per_recipient( /// `receiving_scan_checkpoint`), so it must be read before the older docs are /// dropped. pub(super) fn record_and_collapse_sent_requests( - managed: &mut crate::wallet::identity::ManagedIdentity, + managed: &mut ManagedIdentity, requests: impl IntoIterator, -) -> std::collections::BTreeMap { +) -> BTreeMap { let requests: Vec = requests.into_iter().collect(); for request in &requests { managed.note_sent_request_core_height(request.recipient_id, request.core_height_created_at); @@ -4138,6 +4140,7 @@ mod contact_sync_report_tests { #[cfg(test)] mod sweep_tests { + use super::super::contacts::receiving_scan_checkpoint; use super::*; use crate::broadcaster::SpvBroadcaster; use crate::changeset::{ContactChangeSet, PlatformWalletChangeSet, SentContactRequestKey}; @@ -5092,7 +5095,7 @@ mod sweep_tests { .expect("newest sent doc tracked"); assert_eq!(tracked.core_height_created_at, 500, "collapse keeps newest"); assert_eq!( - super::super::contacts::receiving_scan_checkpoint(&info, &our, &recipient), + receiving_scan_checkpoint(&info, &our, &recipient), 100, "the oldest publication of our receiving xpub bounds the rescan" ); @@ -5113,7 +5116,7 @@ mod sweep_tests { ], ); assert_eq!( - super::super::contacts::receiving_scan_checkpoint( + receiving_scan_checkpoint( &info, &Identifier::from([1u8; 32]), &Identifier::from([2u8; 32]) @@ -5140,11 +5143,7 @@ mod sweep_tests { 900, )); assert_eq!( - super::super::contacts::receiving_scan_checkpoint( - &info, - &our, - &Identifier::from([2u8; 32]) - ), + receiving_scan_checkpoint(&info, &our, &Identifier::from([2u8; 32])), 49 ); } 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 4b963db3b48..57028a349fb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -35,7 +35,7 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; /// `H` must come from Platform, never from a client-written field: the caller /// raises `synced_height` to it, and `update_synced_height` prunes spend state /// up to that height. It is also bounded by the previous checkpoint (see -/// [`add_managed_contact_account`]), so it never exceeds a range already +/// [`add_managed_receiving_account`]), so it never exceeds a range already /// scanned. /// /// Falls back to the wallet birth floor when the owner is unknown, or no @@ -63,16 +63,26 @@ pub(super) fn receiving_scan_checkpoint( checkpoint.max(birth_checkpoint) } -fn add_managed_contact_account( +/// Add a `DashpayReceivingFunds` managed account and apply its scan checkpoint. +/// +/// Upstream `add_managed_account` inserts the account, bumps the wallet's +/// filter-scan generation (so no in-flight batch scanned without the new +/// scripts can certify coverage) and rewinds `synced_height` to wallet birth. +/// This then restores `min(previous, scan_checkpoint)`: only the range already +/// certified for the new account, preserving any deeper pending scan. The +/// caller must hold the manager write lock across the whole call so no scan +/// commit interleaves. +/// +/// Receiving accounts only. A `DashpayExternalAccount` is outbound and never +/// receives, so it must not rewind the wallet or invalidate in-flight scans; +/// it is inserted with `insert_funds_bearing_account` instead. +fn add_managed_receiving_account( info: &mut PlatformWalletInfo, 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)); @@ -313,11 +323,13 @@ impl DashPayView<'_, B> { "Failed to add contact account to wallet: {e}" )) })?; - add_managed_contact_account(info, wallet, account_type, scan_checkpoint).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to register contact account: {e}" - )) - })?; + add_managed_receiving_account(info, wallet, account_type, scan_checkpoint).map_err( + |e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register contact account: {e}" + )) + }, + )?; // The checkpoint just applied already schedules this account's // backfill, pending or established alike. Mark it so the next // `reconcile_dashpay_rescan` does not rewind over the same range again; 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 c25e8d059cf..0b5758e0db1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3520,6 +3520,87 @@ mod tests { ); } + /// Several Platform identities can share one HD wallet, and so its single + /// `synced_height`. One reconcile pass must take the floor across every + /// owner's candidates and mark each contact under its own owner: one + /// owner's contact needs a backfill while the other's is forward-covered. + #[tokio::test] + async fn should_rewind_to_min_checkpoint_across_owner_identities_sharing_a_wallet() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner_a = Identifier::from([0xAA; 32]); + let owner_b = Identifier::from([0xAB; 32]); + let contact_a = Identifier::from([0xA1; 32]); + let contact_b = Identifier::from([0xB1; 32]); + + // Distinct registration slots: the fixture adds a missing owner at + // index 0, which would replace owner A in the wallet's bucket. + { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let mut wm = wallet.identity().wallet_manager.write().await; + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + for (index, owner) in [(0, owner_a), (1, owner_b)] { + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), index, wallet_id, &p) + .expect("add owner"); + } + } + establish_receival_contact( + &manager, &persister, wallet_id, owner_a, contact_a, 300, 300, + ) + .await; + establish_receival_contact( + &manager, &persister, wallet_id, owner_b, contact_b, 2_000, 2_000, + ) + .await; + set_synced_height(&manager, wallet_id, 1_000).await; + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + Some(300), + "owner A's contact below the tip sets the wallet-wide floor" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 300); + { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let guarded = |owner: &Identifier, contact: &Identifier| { + info.identity_manager + .managed_identity(owner) + .expect("managed") + .dashpay() + .rescan_triggered + .contains(contact) + }; + assert!(guarded(&owner_a, &contact_a), "backfilled contact marked"); + assert!( + guarded(&owner_b, &contact_b), + "forward-covered contact marked under its own owner" + ); + assert!( + !guarded(&owner_a, &contact_b) && !guarded(&owner_b, &contact_a), + "marks never leak across owners" + ); + } + + // The forward scan climbs past owner B's checkpoint; neither owner's + // contact may pull the wallet back again. + set_synced_height(&manager, wallet_id, 2_500).await; + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None + ); + assert_eq!(synced_height(&manager, wallet_id).await, 2_500); + } + /// `synced_height == 0` means "scan from genesis / not started" — already a /// full historical scan. Reconcile leaves the height alone but marks the /// contact so advancing that scan does not cause a redundant rewind. 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 ee91c1833fa..5027f41eef6 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 @@ -814,7 +814,11 @@ impl ManagedIdentity { /// own height), the rescan guard is cleared so the next /// `reconcile_dashpay_rescan` backfills the gap. A rotated tracked request /// was checkpointed at wallet birth, which nothing predates. - pub fn note_sent_request_core_height(&mut self, recipient: Identifier, core_height: u32) { + pub(crate) fn note_sent_request_core_height( + &mut self, + recipient: Identifier, + core_height: u32, + ) { let applied = self .dashpay .earliest_sent_core_height(&recipient) From 6146c43ce55689fc693482edf304c873ba6e1348 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:24:18 +0000 Subject: [PATCH 12/14] refactor(platform-wallet): share the sent-sweep pipeline with tests and document rescan limits - Extract ingest_sent_sweep (record earliest heights, collapse, ingest, mark completed on fetch+persist success); the sweep and both test suites use it, and ingest_sent_requests / record_and_collapse_sent_requests go back to private. - Document why earliest heights are recorded for every fetched doc before ingest, and test a mid-batch persist failure followed by a retried sweep, plus a failed fetch never completing the sweep. - Note the known dash-spv in-flight-batch race at the reconcile checkpoint lowering; closing it needs an upstream generation-invalidation API. - Rewrap the reconcile_dashpay_rescan rustdoc. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 129 +++++++++++++++--- .../src/wallet/identity/network/payments.rs | 54 +++++--- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 1281fbfad0a..8e774a71da5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -21,6 +21,7 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; +use crate::wallet::persister::WalletPersister; use std::collections::BTreeMap; // --------------------------------------------------------------------------- @@ -884,6 +885,28 @@ fn newest_sent_per_recipient( newest } +/// Run the sweep's sent-side pipeline over one identity's fetched sent docs: +/// record earliest heights, collapse to the newest doc per recipient, ingest, +/// and mark the sent sweep completed. Returns whether every ingest reached +/// disk, with the same contract as [`ingest_sent_requests`]. +/// +/// The sweep counts as completed only when `fetch_ok` (the sent fetch +/// returned without error) and every ingest persisted. +pub(super) fn ingest_sent_sweep( + managed: &mut ManagedIdentity, + persister: &WalletPersister, + identity_id: Identifier, + requests: impl IntoIterator, + fetch_ok: bool, +) -> bool { + let newest_by_recipient = record_and_collapse_sent_requests(managed, requests); + let persisted = ingest_sent_requests(managed, persister, identity_id, newest_by_recipient); + if fetch_ok && persisted { + managed.mark_sent_sweep_completed(); + } + persisted +} + /// Record the earliest `$createdAtCoreBlockHeight` per recipient over ALL of /// our fetched sent docs, then collapse them to the newest per recipient. /// @@ -891,7 +914,16 @@ fn newest_sent_per_recipient( /// checkpoint needs the OLDEST publication of our receiving xpub (see /// `receiving_scan_checkpoint`), so it must be read before the older docs are /// dropped. -pub(super) fn record_and_collapse_sent_requests( +/// +/// Heights are recorded for every fetched doc BEFORE ingest, including docs a +/// later persist failure leaves un-ingested. That is the safe direction: each +/// height is a Platform-assigned fact about a document that exists on-chain, +/// whatever happens to our local copy, and it can only lower the checkpoint +/// (a deeper scan, never a missed payment). The order is load-bearing too: +/// `note_sent_request_core_height` compares against the checkpoint the +/// currently tracked request produced, which ingest may replace with a newer +/// (possibly rotated) request and so hide an older publication. +fn record_and_collapse_sent_requests( managed: &mut ManagedIdentity, requests: impl IntoIterator, ) -> BTreeMap { @@ -1001,9 +1033,9 @@ fn ingest_received_requests( /// /// `add_sent_contact_request` carries its own duplicate / metadata-loss guard, /// so re-ingesting the same range on the next sweep is safe. -pub(super) fn ingest_sent_requests( - managed: &mut crate::wallet::identity::ManagedIdentity, - persister: &crate::wallet::persister::WalletPersister, +fn ingest_sent_requests( + managed: &mut ManagedIdentity, + persister: &WalletPersister, identity_id: Identifier, newest_by_recipient: std::collections::BTreeMap, ) -> bool { @@ -1555,14 +1587,8 @@ impl DashPayView<'_, B> { .and_then(|v: &Value| v.to_identifier().ok())?; Self::parse_sent_contact_request_doc(doc, identity_id, recipient_id) }); - let newest_by_recipient = record_and_collapse_sent_requests(managed, parsed_sent); - - let sent_persist_ok = ingest_sent_requests( - managed, - &self.persister, - identity_id, - newest_by_recipient, - ); + let sent_persist_ok = + ingest_sent_sweep(managed, &self.persister, identity_id, parsed_sent, sent_ok); // (2a') Rotation self-heal across restart: an external account // rebuilt from the persisted (tombstone-less) registration @@ -1641,7 +1667,6 @@ impl DashPayView<'_, B> { } if sent_ok && sent_persist_ok { managed.advance_high_water_sent(hw_sent, max_sent); - managed.mark_sent_sweep_completed(); } // A held-back cursor and a report that says "complete" cannot @@ -5062,12 +5087,12 @@ mod sweep_tests { .identity_manager .managed_identity_mut(&our_id) .expect("managed identity"); - let newest = record_and_collapse_sent_requests(managed, docs); - assert!(ingest_sent_requests( + assert!(ingest_sent_sweep( managed, &noop_persister(), our_id, - newest + docs, + true )); } @@ -5148,6 +5173,78 @@ mod sweep_tests { ); } + /// A persist failure mid-batch still records every fetched doc's height + /// (Platform facts, the safe direction; see + /// `record_and_collapse_sent_requests`) but must not certify the sweep + /// complete. A retried sweep with a working persister completes it. + #[test] + fn should_record_all_heights_but_not_complete_sweep_when_sent_persist_fails() { + let mut info = info_with_bare_identity(1); + let our = Identifier::from([1u8; 32]); + let first = Identifier::from([2u8; 32]); + let second = Identifier::from([3u8; 32]); + let docs = || { + vec![ + sent_at_core_height(1, 2, 100, 100, 100), + sent_at_core_height(1, 3, 100, 200, 200), + ] + }; + let managed = info + .identity_manager + .managed_identity_mut(&our) + .expect("managed identity"); + + assert!(!ingest_sent_sweep( + managed, + &failing_persister(), + our, + docs(), + true + )); + assert_eq!( + managed.dashpay().earliest_sent_core_height(&first), + Some(100) + ); + assert_eq!( + managed.dashpay().earliest_sent_core_height(&second), + Some(200) + ); + assert!( + managed.dashpay().sent_contact_requests().is_empty(), + "nothing reached disk, so nothing was ingested" + ); + assert!(!managed.dashpay().sent_sweep_completed()); + + assert!(ingest_sent_sweep( + managed, + &noop_persister(), + our, + docs(), + true + )); + assert_eq!(managed.dashpay().sent_contact_requests().len(), 2); + assert!(managed.dashpay().sent_sweep_completed()); + } + + /// A failed sent fetch ingests nothing and must not certify the sweep. + #[test] + fn should_not_complete_sent_sweep_when_fetch_failed() { + let mut info = info_with_bare_identity(1); + let our = Identifier::from([1u8; 32]); + let managed = info + .identity_manager + .managed_identity_mut(&our) + .expect("managed identity"); + assert!(ingest_sent_sweep( + managed, + &noop_persister(), + our, + Vec::new(), + false + )); + assert!(!managed.dashpay().sent_sweep_completed()); + } + /// A sweep that learns of an older publication than the one a registration /// already used re-arms the rescan guard, so the next reconcile covers the /// gap; a sweep that learns nothing older leaves the guard alone. 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 0b5758e0db1..f3360e61842 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -85,23 +85,24 @@ impl DashPayView<'_, B> { /// block is silently missed. Accounts exist as soon as our outgoing request /// is known, whether or not the contact has reciprocated. /// - /// This lowers the wallet's SPV `synced_height` to the minimum - /// receiving scan checkpoint (`receiving_scan_checkpoint`) across registered - /// receival accounts 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 - /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does - /// not re-lower the height every pass (which would reset the in-flight - /// backfill and keep it from ever completing). Contacts are deferred - /// (neither rewound nor marked) until this process has completed a - /// sent-request sweep, unless that sweep already recorded their earliest - /// sent-request height. Before that, the checkpoint could come from a newer - /// request than the one that first published our receiving xpub. - /// Registration sets the same - /// mark, since it applies the checkpoint itself. The guard is in-memory, - /// so a relaunch — where `synced_height` is restored at its high-water — - /// safely re-triggers an interrupted backfill. + /// This lowers the wallet's SPV `synced_height` to the minimum receiving + /// scan checkpoint (`receiving_scan_checkpoint`) across registered receival + /// accounts 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 + /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) + /// so the recurring sweep does not re-lower the height every pass (which + /// would reset the in-flight backfill and keep it from ever completing). + /// + /// Contacts are deferred (neither rewound nor marked) until this process + /// has completed a sent-request sweep, unless that sweep already recorded + /// their earliest sent-request height. Before that, the checkpoint could + /// come from a newer request than the one that first published our + /// receiving xpub. Registration sets the same mark, since it applies the + /// checkpoint itself. The guard is in-memory, so a relaunch — where + /// `synced_height` is restored at its high-water — safely re-triggers an + /// interrupted backfill. /// /// `synced_height` may regress here: that is safe because it is the /// filter-scan checkpoint, decoupled from the monotonic @@ -184,6 +185,15 @@ impl DashPayView<'_, B> { // below the tip (otherwise every handled contact is forward-covered and we // record the guard without rewinding). The engine clamps `floor` to its // own header/birth floor, so no double-clamp here. + // + // Known limitation: a plain `update_synced_height` does not invalidate + // dash-spv's in-flight filter batches. A batch scanned at the old + // checkpoint skipped this wallet's heights up to it, yet can still + // commit past the lowered value (same account generation, contiguity + // passes), certifying a range never matched for this wallet. Closing + // it needs a public rust-dashcore API that lowers the checkpoint and + // bumps the account generation together. + // TODO: link the tracking issue for that upstream API. if let Some(floor) = floor { info.core_wallet.update_synced_height(floor); } @@ -1662,6 +1672,7 @@ mod tests { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::Network; + use super::super::contact_requests::ingest_sent_sweep; use crate::changeset::{ ClientStartState, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, @@ -3131,14 +3142,13 @@ mod tests { .identity_manager .managed_identity_mut(&owner) .expect("managed"); - let newest = super::super::contact_requests::record_and_collapse_sent_requests( + assert!(ingest_sent_sweep( managed, + &p, + owner, vec![older, newer], - ); - assert!(super::super::contact_requests::ingest_sent_requests( - managed, &p, owner, newest + true )); - managed.mark_sent_sweep_completed(); } assert_eq!( From 504a717e9d2a357e00f6f026b9e92676c99d509b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:24:44 +0000 Subject: [PATCH 13/14] docs(platform-wallet): link the in-flight scan race tracking issue Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/wallet/identity/network/payments.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f3360e61842..351dfc4e0a7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -193,7 +193,7 @@ impl DashPayView<'_, B> { // passes), certifying a range never matched for this wallet. Closing // it needs a public rust-dashcore API that lowers the checkpoint and // bumps the account generation together. - // TODO: link the tracking issue for that upstream API. + // Tracked in https://github.com/dashpay/platform/issues/4955. if let Some(floor) = floor { info.core_wallet.update_synced_height(floor); } From f9426a4236d2c9fe58bbffd57a34e9a9d84ddf37 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:05:37 +0000 Subject: [PATCH 14/14] test(platform-wallet): complete the sent sweep before asserting the registration guard Without a completed sent sweep, reconcile_dashpay_rescan returns early through its pre-sweep deferral, so the guard tests passed even with the registration guard removed. Mark the sweep complete so only the guard prevents the rewind. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/payments.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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 351dfc4e0a7..3c905b88312 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3079,6 +3079,9 @@ mod tests { .expect("register after send"); assert_eq!(synced_height(&manager, wallet_id).await, 100); + // Complete the sent sweep so reconcile cannot defer: only the + // registration guard may keep it from rewinding. + mark_sent_sweep_completed(&manager, wallet_id, owner).await; set_synced_height(&manager, wallet_id, 500).await; assert_eq!( iw.dashpay() @@ -3208,6 +3211,9 @@ mod tests { .await .expect("replay persisted established contact"); + // Complete the sent sweep so reconcile cannot defer: only the + // registration guard may keep it from rewinding. + mark_sent_sweep_completed(&manager, wallet_id, owner).await; set_synced_height(&manager, wallet_id, 500).await; assert_eq!( wallet @@ -3289,6 +3295,9 @@ mod tests { .expect("apply contact rotation"); assert!(rekeyed, "established contact must be re-keyed"); } + // Complete the sent sweep so reconcile cannot defer: only the guard may + // keep the contact's rotation from forcing a rewind. + mark_sent_sweep_completed(&manager, wallet_id, owner).await; assert_eq!( iw.dashpay() .reconcile_dashpay_rescan()