diff --git a/packages/rs-platform-encryption/src/account_reference.rs b/packages/rs-platform-encryption/src/account_reference.rs index 82f6a2b7324..ea03b51f044 100644 --- a/packages/rs-platform-encryption/src/account_reference.rs +++ b/packages/rs-platform-encryption/src/account_reference.rs @@ -1,5 +1,20 @@ //! DIP-15 `accountReference` (masked account index). +/// 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`). +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` @@ -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_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_INDEX_MASK) ^ ask28; (version, account_index) } @@ -81,15 +96,15 @@ 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!( - calculate_account_reference(&secret_key, &compact, 0, 15) >> 28, + account_reference_version(calculate_account_reference(&secret_key, &compact, 0, 15)), 15 ); } @@ -123,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" ); 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/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 753cf5db9ff..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 @@ -20,6 +20,9 @@ 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 crate::wallet::persister::WalletPersister; +use std::collections::BTreeMap; // --------------------------------------------------------------------------- // Deferred-crypto drain provider @@ -882,6 +885,55 @@ 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. +/// +/// 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. +/// +/// 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 { + 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. /// @@ -982,8 +1034,8 @@ 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( - managed: &mut crate::wallet::identity::ManagedIdentity, - persister: &crate::wallet::persister::WalletPersister, + managed: &mut ManagedIdentity, + persister: &WalletPersister, identity_id: Identifier, newest_by_recipient: std::collections::BTreeMap, ) -> bool { @@ -1523,7 +1575,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,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 = newest_sent_per_recipient(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 @@ -4117,6 +4165,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}; @@ -4129,6 +4178,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 +5065,226 @@ 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"); + assert!(ingest_sent_sweep( + managed, + &noop_persister(), + our_id, + docs, + true + )); + } + + /// 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!( + 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!( + 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!( + receiving_scan_checkpoint(&info, &our, &Identifier::from([2u8; 32])), + 49 + ); + } + + /// 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. + #[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 a19288f4128..57028a349fb 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,10 @@ 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 key_wallet::Wallet; +use platform_encryption::account_reference_version; use super::*; use crate::broadcaster::TransactionBroadcaster; @@ -14,6 +18,77 @@ 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 Core height already covered for our new receiving account. +/// +/// Scanning resumes at `H + 1`, where `H` is the Platform-assigned +/// `$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 +/// up to that height. It is also bounded by the previous checkpoint (see +/// [`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 +/// earliest height is known and the tracked request is missing or rotated. +pub(super) fn receiving_scan_checkpoint( + info: &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 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, + }; + checkpoint.max(birth_checkpoint) +} + +/// 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(); + 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 @@ -147,7 +222,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 + /// (`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. /// /// No-op if the account already exists for this contact relationship. pub async fn register_contact_account( @@ -203,9 +286,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 +310,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 = 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` @@ -239,14 +323,22 @@ impl DashPayView<'_, B> { "Failed to add contact account to wallet: {e}" )) })?; - info.core_wallet - .accounts - .insert_funds_bearing_account(managed) - .map_err(|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; + // a later change to our outgoing request clears the mark. + if let Some(managed) = info.identity_manager.managed_identity_mut(our_identity_id) { + managed + .dashpay_rescan_triggered_mut() + .insert(*contact_identity_id); + } tracing::info!( our_identity = %our_identity_id, @@ -534,8 +626,8 @@ impl DashPayView<'_, B> { is_watch_only: true, }; - // DashpayExternalAccount is funds-bearing; insert via the - // typed `insert_funds` API after the upstream split. + // 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 @@ -574,7 +666,15 @@ impl DashPayView<'_, B> { ))) })?; - // (b) Insert ManagedCoreFundsAccount for address-pool tracking. + // (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) 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..3c905b88312 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::receiving_scan_checkpoint; use super::*; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -75,23 +77,32 @@ 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 established 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 - /// [`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. + /// 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 @@ -101,19 +112,15 @@ 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); }; + // 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 checkpoint 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 +137,9 @@ 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 + // 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 { @@ -146,23 +149,30 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } - let Some(established) = managed.dashpay().established_contacts().get(&contact) else { + // 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 funding = established - .outgoing_request - .core_height_created_at - .min(established.incoming_request.core_height_created_at); - // 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. - if funding < synced_height { - floor = Some(floor.map_or(funding, |cur| cur.min(funding))); + } + 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 + // 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))); } to_mark.push((owner, contact)); } @@ -171,10 +181,19 @@ 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. + // + // 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. + // Tracked in https://github.com/dashpay/platform/issues/4955. if let Some(floor) = floor { info.core_wallet.update_synced_height(floor); } @@ -1100,7 +1119,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; @@ -1651,17 +1669,21 @@ 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 super::super::contact_requests::ingest_sent_sweep; use crate::changeset::{ ClientStartState, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; 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"; @@ -2288,6 +2310,35 @@ mod tests { 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 +2356,26 @@ 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, + "the sent request's Platform-assigned $createdAtCoreBlockHeight is the \ + checkpoint; scanning resumes at H + 1" + ); + } + { let stores = persister.stores.lock().unwrap(); let registered = stores.iter().any(|(_, cs)| { @@ -2329,6 +2400,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,13 +2415,102 @@ 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 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]); + 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 << ACCOUNT_REFERENCE_VERSION_SHIFT, + 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 @@ -2675,17 +2836,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() { - use crate::wallet::identity::{ContactRequest, EstablishedContact}; - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - + async fn rescan_lowers_synced_height_to_outgoing_request_height_then_is_idempotent() { let (manager, persister, wallet_id) = make_wallet().await; let owner = Identifier::from([0xAA; 32]); let contact = Identifier::from([0xBB; 32]); @@ -2704,13 +2863,19 @@ 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") .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); } @@ -2721,7 +2886,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; @@ -2756,149 +2921,576 @@ mod tests { } } - /// 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. - async fn establish_receival_contact( + /// 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, - contact: Identifier, - out_height: u32, - in_height: u32, + requests: Vec, ) { - 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 _); - iw.dashpay() - .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) - .await - .expect("register receival account"); - let mut wm = iw.wallet_manager.write().await; + 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 outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], out_height, 0); - let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], in_height, 0); - info.identity_manager + let managed = info + .identity_manager .managed_identity_mut(&owner) - .expect("managed") - .apply_established_contact(EstablishedContact::new(contact, outgoing, incoming)); - } - - async fn set_synced_height( - manager: &Arc>, - 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) - .expect("info") - .core_wallet - .update_synced_height(height); - } - - async fn synced_height( - 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) - .expect("info") - .core_wallet - .synced_height() + .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 contact established while the wallet was still catching up (funded 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 - /// already-scanned range. + /// 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_does_not_redundantly_rewind_a_forward_covered_contact() { + 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]); + 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(); + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register sent-only receival account"); - // Funded at 500, but we have only synced to 450 — below the funding - // height, 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; - - let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + // 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"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + managed.dashpay_rescan_triggered_mut().clear(); + managed.mark_sent_sweep_completed(); + info.core_wallet.update_synced_height(1_000); + } assert_eq!( - iw_wallet - .identity() - .dashpay() + iw.dashpay() .reconcile_dashpay_rescan() .await - .expect("rescan"), - None, - "funded above the tip -> no backfill" + .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!( - synced_height(&manager, wallet_id).await, - 450, - "height unchanged" + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("sent-only retry"), + None, + "the same pending relationship must not restart its backfill" ); - // Forward sync climbs past the funding height. The contact was marked, - // so the next sweep must not re-lower to 500. - set_synced_height(&manager, wallet_id, 600).await; + // 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") + .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"); + } assert_eq!( - iw_wallet - .identity() - .dashpay() + iw.dashpay() .reconcile_dashpay_rescan() .await - .expect("rescan 2"), + .expect("established reconcile"), None, - "forward-covered contact must not be redundantly rewound" - ); - assert_eq!( - synced_height(&manager, wallet_id).await, - 600, - "no redundant rewind" + "the contact's request must not re-arm our receiving account's rescan" ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_000); } - /// Multiple contacts: the floor is the MINIMUM funding height 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). + /// `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 rescan_uses_min_funding_across_contacts_and_drip_feed_settles() { + 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 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. - 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; + 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); - let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + // 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_wallet - .identity() + 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); + } + + /// 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"); + assert!(ingest_sent_sweep( + managed, + &p, + owner, + vec![older, newer], + true + )); + } + + 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 + /// 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"); + + // 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 + .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 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]); + 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") + .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"); + } + // 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() + .await + .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 whose requests sit at `out_height`/`in_height`. The owner managed + /// 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, + wallet_id: WalletId, + owner: Identifier, + 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(); + 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 receival account"); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + if info.identity_manager.managed_identity(&owner).is_none() { + info.identity_manager + .add_identity(bare_identity(owner.to_buffer()), 0, wallet_id, &p) + .expect("add owner"); + } + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], out_height, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], in_height, 0); + info.identity_manager + .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( + manager: &Arc>, + wallet_id: WalletId, + height: u32, + ) { + 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") + .core_wallet + .update_synced_height(height); + } + + async fn synced_height( + manager: &Arc>, + wallet_id: WalletId, + ) -> u32 { + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let wm = wallet.identity().wallet_manager.read().await; + wm.get_wallet_info(&wallet_id) + .expect("info") + .core_wallet + .synced_height() + } + + /// 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 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() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + + // 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; + + let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + None, + "request checkpoint above the tip -> no backfill" + ); + assert_eq!( + synced_height(&manager, wallet_id).await, + 450, + "height unchanged" + ); + + // 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!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None, + "forward-covered contact must not be redundantly rewound" + ); + assert_eq!( + synced_height(&manager, wallet_id).await, + 600, + "no redundant rewind" + ); + } + + /// Multiple contacts: the floor is the MINIMUM request checkpoint across all + /// 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 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 (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; + + let iw_wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + iw_wallet + .identity() .dashpay() .reconcile_dashpay_rescan() .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); @@ -2914,8 +3506,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 +3523,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() @@ -2939,9 +3539,90 @@ 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, 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 +3644,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 @@ -4951,35 +5645,42 @@ 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, + )); + // 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); } // 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). @@ -5002,6 +5703,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(), + 0, + "a watch-only external account must not invalidate in-flight filter scans" + ); + assert_eq!( + info.synced_height(), + 1_000, + "a watch-only external account must not rewind the filter scan" + ); use key_wallet::account::account_collection::DashpayAccountKey; let key = DashpayAccountKey { index: 0, @@ -5017,6 +5728,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 @@ -5030,8 +5893,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]); @@ -5476,7 +6337,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}; @@ -5635,7 +6495,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; @@ -5844,7 +6703,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 a5eb645450e..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 @@ -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 @@ -109,6 +110,11 @@ 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(()); } // Already tracked as a pending sent request. Same outgoing @@ -141,6 +147,7 @@ impl ManagedIdentity { self.dashpay .sent_contact_requests .insert(recipient_id, request); + self.dashpay.rescan_triggered.remove(&recipient_id); return Ok(()); } @@ -189,6 +196,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( @@ -789,6 +797,46 @@ impl ManagedIdentity { self.dashpay.high_water_sent_ms = 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`. + /// + /// 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(crate) 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) --- @@ -1583,6 +1631,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 +1650,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 new outgoing request must make the receiving account 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 +1674,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 +1687,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/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index fb981b0ebd4..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 @@ -79,6 +79,28 @@ 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. 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. @@ -126,19 +148,22 @@ 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 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 /// 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, @@ -198,4 +223,24 @@ 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)) + } + + /// 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 { + self.earliest_sent_core_heights.get(recipient).copied() + } } 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) 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);