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 2e2f2ef9992..0efef0663e1 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 @@ -1670,6 +1670,11 @@ impl DashPayView<'_, B> { self.build_contact_accounts(&identity_id, candidate).await; } + // (3b) Our receiving account for every contact holding a request + // we sent, reciprocated or not: our xpub is in that request, + // so they can already pay us on it. Gated on our side only. + self.enqueue_receiving_account_builds(&identity_id).await; + // (4) Enqueue DIP-15 auto-accept for inbound requests carrying a // proof. Signerless: verify + accept happen later in // `drain_auto_accepts` at a signer-present moment. @@ -1910,6 +1915,143 @@ impl DashPayView<'_, B> { out } + /// Collect every contact (for `identity_id`) that holds a contact request + /// WE sent — reciprocated or not — and has no `DashpayReceivingFunds` + /// account yet: the receiving-account build candidates for this sweep. + /// Runs under the caller's guard; performs no awaits and no lock + /// re-acquisition. + /// + /// Our receiving account depends on our own request alone. DIP-15 puts + /// our receiving xpub in the request we send, so its recipient can pay + /// us on that chain whether or not they ever reciprocate, and building + /// the account needs only our identity, theirs and the signer. + /// [`Self::collect_account_build_candidates`] gates on the counterparty's + /// side instead — established only, external account missing, channel + /// not broken — so three kinds of contact never got a receival account + /// and their payments to us were never seen, at any rescan depth: + /// + /// * a one-way contact, whose request we sent and who never replied + /// (the live send path registers the account itself; the sweep learns + /// of the request from Platform on a restore from seed or a second + /// device, or after a live registration failed); + /// * an established contact whose channel is marked broken, where the + /// failure was in decrypting THEIR xpub, which our receiving side + /// never touches; + /// * an established contact whose external account was built but whose + /// receiving build failed once — the external row survives a relaunch + /// and the regular gate then skips the contact for good. + /// + /// This queues only the receiving side. The external account still needs + /// the contact's xpub from a request they send us, and keeps its own + /// gate. Overlap with the regular candidates is harmless: enqueueing is + /// idempotent per `(owner, contact, kind)`. The broken-channel flag is + /// ignored on purpose; `RegisterReceiving` makes no fetch and no + /// decrypt, so it cannot retry without bound. Identities without an HD + /// slot are skipped — there is no seed path to derive our xpub from. + fn collect_receiving_account_candidates( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + identity_id: &Identifier, + ) -> Vec { + use key_wallet::account::account_collection::DashpayAccountKey; + + let Some(managed) = info.identity_manager.managed_identity(identity_id) else { + return Vec::new(); + }; + if managed.identity_index.is_none() { + return Vec::new(); + } + + let dashpay = managed.dashpay(); + // Both maps are keyed by contact and ordered, and a contact sits in + // at most one of them outside a transient failed auto-establish, so + // the set keeps the result deterministic and free of duplicates. + let with_our_request: std::collections::BTreeSet<&Identifier> = dashpay + .sent_contact_requests() + .keys() + .chain(dashpay.established_contacts().keys()) + .collect(); + + with_our_request + .into_iter() + .filter(|contact_id| { + let key = DashpayAccountKey { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + !info + .core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&key) + }) + .copied() + .collect() + } + + /// Queue `RegisterReceiving` for every receiving-account candidate of + /// `identity_id` (see [`Self::collect_receiving_account_candidates`]) + /// for the signer-backed drain. Collection and enqueue share one write + /// guard, so identity removal cannot interleave between them. + /// + /// Idempotent per `(owner, contact, kind)`, like + /// [`Self::enqueue_deferred_contact_crypto`]. The queue is not restored + /// on load, so this re-discovery every sweep is what carries a pending + /// build across a relaunch. + async fn enqueue_receiving_account_builds(&self, identity_id: &Identifier) { + use crate::changeset::{ + upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, + PlatformWalletChangeSet, + }; + + let mut wm = self.wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + return; + }; + let contacts = Self::collect_receiving_account_candidates(info, identity_id); + if contacts.is_empty() { + return; + } + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + return; + }; + + let enqueued_at_ms = crate::util::now_ms(); + let entries: Vec = contacts + .iter() + .map(|contact_id| PendingContactCrypto { + owner_identity_id: *identity_id, + contact_id: *contact_id, + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms, + }) + .collect(); + for entry in &entries { + upsert_pending_contact_crypto( + managed.dashpay_pending_contact_crypto_mut(), + entry.clone(), + ); + } + + // Best-effort, as in `enqueue_deferred_contact_crypto`: the next + // sweep re-discovers these if the store fails. + let changeset = PlatformWalletChangeSet { + pending_contact_crypto_added: entries, + ..Default::default() + }; + if let Err(e) = self.persister.store(changeset) { + tracing::warn!( + identity = %identity_id, contacts = contacts.len(), error = %e, + "failed to persist receiving-account contact-crypto enqueue; will re-enqueue next sweep" + ); + } + tracing::info!( + identity = %identity_id, + contacts = contacts.len(), + "Deferred DashPay receiving-account builds: enqueued for the signer-backed drain" + ); + } + /// Queue the two DashPay account builds for one established contact. /// /// The recurring sweep runs without a signer, so it cannot derive the @@ -5278,6 +5420,435 @@ mod sweep_tests { } } +// --------------------------------------------------------------------------- +// One-way contacts: we sent a request, the contact never sent one back. +// +// Our receiving xpub travels in the request we send, so the contact can pay us +// on that chain without reciprocating. These pin that such a contact gets a +// receiving account from the sweep, which watches the chain. +// --------------------------------------------------------------------------- +#[cfg(test)] +mod one_way_contact_tests { + use super::*; + use crate::broadcaster::SpvBroadcaster; + use crate::changeset::{PendingContactCryptoKind, PendingContactCryptoOp}; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; + use crate::wallet::platform_wallet::PlatformWalletInfo; + use dash_sdk::drive::query::{OrderClause, SelectProjection, WhereClause, WhereOperator}; + use dash_sdk::error::ContextProviderError; + use dash_sdk::platform::{ContextProvider, DocumentQuery}; + use dpp::data_contracts::SystemDataContract; + use dpp::identity::v0::IdentityV0; + use dpp::version::PlatformVersion; + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + use std::sync::Arc; + + const SEED: [u8; 64] = [42; 64]; + + struct NoopEvents; + impl EventHandler for NoopEvents {} + impl PlatformEventHandler for NoopEvents {} + + fn id(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn request(sender: Identifier, recipient: Identifier, core_height: u32) -> ContactRequest { + ContactRequest::new(sender, recipient, 1, 2, 0, vec![7u8; 96], core_height, 0) + } + + fn receival_key(owner: &Identifier, contact: &Identifier) -> DashpayAccountKey { + DashpayAccountKey { + index: 0, + user_identity_id: owner.to_buffer(), + friend_identity_id: contact.to_buffer(), + } + } + + fn has_receival_account( + info: &PlatformWalletInfo, + owner: &Identifier, + contact: &Identifier, + ) -> bool { + info.core_wallet + .accounts + .dashpay_receival_accounts + .contains_key(&receival_key(owner, contact)) + } + + /// One page of the contact-request sweep's document query, built as + /// `Sdk::fetch_contact_requests_paginated` builds it for a first sweep + /// (no high-water cursor yet). A mock expectation is keyed by the encoded + /// request, so this must match it exactly; the sweep test asserts the + /// received fetch was answered, so a drifted copy fails loudly. + fn contact_request_query( + contract: Arc, + filter_field: &str, + identity_id: Identifier, + ) -> DocumentQuery { + DocumentQuery { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: "contactRequest".to_string(), + where_clauses: vec![WhereClause { + field: filter_field.to_string(), + operator: WhereOperator::Equal, + value: dpp::platform_value::platform_value!(identity_id), + }], + time_range_clauses: vec![], + integer_range_clauses: vec![], + sub_queries: vec![], + group_by: vec![], + having: vec![], + order_by_clauses: vec![OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }], + limit: 100, + offset: None, + start: None, + } + } + + /// Serves the bundled DashPay contract. The sweep resolves it through the + /// SDK's context provider first, and the mock's default provider errors + /// without a dump directory. + struct DashPayContractProvider(Arc); + + impl ContextProvider for DashPayContractProvider { + fn get_data_contract( + &self, + id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok((*id == SystemDataContract::Dashpay.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".to_string(), + )) + } + + fn get_platform_activation_height( + &self, + ) -> Result { + Ok(0) + } + } + + /// A mock SDK on which Platform holds no new contact request in either + /// direction for `owner`: the sweep then works only from local state. + async fn sdk_with_no_new_contact_requests(owner: Identifier) -> dash_sdk::Sdk { + // Pinned: expectations are keyed by the encoded request, and an + // unpinned mock re-encodes later queries after the first response. + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("mock sdk"); + let contract = Arc::new( + dpp::system_data_contracts::load_system_data_contract( + SystemDataContract::Dashpay, + PlatformVersion::latest(), + ) + .expect("bundled DashPay contract"), + ); + sdk.set_context_provider(DashPayContractProvider(Arc::clone(&contract))); + for filter_field in ["toUserId", "$ownerId"] { + sdk.mock() + .expect_fetch_many::( + contact_request_query(Arc::clone(&contract), filter_field, owner), + Some(Default::default()), + ) + .await + .expect("contact-request expectation"); + } + sdk + } + + /// A wallet-owned identity `owner` (HD slot 0) on a wallet built from + /// [`SEED`], served by `sdk`. + async fn wallet_with_owner( + sdk: dash_sdk::Sdk, + owner: Identifier, + ) -> ( + crate::PlatformWalletManager, + IdentityWallet, + ) { + let persistence = Arc::new(NoPlatformPersistence); + let manager = crate::PlatformWalletManager::new( + Arc::new(sdk), + Arc::clone(&persistence), + Arc::new(NoopEvents), + ); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &SEED, + WalletAccountCreationOptions::None, + Some(0), + ) + .await + .expect("wallet"); + let iw = wallet.identity().clone(); + { + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&iw.wallet_id) + .expect("info") + .identity_manager + .add_identity( + Identity::V0(IdentityV0 { + id: owner, + public_keys: Default::default(), + balance: 0, + revision: 0, + }), + 0, + iw.wallet_id, + &WalletPersister::new(iw.wallet_id, persistence), + ) + .expect("add owner"); + } + (manager, iw) + } + + /// Seed local contact state on `owner` with the state-machine entry + /// points the sweep's own ingest uses. + async fn seed_requests( + iw: &IdentityWallet, + owner: &Identifier, + sent: &[ContactRequest], + received: &[ContactRequest], + ) { + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&iw.wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(owner) + .expect("managed"); + for r in received { + managed + .add_incoming_contact_request(r.clone(), &iw.persister) + .expect("setup persists"); + } + for r in sent { + managed + .add_sent_contact_request(r.clone(), &iw.persister) + .expect("setup persists"); + } + } + + async fn queued_kinds( + iw: &IdentityWallet, + owner: &Identifier, + contact: &Identifier, + ) -> Vec { + let wm = iw.wallet_manager.read().await; + wm.get_wallet_info(&iw.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(owner)) + .map(|m| { + m.dashpay() + .pending_contact_crypto + .iter() + .filter(|e| e.contact_id == *contact) + .map(|e| e.op.kind()) + .collect() + }) + .unwrap_or_default() + } + + /// The candidate set is gated on OUR request alone: a contact we sent a + /// request to is a receiving-account candidate whether they replied + /// (established) or not (one-way out). A contact that only sent us one + /// is not: they never received our xpub, so they cannot pay us on a + /// chain of ours. The regular candidates still need the established + /// pair; the overlap on the established contact is intended. + #[tokio::test] + async fn should_collect_every_contact_holding_our_request_as_receiving_candidate() { + let owner = id(0xAA); + let one_way_out = id(0xB1); + let one_way_in = id(0xB2); + let established = id(0xB3); + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let (_manager, iw) = wallet_with_owner(sdk, owner).await; + seed_requests( + &iw, + &owner, + &[ + request(owner, one_way_out, 100), + request(owner, established, 100), + ], + &[ + request(one_way_in, owner, 100), + request(established, owner, 100), + ], + ) + .await; + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&iw.wallet_id).expect("info"); + assert_eq!( + DashPayView::::collect_receiving_account_candidates(info, &owner), + vec![one_way_out, established] + ); + let regular: Vec = + DashPayView::::collect_account_build_candidates(info, &owner) + .into_iter() + .map(|c| c.contact_id) + .collect(); + assert_eq!(regular, vec![established]); + } + + /// A broken payment channel records a failure to decrypt THEIR xpub, + /// which our receiving side never touches. The regular candidates skip a + /// broken contact for good (no unbounded retry of the decrypt), so + /// without this gate such a contact never got a receival account either, + /// and payments they sent us were never seen. `RegisterReceiving` makes + /// no fetch and no decrypt, so queuing it here cannot retry without + /// bound. + #[tokio::test] + async fn should_still_build_receiving_account_when_channel_is_broken() { + let owner = id(0xAA); + let contact = id(0xBB); + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let (_manager, iw) = wallet_with_owner(sdk, owner).await; + seed_requests( + &iw, + &owner, + &[request(owner, contact, 100)], + &[request(contact, owner, 100)], + ) + .await; + { + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&iw.wallet_id) + .expect("info") + .identity_manager + .managed_identity_mut(&owner) + .expect("managed") + .established_contact_mut(&contact) + .expect("established") + .payment_channel_broken = true; + } + + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&iw.wallet_id).expect("info"); + assert!( + DashPayView::::collect_account_build_candidates(info, &owner) + .is_empty(), + "the regular gate leaves a broken channel alone" + ); + assert_eq!( + DashPayView::::collect_receiving_account_candidates(info, &owner), + vec![contact], + "our receiving account does not depend on their xpub" + ); + } + + /// **The one-way receival gap, through the sweep's public entry point.** + /// A wallet that learned of its sent request from Platform (restore from + /// seed, a second device) holds it in `sent_contact_requests` with no + /// receiving account. Before the fix the sweep walked established + /// contacts only, so it queued nothing, the receiving chain was never + /// watched, and the contact's payments to us were never seen. The sweep + /// must queue `RegisterReceiving` (only — there is no xpub of theirs to + /// decrypt), and the drain must then register the account. + #[tokio::test] + async fn should_build_receiving_account_for_one_way_contact_on_sweep() { + let owner = id(0xAA); + let contact = id(0xBB); + let sdk = sdk_with_no_new_contact_requests(owner).await; + let (_manager, iw) = wallet_with_owner(sdk, owner).await; + seed_requests(&iw, &owner, &[request(owner, contact, 1_475_801)], &[]).await; + + let report = iw + .dashpay() + .sync_contact_requests_reporting() + .await + .expect("sweep"); + // Both mocked fetches must be answered, or the sweep never reached + // the build step and the assertions below would test nothing. + assert_eq!( + report.failed_identities, + Vec::::new(), + "received fetch" + ); + assert_eq!( + report.degraded_identities, + Vec::::new(), + "sent fetch" + ); + assert_eq!( + queued_kinds(&iw, &owner, &contact).await, + vec![PendingContactCryptoKind::RegisterReceiving], + "a one-way contact needs our receiving account, and only that" + ); + + let provider = SeedCryptoProvider::from_seed(SEED, Network::Testnet); + let drained = iw + .dashpay() + .drain_pending_contact_crypto_until(&provider, None) + .await; + assert_eq!(drained, 1); + { + let wm = iw.wallet_manager.read().await; + let info = wm.get_wallet_info(&iw.wallet_id).expect("info"); + assert!(has_receival_account(info, &owner, &contact)); + assert!( + DashPayView::::collect_receiving_account_candidates(info, &owner) + .is_empty(), + "a built account is no longer a candidate" + ); + } + assert!(queued_kinds(&iw, &owner, &contact).await.is_empty()); + } + + /// Queued ops carry no payload a one-way contact could supply, so the + /// entry must be exactly the payload-free receiving op. + #[tokio::test] + async fn should_queue_a_payload_free_receiving_op_for_one_way_contact() { + let owner = id(0xAA); + let contact = id(0xBB); + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let (_manager, iw) = wallet_with_owner(sdk, owner).await; + seed_requests(&iw, &owner, &[request(owner, contact, 100)], &[]).await; + + iw.dashpay().enqueue_receiving_account_builds(&owner).await; + iw.dashpay().enqueue_receiving_account_builds(&owner).await; + + let wm = iw.wallet_manager.read().await; + let queue = &wm + .get_wallet_info(&iw.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(&owner)) + .expect("managed") + .dashpay() + .pending_contact_crypto; + assert_eq!(queue.len(), 1, "re-enqueueing is idempotent"); + assert_eq!(queue[0].owner_identity_id, owner); + assert_eq!(queue[0].contact_id, contact); + assert!(matches!( + queue[0].op, + PendingContactCryptoOp::RegisterReceiving + )); + } +} + // --------------------------------------------------------------------------- // Send-side recipient key selection. // diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs index a19288f4128..84e152ac345 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contacts.rs @@ -147,7 +147,9 @@ impl DashPayView<'_, B> { /// /// Creates a `DashpayReceivingFunds` managed account with address pools /// so the SPV adapter monitors incoming payments from this contact. - /// Call this when a contact is established (mutual requests exist). + /// Call this as soon as our outgoing request is known, whether or not the + /// contact has reciprocated: that request publishes our receiving xpub, + /// so the contact may pay us before replying. /// /// 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 a5b49b41bc6..528dfa8f061 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -83,8 +83,9 @@ impl DashPayView<'_, B> { /// block is silently missed. /// /// This lowers the wallet's SPV `synced_height` to the minimum - /// `$coreHeightCreatedAt` across established receival contacts that haven't - /// been rescanned yet — the filter manager (`dash-spv`) then re-downloads + /// `$coreHeightCreatedAt` across receival contacts (established, or holding + /// only our unreciprocated request) that haven't been rescanned yet — the + /// filter manager (`dash-spv`) then re-downloads /// nothing it already has, re-matches the now-larger script set, and /// re-requests the matching blocks. Each contact is recorded in /// [`DashPayState::rescan_triggered`](crate::wallet::identity::DashPayState) so the recurring sweep does @@ -130,13 +131,14 @@ impl DashPayView<'_, B> { }) .collect(); - // Candidates: established receival contacts not yet rescanned this - // lifetime whose funding height is below our scan tip. The floor is the - // minimum funding height — one rewind covers them all (deeper-funded - // contacts are in the watch set, so the backfill matches them too). The - // funding height is `min(outgoing, incoming)` of the pair: the channel - // is payable only once both requests exist, so the earlier of the two is - // the conservative-correct lower bound. + // Candidates: receival contacts not yet rescanned this lifetime whose + // funding height is below our scan tip. The floor is the minimum + // funding height — one rewind covers them all (deeper-funded contacts + // are in the watch set, so the backfill matches them too). For an + // established pair the funding height is `min(outgoing, incoming)`, + // the conservative lower bound. A contact we sent a request that never + // reciprocated has only our outgoing request: it carries our receiving + // xpub, so the chain is payable from that request's height. let mut floor: Option = None; let mut to_mark: Vec<(Identifier, Identifier)> = Vec::new(); for (owner, contact) in receival_pairs { @@ -146,13 +148,17 @@ impl DashPayView<'_, B> { if managed.dashpay().rescan_triggered.contains(&contact) { continue; } - let Some(established) = managed.dashpay().established_contacts().get(&contact) else { - continue; + let dashpay = managed.dashpay(); + let funding = match dashpay.established_contacts().get(&contact) { + Some(established) => established + .outgoing_request + .core_height_created_at + .min(established.incoming_request.core_height_created_at), + None => match dashpay.sent_contact_requests().get(&contact) { + Some(sent) => sent.core_height_created_at, + None => continue, + }, }; - let funding = established - .outgoing_request - .core_height_created_at - .min(established.incoming_request.core_height_created_at); // Contacts funded below the tip need a backfill — their addresses // weren't watched when those blocks were first scanned. Contacts // funded at or after the tip are already covered by the ongoing @@ -2818,6 +2824,70 @@ mod tests { .synced_height() } + /// **One-way contact.** A receival account for a contact we sent a + /// request that never reciprocated is watched from the moment it is + /// registered — usually long after SPV scanned past our request. Our + /// request carries our receiving xpub, so the contact could pay us from + /// its height on; the rescan must rewind there. Before the fix it skipped + /// every non-established contact, so those payments stayed missing at + /// any scan depth (testnet: a payment 19 blocks after our request). + #[tokio::test] + async fn rescan_backfills_a_one_way_contact_from_our_sent_request_height() { + use crate::wallet::identity::ContactRequest; + + let (manager, persister, wallet_id) = make_wallet().await; + let owner = Identifier::from([0xAA; 32]); + let contact = Identifier::from([0xBB; 32]); + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet.identity(); + let p = WalletPersister::new(wallet_id, Arc::clone(&persister) as _); + + iw.dashpay() + .register_contact_account(&owner, &contact, 0, test_receiving_xpub(&owner, &contact)) + .await + .expect("register receival account"); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity(bare_identity([0xAA; 32]), 0, wallet_id, &p) + .expect("add owner"); + let managed = info + .identity_manager + .managed_identity_mut(&owner) + .expect("managed"); + managed + .add_sent_contact_request( + ContactRequest::new(owner, contact, 0, 0, 0, vec![0u8; 96], 1_475_801, 0), + &p, + ) + .expect("setup persists"); + assert!( + managed.dashpay().established_contacts().is_empty(), + "the contact never reciprocated" + ); + } + set_synced_height(&manager, wallet_id, 1_561_776).await; + + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan"), + Some(1_475_801), + "rewinds to the height of the request that carried our xpub" + ); + assert_eq!(synced_height(&manager, wallet_id).await, 1_475_801); + assert_eq!( + iw.dashpay() + .reconcile_dashpay_rescan() + .await + .expect("rescan 2"), + None, + "the guard makes it single-shot, as for established contacts" + ); + } + /// A contact established while the wallet was still catching up (funded at or /// after the current tip) is covered by the ongoing forward scan, so the /// rescan leaves `synced_height` alone — but it must MARK the contact so