From 6b5245a4fc79f870aaa20066308474a53c0c63cb Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 1 Oct 2026 16:26:13 -0700 Subject: [PATCH 1/2] fix(platform-wallet): build our receiving account for one-way DashPay contacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A contact we sent a contact request to, who never sent one back, got no DashpayReceivingFunds account. DIP-15 puts our receiving xpub in the request we send, so that contact can pay us without reciprocating. But the sweep built accounts only for established contacts, and the rescan reconcile skipped every contact that was not established. Payments on that chain were never seen, at any rescan depth. The live send path registers the account itself, so this hit wallets that learned of the sent request from Platform (restore from seed, a second device) or whose live registration failed after the request was saved. Seen on a topple testnet wallet: a 0.001 DASH receive (e5169bfc…, height 1,475,820) on our chain for a contact whose only request is ours, sent 19 blocks earlier. It is missing from every archived SDK store and present in dashj's, and its later spend is recorded with a positive net amount because the funding transaction is unknown. The sweep now queues RegisterReceiving, and only that, for each unreciprocated sent request that has no receival account. There is no xpub of theirs to decrypt, so no external account can be built until they reciprocate. The rescan reconcile rewinds a sent-only receival account to our request's core height. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 512 ++++++++++++++++++ .../src/wallet/identity/network/payments.rs | 100 +++- 2 files changed, 597 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 2e2f2ef9992..3bf2a6b3b99 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 we sent a request + // that has not reciprocated: our xpub is in that request, so + // they can already pay us on it. + self.enqueue_receiving_only_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,132 @@ impl DashPayView<'_, B> { out } + /// Collect every contact (for `identity_id`) that we sent a contact + /// request to, that has not sent one back, and that has no + /// `DashpayReceivingFunds` account yet — the receiving-only build + /// candidates for this sweep. Runs under the caller's guard; performs + /// no awaits and no lock re-acquisition. + /// + /// 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. [`Self::collect_account_build_candidates`] walks only + /// established contacts, so without this a one-way contact never got a + /// receival account and its payments to us were never seen, at any + /// rescan depth. The live send path registers the account itself; this + /// covers a sent request the sweep learned from Platform (restore from + /// seed, a second device) and a live registration that failed after the + /// request was saved. + /// + /// Only the receiving side can be built: the external account needs the + /// contact's xpub, which exists only in a request they send us. Once + /// they do, the contact is established and the regular candidates take + /// over. Identities without an HD slot are skipped — there is no seed + /// path to derive our receiving xpub from. + fn collect_receiving_only_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(); + } + + managed + .dashpay() + .sent_contact_requests() + .keys() + // A sent request moves into `established_contacts` when the pair + // completes, so an overlap here is transient; the established + // candidates own that contact. + .filter(|contact_id| { + !managed + .dashpay() + .established_contacts() + .contains_key(contact_id) + }) + .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-only candidate of + /// `identity_id` (see [`Self::collect_receiving_only_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_only_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_only_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-only contact-crypto enqueue; will re-enqueue next sweep" + ); + } + tracing::info!( + identity = %identity_id, + contacts = contacts.len(), + "Deferred DashPay receiving-account build for one-way contacts: 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 +5409,387 @@ 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: a contact we sent an unreciprocated request is a + /// receiving-only candidate; a contact that only sent us one is not (it + /// never received our xpub, so it cannot pay us on a chain of ours); + /// and an established contact belongs to the regular candidates. + #[tokio::test] + async fn should_collect_only_unreciprocated_sent_requests_as_receiving_only_candidates() { + 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_only_candidates(info, &owner), + vec![one_way_out] + ); + let regular: Vec = + DashPayView::::collect_account_build_candidates(info, &owner) + .into_iter() + .map(|c| c.contact_id) + .collect(); + assert_eq!(regular, vec![established]); + } + + /// **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_only_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_only_builds(&owner).await; + iw.dashpay().enqueue_receiving_only_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/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 From de7bfa87ad6ec69b3219c4639651a4f3e497e1cc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 1 Oct 2026 20:21:20 -0700 Subject: [PATCH 2/2] fix(platform-wallet): gate the receiving-account build on our request alone The previous commit queued our receiving account for a contact whose request we sent and who never replied. Two established contacts were still left without one: a contact whose payment channel is marked broken, and a contact whose external account was built but whose receiving build failed once. The regular candidate gate skips both for good, and it is the only thing that re-queues a build after a relaunch. Our receiving account needs our identity, theirs and the signer. It never touches their xpub, so neither failure is a reason to skip it. The receiving-side collector now lists every contact that holds a request we sent, in sent_contact_requests or established_contacts, with no receival account, and ignores the broken flag. RegisterReceiving makes no fetch and no decrypt, so it cannot retry without bound. The external account keeps its own gate, and the overlap with the regular candidates is harmless because enqueueing is idempotent per (owner, contact, kind). Co-Authored-By: Claude Fable 5.1 --- .../identity/network/contact_requests.rs | 163 ++++++++++++------ .../src/wallet/identity/network/contacts.rs | 4 +- 2 files changed, 114 insertions(+), 53 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 3bf2a6b3b99..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,10 +1670,10 @@ impl DashPayView<'_, B> { self.build_contact_accounts(&identity_id, candidate).await; } - // (3b) Our receiving account for every contact we sent a request - // that has not reciprocated: our xpub is in that request, so - // they can already pay us on it. - self.enqueue_receiving_only_builds(&identity_id).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 @@ -1915,28 +1915,40 @@ impl DashPayView<'_, B> { out } - /// Collect every contact (for `identity_id`) that we sent a contact - /// request to, that has not sent one back, and that has no - /// `DashpayReceivingFunds` account yet — the receiving-only build - /// candidates for this sweep. Runs under the caller's guard; performs - /// no awaits and no lock re-acquisition. + /// 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. /// - /// 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. [`Self::collect_account_build_candidates`] walks only - /// established contacts, so without this a one-way contact never got a - /// receival account and its payments to us were never seen, at any - /// rescan depth. The live send path registers the account itself; this - /// covers a sent request the sweep learned from Platform (restore from - /// seed, a second device) and a live registration that failed after the - /// request was saved. + /// 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: /// - /// Only the receiving side can be built: the external account needs the - /// contact's xpub, which exists only in a request they send us. Once - /// they do, the contact is established and the regular candidates take - /// over. Identities without an HD slot are skipped — there is no seed - /// path to derive our receiving xpub from. - fn collect_receiving_only_candidates( + /// * 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 { @@ -1949,19 +1961,18 @@ impl DashPayView<'_, B> { return Vec::new(); } - managed - .dashpay() + 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() - // A sent request moves into `established_contacts` when the pair - // completes, so an overlap here is transient; the established - // candidates own that contact. - .filter(|contact_id| { - !managed - .dashpay() - .established_contacts() - .contains_key(contact_id) - }) + .chain(dashpay.established_contacts().keys()) + .collect(); + + with_our_request + .into_iter() .filter(|contact_id| { let key = DashpayAccountKey { index: 0, @@ -1978,16 +1989,16 @@ impl DashPayView<'_, B> { .collect() } - /// Queue `RegisterReceiving` for every receiving-only candidate of - /// `identity_id` (see [`Self::collect_receiving_only_candidates`]) for - /// the signer-backed drain. Collection and enqueue share one write + /// 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_only_builds(&self, identity_id: &Identifier) { + async fn enqueue_receiving_account_builds(&self, identity_id: &Identifier) { use crate::changeset::{ upsert_pending_contact_crypto, PendingContactCrypto, PendingContactCryptoOp, PlatformWalletChangeSet, @@ -1997,7 +2008,7 @@ impl DashPayView<'_, B> { let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { return; }; - let contacts = Self::collect_receiving_only_candidates(info, identity_id); + let contacts = Self::collect_receiving_account_candidates(info, identity_id); if contacts.is_empty() { return; } @@ -2031,13 +2042,13 @@ impl DashPayView<'_, B> { if let Err(e) = self.persister.store(changeset) { tracing::warn!( identity = %identity_id, contacts = contacts.len(), error = %e, - "failed to persist receiving-only contact-crypto enqueue; will re-enqueue next sweep" + "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 build for one-way contacts: enqueued for the signer-backed drain" + "Deferred DashPay receiving-account builds: enqueued for the signer-backed drain" ); } @@ -5662,12 +5673,14 @@ mod one_way_contact_tests { .unwrap_or_default() } - /// The candidate set: a contact we sent an unreciprocated request is a - /// receiving-only candidate; a contact that only sent us one is not (it - /// never received our xpub, so it cannot pay us on a chain of ours); - /// and an established contact belongs to the regular candidates. + /// 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_only_unreciprocated_sent_requests_as_receiving_only_candidates() { + 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); @@ -5691,8 +5704,8 @@ mod one_way_contact_tests { let wm = iw.wallet_manager.read().await; let info = wm.get_wallet_info(&iw.wallet_id).expect("info"); assert_eq!( - DashPayView::::collect_receiving_only_candidates(info, &owner), - vec![one_way_out] + DashPayView::::collect_receiving_account_candidates(info, &owner), + vec![one_way_out, established] ); let regular: Vec = DashPayView::::collect_account_build_candidates(info, &owner) @@ -5702,6 +5715,52 @@ mod one_way_contact_tests { 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 @@ -5752,7 +5811,7 @@ mod one_way_contact_tests { let info = wm.get_wallet_info(&iw.wallet_id).expect("info"); assert!(has_receival_account(info, &owner, &contact)); assert!( - DashPayView::::collect_receiving_only_candidates(info, &owner) + DashPayView::::collect_receiving_account_candidates(info, &owner) .is_empty(), "a built account is no longer a candidate" ); @@ -5770,8 +5829,8 @@ mod one_way_contact_tests { let (_manager, iw) = wallet_with_owner(sdk, owner).await; seed_requests(&iw, &owner, &[request(owner, contact, 100)], &[]).await; - iw.dashpay().enqueue_receiving_only_builds(&owner).await; - iw.dashpay().enqueue_receiving_only_builds(&owner).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 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(