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/changeset/dashpay_backfill.rs b/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs index fdaa8f2facb..db94d9a05df 100644 --- a/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs +++ b/packages/rs-platform-wallet/src/changeset/dashpay_backfill.rs @@ -36,8 +36,8 @@ use super::{PersistenceCapabilities, PlatformWalletPersistence}; /// was recorded — the earliest height whose block the scanner is guaranteed to /// test against this contact's addresses. It is stored per contact rather than /// as one wallet-wide floor because a contact's checkpoint can *drop* after it -/// was covered (the reciprocal request turns out to be older than ours, or a -/// rotation falls back to wallet birth): a wallet-wide floor would then read +/// was covered (an older outgoing publication becomes known, or a rotation +/// falls back to wallet birth): a wallet-wide floor would then read /// as "still covered" for blocks the scan never tested with that contact. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 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 aaff5afd70d..4d9e3e2eafb 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 @@ -911,6 +914,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. /// @@ -1011,8 +1063,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 { @@ -1342,11 +1394,10 @@ impl DashPayView<'_, B> { /// 3. Ingests own sent requests via `add_sent_contact_request`, which /// carries its own sent-side guard so a recurring re-ingest /// creates no phantom pending rows and preserves contact metadata. - /// 4. For **every** established contact missing a sending account - /// (not only newly-established ones — this also repairs - /// restore-from-seed and best-effort-accept gaps), rebuilds both - /// the `DashpayReceivingFunds` and `DashpayExternalAccount` - /// accounts, with the transient/permanent failure policy. + /// 4. Queue missing receiving accounts for sent-only and established + /// contacts, including broken outbound channels. Established contacts + /// also queue missing external accounts unless permanently broken, + /// repairing restore-from-seed and best-effort-accept gaps. /// /// **Lock ordering (critical).** The account-building registrations /// (`register_contact_account`, `register_external_contact_account`) @@ -1557,7 +1608,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`. @@ -1567,14 +1620,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 @@ -1669,10 +1716,8 @@ impl DashPayView<'_, B> { report.unpersisted_identities.push(identity_id); } - // (3) Collect account-building candidates: every established - // contact missing a sending (external) account, skipping - // contacts whose payment channel is already marked - // permanently broken (no unbounded retry). + // (3) Collect missing receiving accounts for sent-only + // requests and account pairs for established contacts. Self::collect_account_build_candidates(info, &identity_id) }; @@ -1873,11 +1918,9 @@ impl DashPayView<'_, B> { } } - /// Collect every established contact (for `identity_id`) that is - /// missing its `DashpayExternalAccount` and is NOT already marked - /// permanently broken — the account-building candidates for this - /// sweep. Runs under the caller's write guard; performs no - /// awaits and no lock re-acquisition. + /// Collect missing receiving accounts and recoverable external accounts. + /// Broken outbound channels remain eligible for receiving recovery. + /// Runs under the caller's write guard without awaiting or re-locking. pub(super) fn collect_account_build_candidates( info: &crate::wallet::platform_wallet::PlatformWalletInfo, identity_id: &Identifier, @@ -1889,43 +1932,66 @@ impl DashPayView<'_, B> { }; let mut out = Vec::new(); - for (contact_id, contact) in managed.dashpay().established_contacts() { - // Never retry a permanently-broken channel — wait for a - // superseding request (which clears the flag on re-establish). - if contact.payment_channel_broken { - continue; + for contact_id in managed.dashpay().sent_contact_requests().keys() { + let key = DashpayAccountKey { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + if !info + .core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key) + { + out.push(AccountBuildCandidate { + contact_id: *contact_id, + external: None, + }); } + } + for (contact_id, contact) in managed.dashpay().established_contacts() { let key = DashpayAccountKey { index: 0, user_identity_id: identity_id.to_buffer(), friend_identity_id: contact_id.to_buffer(), }; + let has_receiving = info + .core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key); let has_external = info .core_wallet .accounts .dashpay_external_accounts .contains_key(&key); - if has_external { - continue; + // A broken outbound channel must not prevent receiving recovery. + let external = if has_external || contact.payment_channel_broken { + None + } else { + let incoming = &contact.incoming_request; + Some(ExternalAccountBuild { + encrypted_public_key: incoming.encrypted_public_key.clone(), + our_decryption_key_index: incoming.recipient_key_index, + contact_encryption_key_index: incoming.sender_key_index, + }) + }; + if !has_receiving || external.is_some() { + out.push(AccountBuildCandidate { + contact_id: *contact_id, + external, + }); } - // The incoming request carries the counterparty's encrypted - // xpub + the key indices needed for ECDH. - let incoming = &contact.incoming_request; - out.push(AccountBuildCandidate { - contact_id: *contact_id, - encrypted_public_key: incoming.encrypted_public_key.clone(), - our_decryption_key_index: incoming.recipient_key_index, - contact_encryption_key_index: incoming.sender_key_index, - }); } out } - /// Queue the two DashPay account builds for one established contact. + /// Queue a receiving account and, for an established contact, an external account. /// /// The recurring sweep runs without a signer, so it cannot derive the /// receiving (friendship) xpub or the ECDH secret itself. It enqueues - /// both ops for the signer-backed drain + /// the required ops for the signer-backed drain /// ([`Self::drain_pending_contact_crypto_verified`]), which registers the /// `DashpayReceivingFunds` account, fetches the counterparty, validates /// the request's key indices before any ECDH, and registers the @@ -1972,7 +2038,7 @@ impl DashPayView<'_, B> { return; } - // Enqueue the deferred crypto ops (receiving xpub + external decrypt). + // Enqueue receiving derivation and any reciprocal external decryption. // Idempotent per (owner, contact, kind), so re-enqueuing every sweep is // a no-op until the drain clears them. self.enqueue_deferred_contact_crypto(identity_id, &candidate) @@ -2001,26 +2067,24 @@ impl DashPayView<'_, B> { }; let enqueued_at_ms = crate::util::now_ms(); - let entries = vec![ - // (1) Our receiving xpub (no payload — derived from the identity ids). - PendingContactCrypto { - owner_identity_id: *identity_id, - contact_id: candidate.contact_id, - op: PendingContactCryptoOp::RegisterReceiving, - enqueued_at_ms, - }, - // (2) The external account — ECDH decrypt of the contact's xpub. - PendingContactCrypto { + let mut entries = vec![PendingContactCrypto { + owner_identity_id: *identity_id, + contact_id: candidate.contact_id, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms, + }]; + if let Some(external) = &candidate.external { + entries.push(PendingContactCrypto { owner_identity_id: *identity_id, contact_id: candidate.contact_id, op: PendingContactCryptoOp::RegisterExternal { - encrypted_public_key: candidate.encrypted_public_key.clone(), - our_decryption_key_index: candidate.our_decryption_key_index, - contact_encryption_key_index: candidate.contact_encryption_key_index, + encrypted_public_key: external.encrypted_public_key.clone(), + our_decryption_key_index: external.our_decryption_key_index, + contact_encryption_key_index: external.contact_encryption_key_index, }, enqueued_at_ms, - }, - ]; + }); + } // Serialize the queue write with identity removal under the same guard. let mut wm = self.wallet_manager.write().await; @@ -3401,12 +3465,16 @@ impl DashPayView<'_, B> { } } -/// One established contact that needs its DashPay accounts (re)built -/// during a sync sweep. Collected under the write guard, consumed -/// after it is dropped. +/// Contact accounts to build after releasing the sync sweep's write guard. pub(super) struct AccountBuildCandidate { /// The counterparty identity. contact_id: Identifier, + /// A reciprocal request supplies the material for our external sending account. + external: Option, +} + +/// Public request material needed to build an external sending account. +struct ExternalAccountBuild { /// The counterparty's 96-byte encrypted xpub (from their incoming /// request to us) to decrypt + register as a `DashpayExternalAccount`. encrypted_public_key: Vec, @@ -4164,6 +4232,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}; @@ -4176,6 +4245,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; @@ -4804,18 +4874,16 @@ mod sweep_tests { assert_eq!(c.contact_id, Identifier::from([contact; 32])); // The candidate carries the counterparty's encrypted xpub + the // ECDH key indices taken from the INCOMING request. - assert_eq!(c.encrypted_public_key, vec![7u8; 96]); + let external = c.external.as_ref().expect("reciprocal request"); + assert_eq!(external.encrypted_public_key, vec![7u8; 96]); // incoming request: sender=contact key_index 1, recipient(us) key_index 2 - assert_eq!(c.contact_encryption_key_index, 1); - assert_eq!(c.our_decryption_key_index, 2); + assert_eq!(external.contact_encryption_key_index, 1); + assert_eq!(external.our_decryption_key_index, 2); } - /// **Test 4 (permanent failure → no retry):** once a contact's payment - /// channel is marked broken, the sweep must NOT re-list it as a - /// candidate — no unbounded retry until a superseding request clears - /// the flag. + /// A broken outbound channel still permits recovery of our receiving account. #[test] - fn broken_payment_channel_is_skipped_by_the_sweep() { + fn should_recover_missing_receiving_account_for_broken_payment_channel() { let our = 1u8; let contact = 2u8; let our_id = Identifier::from([our; 32]); @@ -4832,9 +4900,15 @@ mod sweep_tests { let candidates = DashPayView::::collect_account_build_candidates(&info, &our_id); + assert_eq!( + candidates.len(), + 1, + "receiving recovery must remain available" + ); + assert_eq!(candidates[0].contact_id, contact_id); assert!( - candidates.is_empty(), - "a permanently-broken contact must not be retried by the sweep" + candidates[0].external.is_none(), + "broken external channels must not be retried" ); } @@ -5064,6 +5138,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 @@ -5868,6 +6162,391 @@ mod drain_budget_tests { } } +#[cfg(test)] +mod receiving_restore_tests { + use super::*; + use crate::broadcaster::SpvBroadcaster; + use crate::changeset::{ + ClientStartState, PendingContactCryptoOp, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, + }; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWalletManager; + use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; + use dash_sdk::error::ContextProviderError; + use dash_sdk::platform::{ContextProvider, DocumentQuery}; + use dash_sdk::query_types::Documents; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::document::{Document, DocumentV0}; + use dpp::identity::v0::IdentityV0; + use dpp::version::PlatformVersion; + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::account::AccountType; + use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use key_wallet::managed_account::ManagedCoreFundsAccount; + 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 std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct RecordingPersistence(Mutex>); + + impl PlatformWalletPersistence for RecordingPersistence { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.lock().expect("recording lock").push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + enum RestoreState { + SentOnly, + Established { + has_external: bool, + payment_channel_broken: bool, + }, + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + struct DashpayContractProvider(Arc); + + impl ContextProvider for DashpayContractProvider { + fn get_data_contract( + &self, + id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok((*id == self.0.id()).then(|| Arc::clone(&self.0))) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Err(ContextProviderError::Config( + "no quorum keys in this test".into(), + )) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + } + + #[tokio::test] + async fn should_discover_sent_only_account_and_backfill_without_reciprocal_request() { + assert_receiving_recovery(RestoreState::SentOnly).await; + } + + #[tokio::test] + async fn should_recover_receiving_account_when_external_account_exists() { + assert_receiving_recovery(RestoreState::Established { + has_external: true, + payment_channel_broken: false, + }) + .await; + } + + #[tokio::test] + async fn should_recover_receiving_account_when_payment_channel_is_broken() { + assert_receiving_recovery(RestoreState::Established { + has_external: false, + payment_channel_broken: true, + }) + .await; + } + + #[tokio::test] + async fn should_recover_receiving_account_when_broken_channel_has_external_account() { + assert_receiving_recovery(RestoreState::Established { + has_external: true, + payment_channel_broken: true, + }) + .await; + } + + async fn assert_receiving_recovery(state: RestoreState) { + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let document_id = Identifier::from([0xCC; 32]); + let contract = dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + PlatformVersion::latest(), + ) + .expect("bundled DashPay contract"); + let contract = Arc::new(contract); + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_version(PlatformVersion::latest()) + .with_context_provider(DashpayContractProvider(Arc::clone(&contract))) + .build() + .expect("mock sdk"); + let document = Document::V0(DocumentV0 { + id: document_id, + owner_id: owner, + properties: BTreeMap::from([ + ("toUserId".into(), Value::Identifier(contact.to_buffer())), + ("senderKeyIndex".into(), Value::U32(0)), + ("recipientKeyIndex".into(), Value::U32(0)), + ("accountReference".into(), Value::U32(0)), + ("encryptedPublicKey".into(), Value::Bytes(vec![1; 96])), + ]), + created_at: Some(100), + created_at_core_block_height: Some(200), + ..Default::default() + }); + for field in ["toUserId", "$ownerId"] { + let mut query = + DocumentQuery::new(Arc::clone(&contract), "contactRequest").expect("contact query"); + query.where_clauses = vec![WhereClause { + field: field.into(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner.to_buffer()), + }]; + query.order_by_clauses = vec![OrderClause { + field: "$createdAt".into(), + ascending: true, + }]; + query.limit = 100; + let mut documents = Documents::new(); + if field == "$ownerId" { + documents.insert(document_id, Some(document.clone())); + } + sdk.mock() + .expect_fetch_many::(query, Some(documents)) + .await + .expect("contact query expectation"); + } + + let persister = Arc::new(RecordingPersistence::default()); + let manager = PlatformWalletManager::new( + Arc::new(sdk), + Arc::clone(&persister), + Arc::new(NoopEventHandler), + ); + let seed = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ) + .expect("test mnemonic") + .to_seed(""); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet"); + let wallet_id = wallet.wallet_id(); + let iw = wallet.identity(); + let has_external = matches!( + state, + RestoreState::Established { + has_external: true, + .. + } + ); + let channel_broken = matches!( + state, + RestoreState::Established { + payment_channel_broken: true, + .. + } + ); + { + let mut wm = iw.wallet_manager.write().await; + let (core_wallet, info) = wm + .get_wallet_mut_and_info_mut(&wallet_id) + .expect("wallet and info"); + info.identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &iw.persister, + ) + .expect("owner"); + if let RestoreState::Established { .. } = state { + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("owner"); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![1; 96], 200, 100); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![2; 96], 300, 100); + let mut established = EstablishedContact::new(contact, outgoing, incoming); + established.payment_channel_broken = channel_broken; + established.external_account_reference = has_external.then_some(0); + managed.apply_established_contact(established); + } + if has_external { + let account_type = AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + }; + let account_xpub = ExtendedPubKey::from_priv( + &ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master key"), + ); + core_wallet + .add_account(account_type, Some(account_xpub)) + .expect("external account"); + let account = key_wallet::Account { + parent_wallet_id: Some(wallet_id), + account_type, + network: Network::Testnet, + account_xpub, + is_watch_only: true, + }; + info.core_wallet + .accounts + .insert_funds_bearing_account(ManagedCoreFundsAccount::from_account(&account)) + .expect("managed external account"); + } + info.core_wallet.update_synced_height(1000); + } + let report = iw + .dashpay() + .sync_contact_requests_reporting() + .await + .expect("sync"); + assert!( + report.is_complete(), + "mock must answer both query directions: {report:?}" + ); + { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("owner"); + assert_eq!( + managed.dashpay().established_contacts().len(), + usize::from(!matches!(state, RestoreState::SentOnly)) + ); + assert_eq!( + managed.dashpay().sent_contact_requests().len(), + usize::from(matches!(state, RestoreState::SentOnly)) + ); + let queue = &managed.dashpay().pending_contact_crypto; + assert_eq!(queue.len(), 1, "recovery must queue receiving only"); + assert!(matches!( + queue[0].op, + PendingContactCryptoOp::RegisterReceiving + )); + assert!(info + .core_wallet + .accounts + .dashpay_receival_accounts + .is_empty()); + } + { + let writes = persister.0.lock().expect("recording lock"); + let queued: Vec<_> = writes + .iter() + .flat_map(|cs| &cs.pending_contact_crypto_added) + .collect(); + assert_eq!( + queued.len(), + 1, + "the receiving operation must reach persistence" + ); + assert!(matches!( + queued[0].op, + PendingContactCryptoOp::RegisterReceiving + )); + } + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + assert_eq!( + iw.dashpay() + .drain_pending_contact_crypto_verified(&provider, None) + .await + .expect("verified drain"), + 1, + "the signer-backed drain must build the discovered receiving account" + ); + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + Some(200), + "backfill must start at the outgoing request's publication height" + ); + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!(info.core_wallet.synced_height(), 200); + assert!(info + .core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + })); + assert_eq!( + info.core_wallet.accounts.dashpay_external_accounts.len(), + usize::from(has_external) + ); + let managed = info + .identity_manager + .managed_identity(&owner) + .expect("owner"); + assert!(managed.dashpay().pending_contact_crypto.is_empty()); + if let Some(contact) = managed.dashpay().established_contacts().get(&contact) { + assert_eq!(contact.payment_channel_broken, channel_broken); + } + let writes = persister.0.lock().expect("recording lock"); + assert_eq!( + writes + .iter() + .flat_map(|cs| &cs.pending_contact_crypto_cleared) + .count(), + 1, + "the completed operation must be durably dequeued" + ); + assert!( + DashPayView::::collect_account_build_candidates(info, &owner) + .is_empty(), + "a later sweep must not rebuild the receiving account" + ); + } +} + #[cfg(test)] mod pending_enqueue_tests { use super::*; @@ -5878,9 +6557,11 @@ mod pending_enqueue_tests { let (iw, owner, backend) = super::super::pending_crypto_tests::fixture().await; let candidate = AccountBuildCandidate { contact_id: Identifier::from([0xBB; 32]), - encrypted_public_key: vec![0; 96], - our_decryption_key_index: 0, - contact_encryption_key_index: 1, + external: Some(ExternalAccountBuild { + encrypted_public_key: vec![0; 96], + our_decryption_key_index: 0, + contact_encryption_key_index: 1, + }), }; iw.dashpay() .enqueue_deferred_contact_crypto(&owner, &candidate) 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 46e6eb487c0..3e7012faf49 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -13,6 +13,51 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::types::dashpay::payment::DashpayAddressMatch; use crate::wallet::platform_wallet::PlatformWalletInfo; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_encryption::account_reference_version; + +/// 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. +/// +/// Only Platform-assigned heights can establish coverage. Reconciliation +/// lowers the cursor only when this checkpoint precedes it, preserving any +/// deeper scan already in progress. +/// +/// 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) +} /// Build the persistence round for a newly registered DashPay account /// (`DashpayReceivingFunds` / `DashpayExternalAccount`): the @@ -151,7 +196,7 @@ 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). + /// A sent request already exposes this account, even before reciprocation. /// /// No-op if the account already exists for this contact relationship. pub async fn register_contact_account( 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 e8bfa8ec253..f6a4f3919b1 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,18 +77,19 @@ 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. + /// This lowers the wallet's SPV `synced_height` to the earliest + /// Platform-assigned height of our outgoing requests across uncovered + /// receiving accounts, including sent-only relationships. The filter + /// manager reuses downloaded filters, re-matches the expanded script set, + /// and re-requests matching blocks. /// /// # The guard, in memory and on disk /// @@ -116,6 +119,11 @@ impl DashPayView<'_, B> { /// not record it), is re-armed as before — the record only ever suppresses /// a rewind it can vouch for. /// + /// Accounts wait for the first successful sent sweep unless their earliest + /// height is known or the scan is still at its initial floor. At that floor + /// the coming scan covers every possible publication. Registration + /// invalidates in-flight batches; this pass records coverage and the cursor. + /// /// Nothing is marked "complete" at trigger time: the record's extent /// (`floor`, `rewound_from`) is descriptive, and completion is read off the /// cursor ([`DashPayBackfillRecord::is_complete`]). An interrupted @@ -124,10 +132,9 @@ impl DashPayView<'_, B> { /// /// `synced_height` may regress here: that is safe because it is the /// filter-scan checkpoint, decoupled from the monotonic - /// `last_processed_height`. The pinned engine refuses to certify a batch - /// scanned at the old checkpoint once the cursor sits below it, so an - /// in-flight batch cannot lift the rewound cursor back over the range it - /// never tested with the new addresses. Only the **receival** account + /// `last_processed_height`. The engine's contiguity check rejects batches + /// separated from the lowered cursor by a gap; rewinds within an in-flight + /// batch still have the limitation documented below. Only the **receival** account /// matters — `DashpayExternalAccount` is outbound and never receives. /// Returns the floor the height was lowered to, or `None`. /// @@ -192,21 +199,10 @@ impl DashPayView<'_, B> { }) .collect(); - // Candidates: established receival contacts the durable record does - // not cover at their current checkpoint. 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. - // - // A process-local `rescan_triggered` mark is never taken as coverage - // on its own: the mark may belong to a pass whose record write failed, - // or the contact's checkpoint may since have dropped below the height - // it was recorded at (an older reciprocal request learned later). - // Either way the range the scan never tested with this contact has to - // be rewound to, so an uncovered contact goes through the candidate - // path whether or not it is marked. + // One rewind covers every uncovered receiving account. Only our + // outgoing requests bound its history; an incoming request cannot + // force a rescan. Durable coverage, not the process-local mark, decides + // whether the account has already been handled. let mut floor: Option = None; // Receival accounts this pass handles, with the checkpoint each is // covered from. @@ -218,13 +214,20 @@ impl DashPayView<'_, B> { let Some(managed) = info.identity_manager.managed_identity(&owner) else { continue; }; - let Some(established) = managed.dashpay().established_contacts().get(&contact) else { - continue; + let dashpay = managed.dashpay(); + let checkpoint = if !dashpay.sent_sweep_completed() + && dashpay.earliest_sent_core_height(&contact).is_none() + { + // At the initial scan floor every possible publication is + // covered by the coming scan, even before the sent fetch. + if synced_height <= info.core_wallet.birth_height().saturating_sub(1) { + synced_height + } else { + continue; + } + } else { + receiving_scan_checkpoint(info, &owner, &contact) }; - let checkpoint = established - .outgoing_request - .core_height_created_at - .min(established.incoming_request.core_height_created_at); // The previous round already rewound for this contact from a // checkpoint no higher than today's, and the persisted cursor // tracked that scan with its addresses watched, so nothing below @@ -233,7 +236,7 @@ impl DashPayView<'_, B> { .dashpay_backfill .covers(&owner, &contact, account_index, checkpoint) { - if !managed.dashpay().rescan_triggered.contains(&contact) + if !dashpay.rescan_triggered.contains(&contact) && !restored.contains(&(owner, contact)) { restored.push((owner, contact)); @@ -296,10 +299,14 @@ impl DashPayView<'_, B> { } } - // 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. + // + // A rewind within an in-flight batch does not change its account + // generation, so the engine can still certify the skipped prefix. + // Tracked in https://github.com/dashpay/platform/issues/4955. if let Some(floor) = floor { info.core_wallet.update_synced_height(floor); // Advances the engine emitted before this rewind must not be @@ -1538,7 +1545,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; @@ -2018,15 +2024,18 @@ mod tests { use key_wallet::wallet::initialization::WalletAccountCreationOptions; 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"; @@ -2752,6 +2761,58 @@ mod tests { ) } + #[tokio::test] + async fn should_backfill_sent_only_contact_after_historical_scan() { + use crate::wallet::identity::ContactRequest; + 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]); + 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([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + info.identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .add_sent_contact_request( + ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 200, 0), + &p, + ) + .expect("sent request"); + info.core_wallet.update_synced_height(1000); + } + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receiving account after historical scan"); + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile"); + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert!(info + .identity_manager + .managed_identity(&owner) + .expect("owner") + .dashpay() + .established_contacts() + .is_empty()); + assert!( + info.core_wallet.synced_height() <= 200, + "must scan historical payments after our sent request at 200; cursor stayed at {}", + info.core_wallet.synced_height() + ); + } + /// 1. Registering a contact receival account must persist an /// `AccountRegistrationEntry` — otherwise the account (and every /// UTXO routed to it) silently vanishes on the next app launch @@ -2762,6 +2823,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(); { @@ -2779,6 +2869,36 @@ mod tests { .expect("register_contact_account"); } + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + manager + .get_wallet(&wallet_id) + .await + .expect("wallet") + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + { + 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)| { @@ -2803,6 +2923,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 @@ -2817,15 +2938,117 @@ 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_request_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); + } + + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + iw.dashpay() + .register_contact_account(&owner, &pending, 0, test_receiving_xpub(&owner, &pending)) + .await + .expect("register during deeper rescan"); + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + 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"); + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + 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"); + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + 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. @@ -3149,17 +3372,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]); @@ -3181,13 +3402,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); } @@ -3198,7 +3425,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; @@ -3233,9 +3460,408 @@ mod tests { } } + /// 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 host without a saved coverage record needs + /// one backfill when its account and scan cursor are restored. + #[tokio::test] + 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"); + + // 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.dashpay() + .reconcile_dashpay_rescan() + .await + .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() + .await + .expect("sent-only retry"), + None, + "the same pending relationship must not restart its backfill" + ); + + // 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.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); + } + + /// After a sent-only account has been reconciled, later sweeps must not + /// restart its backfill as the scan advances. + #[tokio::test] + async fn should_not_rewind_again_after_fresh_send_is_reconciled() { + 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"); + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + assert_eq!(synced_height(&manager, wallet_id).await, 100); + + // The completed sweep makes this a coverage check, not a deferral. + set_synced_height(&manager, wallet_id, 500).await; + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("reconcile after send"), + None, + "the earlier reconcile 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 invalidating coverage that reconciliation already recorded. + #[tokio::test] + async fn should_not_rewind_again_when_established_contact_is_replayed_after_reconcile() { + 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"); + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + 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"); + + // The completed sweep makes this a coverage check, not a deferral. + 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 coverage already recorded" + ); + 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"); + mark_sent_sweep_completed(&manager, wallet_id, owner).await; + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"); + 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 funded at `out_height`/`in_height`. The owner managed - /// identity is added on first use. + /// 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, @@ -3245,7 +3871,40 @@ mod tests { out_height: u32, in_height: u32, ) { - use crate::wallet::identity::{ContactRequest, EstablishedContact}; + 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 _); @@ -3330,7 +3989,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) @@ -3339,11 +3997,11 @@ 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 - /// 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() { @@ -3351,8 +4009,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; @@ -3365,7 +4023,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, @@ -3373,7 +4031,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!( @@ -3393,19 +4051,19 @@ mod tests { ); } - /// 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). + /// 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 extends the covered range only when its + /// outgoing request predates the current floor; subsequent sweeps settle. #[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; @@ -3419,7 +4077,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); @@ -3435,29 +4093,116 @@ mod tests { "all candidates marked -> no re-trigger" ); - // A newly discovered, older-funded contact re-lowers exactly once... + // A later-discovered account needs only the additional range below + // the current scan checkpoint. Registration leaves the cursor alone. establish_receival_contact(&manager, &persister, wallet_id, owner, c_c, 50, 50).await; + assert_eq!( + synced_height(&manager, wallet_id).await, + 100, + "registration leaves the cursor for the durable reconcile" + ); + assert_eq!( + iw_wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 3"), + Some(50), + "reconcile extends coverage to the newly discovered request" + ); + // The extended coverage settles after this one additional rewind. assert_eq!( iw_wallet .identity() .dashpay() .reconcile_dashpay_rescan() .await - .expect("rescan 3"), - Some(50), - "a new older contact re-lowers to its funding height" + .expect("rescan 4"), + None, + "drip-feed settles -> no further rewind" + ); + } + + /// 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" ); - // ...then settles. + 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_wallet - .identity() - .dashpay() + iw.dashpay() .reconcile_dashpay_rescan() .await - .expect("rescan 4"), - None, - "drip-feed settles -> no further rewind" + .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 @@ -3633,6 +4378,128 @@ mod tests { .find(|cs| cs.dashpay_backfill.is_some()) } + #[tokio::test] + async fn should_cover_unswept_contact_from_initial_scan_floor() { + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + establish_receival_contact_unswept( + &manager, &persister, wallet_id, owner, contact, 500, 500, + ) + .await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("initial coverage"), + None + ); + { + let wm = manager.wallet_manager.read().await; + let info = wm.get_wallet_info(&wallet_id).expect("info"); + assert_eq!( + info.dashpay_backfill.covered_from(&owner, &contact, 0), + Some(0) + ); + } + set_synced_height(&manager, wallet_id, 1_000).await; + { + let mut wm = manager.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("owner"); + managed.note_sent_request_core_height(contact, 100); + managed.mark_sent_sweep_completed(); + } + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("older publication"), + None + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_000); + } + + #[tokio::test] + async fn should_resume_sent_only_backfill_after_restart() { + 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; + mark_sent_sweep_completed(&manager, wallet_id, owner).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("register"); + set_synced_height(&manager, wallet_id, 1_000).await; + assert_eq!( + wallet + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("backfill"), + Some(100) + ); + { + let stores = persister.stores.lock().unwrap(); + let round = last_stored_record(&stores).expect("coverage round"); + assert_eq!( + round.core.as_ref().and_then(|core| core.synced_height), + Some(100) + ); + assert_eq!( + round + .dashpay_backfill + .as_ref() + .unwrap() + .covered_from(&owner, &contact, 0), + Some(100) + ); + } + let snapshot = reload_snapshot(&manager, wallet_id, owner, 400, None).await; + let (restarted, _, _) = reload(snapshot).await; + let restored = restarted.get_wallet(&wallet_id).await.expect("restored"); + assert_eq!( + restored + .identity() + .dashpay() + .reconcile_dashpay_rescan() + .await + .expect("resume"), + None + ); + assert_eq!(synced_height_of(&restarted, wallet_id).await, 400); + } + /// dashpay/platform#4302. The cursor a rewind lowers is durable — the /// host persists every height the rescan climbs through — so a relaunch /// restores it *inside* the climb. With only the in-memory guard every @@ -3776,8 +4643,8 @@ mod tests { ); } - /// A contact's checkpoint can drop after it was covered: the reciprocal - /// request turns out to be older than ours. The record covers it only + /// A contact's checkpoint can drop after it was covered: an older outgoing + /// publication becomes known. The record covers it only /// from the height it was recorded at, so the reload must still rewind /// to the new, lower checkpoint — and the record's floor follows it /// while its climb target stays. @@ -3803,11 +4670,11 @@ mod tests { ); // Before the relaunch the persisted contact state learned an older - // incoming request (50), while the record still says "covered from + // outgoing request (50), while the record still says "covered from // 100". let mut snapshot = reload_snapshot(&manager, wallet_id, owner, 400, None).await; - 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); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 50, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0); snapshot .identities .get_mut(&0) @@ -4123,6 +4990,7 @@ mod tests { )); } } + mark_sent_sweep_completed(&manager, wallet_id, owner).await; // First session: the scan is at the tip when the contacts arrive. // Registration never moves the cursor here; the reconcile pass then // rewinds once to the earliest request height and records every @@ -4306,6 +5174,7 @@ mod tests { )); } } + mark_sent_sweep_completed(&manager, wallet_id, owner).await; // The scan is already at the tip when the contacts are discovered. set_synced_height(&manager, wallet_id, 1_560_731).await; persister.stores.lock().unwrap().clear(); @@ -4619,6 +5488,10 @@ 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") + .mark_sent_sweep_completed(); } /// A fresh manager recreating a wallet the host already holds (the same @@ -6929,7 +7802,7 @@ mod tests { } /// A process-local mark is not coverage. A contact covered from 100 - /// whose checkpoint then drops to 50 (an older reciprocal request) must + /// whose checkpoint then drops to 50 (an older outgoing request) must /// be rewound to 50 — not have the lower checkpoint recorded as covered /// while the range 50–100 was never scanned for it /// (dashpay/platform#4302 review). @@ -6952,13 +7825,13 @@ mod tests { .expect("first pass"), Some(100) ); - // The scan climbs; then the reciprocal request turns out to be older. + // The scan climbs; then an older outgoing publication becomes known. set_synced_height(&manager, wallet_id, 400).await; { let mut wm = manager.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); - 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); + let outgoing = ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 50, 0); + let incoming = ContactRequest::new(contact, owner, 0, 0, 0, vec![0u8; 96], 100, 0); let managed = info .identity_manager .managed_identity_mut(&owner) @@ -9587,35 +10460,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). @@ -9638,6 +10518,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, @@ -9653,6 +10543,129 @@ mod tests { ); } + /// 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, + encrypted_contact_xpub(&owner_id, &contact_id, &[0x66u8; 32]), + 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 @@ -9666,8 +10679,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]); @@ -10112,7 +11123,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}; @@ -10270,7 +11280,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; @@ -10478,7 +11487,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 ab22da79cd7..dbade254b1b 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,13 +148,9 @@ 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 handled by this process's historical-payment reconciliation. + /// Only outgoing request changes clear the mark. Durable coverage decides + /// whether to rewind; a process-local mark alone never certifies a scan. /// /// This set is the in-memory half of the guard and is never persisted: a /// relaunch clears it. Its durable half is the wallet's @@ -205,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)