Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,24 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider {
/// identity `signer_handle` to send the reciprocal). Writes the total number of
/// completed entries (drained + auto-accepted) to `out_drained`.
///
/// # Seed binding
///
/// Whenever there is drainable work, the resolver behind `core_signer_handle`
/// is first checked against this wallet's persisted BIP44 account-0 xpub
/// (`PlatformWallet::drain_pending_contact_crypto_verified`, the same gate the
/// startup sequence drains through). A resolver mapped to a different wallet
/// fails the call with `ErrorInvalidParameter` and derives NOTHING — the queue
/// is left intact for the next correct-seed drain. This is not advisory: a
/// wrong-seed drain writes contact receiving accounts that no later
/// correct-seed pass revisits (`register_contact_account` keys its existence
/// check on the contact pair, not on the xpub), so the corruption would be
/// permanent and its only symptom payments that never arrive. Any other
/// verification failure — a resolver that simply cannot answer — fails closed
/// the same way, with `ErrorWalletOperation`.
///
/// An empty queue skips the check entirely, so a poll with nothing to do still
/// costs no key material.
///
/// # Safety
/// - `signer_handle` (the identity document signer) is **optional**: pass null to
/// run only the provider-derived ops (account build / contactInfo decrypt) and
Expand All @@ -856,6 +874,10 @@ pub unsafe extern "C" fn platform_wallet_drain_pending_contact_crypto(
) -> PlatformWalletFFIResult {
check_ptr!(core_signer_handle);
check_ptr!(out_drained);
// Zero-init before any fallible work so a refused drain leaves a truthful
// count rather than whatever the caller's stack held — same discipline as
// the cached seed-binding verify.
unsafe { *out_drained = 0 };

// The identity signer is optional — null means "provider-only drain".
let signer_addr = if signer_handle.is_null() {
Expand All @@ -866,7 +888,6 @@ pub unsafe extern "C" fn platform_wallet_drain_pending_contact_crypto(
let core_signer_addr = core_signer_handle as usize;

let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| {
let identity = wallet.identity().clone();
let wallet_id = wallet.wallet_id();
let network = wallet.network();
// SAFETY: same lifetime contract as platform_wallet_send_dashpay_payment —
Expand All @@ -878,30 +899,45 @@ pub unsafe extern "C" fn platform_wallet_drain_pending_contact_crypto(
network,
)
};
let wallet = wallet.clone();
block_on_worker(async move {
let drained = identity
.dashpay()
.drain_pending_contact_crypto(&provider)
.await;
// The auto-accept pass needs the identity signer for the reciprocal;
// skip it when no identity signer was supplied.
let accepted = if signer_addr != 0 {
let signer: &VTableSigner = &*(signer_addr as *const VTableSigner);
identity
.dashpay()
.drain_auto_accepts(signer, &provider)
.await
// `None` skips it, matching a null `signer_handle`.
let signer: Option<&VTableSigner> = if signer_addr != 0 {
Some(&*(signer_addr as *const VTableSigner))
} else {
0
None
};
drained + accepted
// Unbounded, as this entry point has always been: it is called off
// the main thread by a host that decided the work is worth waiting
// for, not from the Core-SPV-gating startup path that owns a budget.
wallet
.drain_pending_contact_crypto_verified(&provider, signer, None)
.await
})
});
let total = unwrap_option_or_return!(option);
unsafe {
*out_drained = total as u32;
let result = unwrap_option_or_return!(option);
match result {
Ok(total) => {
unsafe {
*out_drained = total as u32;
}
PlatformWalletFFIResult::ok()
}
// Same code the standalone verify reports for a mis-mapped resolver, so
// a host recognizes the wrong-seed condition identically whether it
// checked up front or was refused at the drain.
Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
e.to_string(),
)
}
Err(e) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
e.to_string(),
),
}
PlatformWalletFFIResult::ok()
}

/// Number of deferred **account-build** contact-crypto ops queued for this
Expand Down
5 changes: 5 additions & 0 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4786,6 +4786,11 @@ fn build_wallet_start_state(
let identity_manager = IdentityManagerStartState {
out_of_wallet_identities: BTreeMap::new(),
wallet_identities,
// No vtable slot carries the identity-scan verdict yet, so nothing is
// restored here. Empty reads as "unknown", which preserves the
// warm-launch shortcut rather than forcing a scan every launch — see
// `IdentityManagerStartState::scan_states`.
scan_states: BTreeMap::new(),
};

// Rehydrate tracked asset-locks (built / broadcast / IS-locked
Expand Down
17 changes: 16 additions & 1 deletion packages/rs-platform-wallet-ffi/src/wallet_startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub enum WalletStartupStatusFFI {
PartialNoIdentity = 2,
PartialAccountsPending = 3,
DiscoveryFailed = 4,
SeedBindingUnverified = 5,
IdentityScanIncomplete = 6,
}

impl From<WalletStartupStatus> for WalletStartupStatusFFI {
Expand All @@ -41,6 +43,8 @@ impl From<WalletStartupStatus> for WalletStartupStatusFFI {
WalletStartupStatus::PartialNoIdentity => Self::PartialNoIdentity,
WalletStartupStatus::PartialAccountsPending => Self::PartialAccountsPending,
WalletStartupStatus::DiscoveryFailed => Self::DiscoveryFailed,
WalletStartupStatus::SeedBindingUnverified => Self::SeedBindingUnverified,
WalletStartupStatus::IdentityScanIncomplete => Self::IdentityScanIncomplete,
}
}
}
Expand All @@ -56,8 +60,17 @@ pub struct WalletStartupOutcomeFFI {
pub identity_id: [u8; 32],
/// Discovery scans performed; `0` when a local identity was already known.
pub discovery_attempts: u32,
/// Whether the inline contact-request pass ran.
/// Whether the inline contact-request pass ran **to completion**. `false`
/// when it came back degraded — some identities' contact documents could
/// not be read, so their account builds were never enqueued.
pub dashpay_sync_ran: bool,
/// The drain was skipped because the supplied contact-crypto provider does
/// not resolve this wallet's seed. Nothing was derived or written.
pub seed_binding_unverified: bool,
/// The wallet's identity scan is on record as having left indices
/// unanswered and this launch did not close the gap. Any identity reported
/// here is real; it may not be the only one.
pub identity_scan_incomplete: bool,
/// Contact-crypto entries the drain completed.
pub contact_accounts_drained: u32,
/// Contact-account builds still queued on return.
Expand All @@ -78,6 +91,8 @@ impl From<WalletStartupOutcome> for WalletStartupOutcomeFFI {
identity_id,
discovery_attempts: outcome.discovery_attempts,
dashpay_sync_ran: outcome.dashpay_sync_ran,
seed_binding_unverified: outcome.seed_binding_unverified,
identity_scan_incomplete: outcome.identity_scan_incomplete,
contact_accounts_drained: outcome.contact_accounts_drained as u32,
contact_accounts_pending: outcome.contact_accounts_pending as u32,
elapsed_ms: outcome.elapsed.as_millis() as u64,
Expand Down
81 changes: 81 additions & 0 deletions packages/rs-platform-wallet/src/changeset/changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,67 @@ pub struct WalletMetadataEntry {
pub birth_height: u32,
}

/// Whether the last gap-limit identity scan for this wallet answered every
/// index it probed.
///
/// A scan has three endings, and only two of them are visible in what it
/// returns. It can find identities, it can prove there are none, or it can
/// find *some* while one of its probes goes unanswered — and that third
/// ending returns `Ok` with the identities it did find, because discarding
/// them would be worse. `ScanTally::is_trustworthy` is
/// `identities_seen > 0 || failed_probes == 0`, so a scan that saw index 0
/// and got no answer at index 1 is reported as a success.
///
/// That is survivable only if something scans again. Nothing did: the
/// warm-launch shortcut skips discovery whenever any identity is on file, and
/// the fact that the scan behind that identity was partial existed nowhere
/// once the process exited. An identity at the unanswered index then stayed
/// invisible for the life of the installation, along with all of its contacts
/// — a silent, permanent gap whose only symptom is a missing identity and
/// DPNS name after a restore. See dashpay/platform#4365.
///
/// This is that missing fact. `complete` is stored rather than derived from
/// `failed_indices` because the two ways a scan can end early are different:
/// unanswered probes leave indices behind, while a scan abandoned at the
/// startup budget leaves none and is no more complete for it.
///
/// Carried as `Option<IdentityScanStateEntry>` — at most one scan verdict per
/// persist round, last-write-wins, which is correct because a later scan's
/// verdict wholly supersedes an earlier one's.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IdentityScanStateEntry {
/// Every index the scan probed was answered. Only a `true` here may let a
/// later launch skip discovery.
pub complete: bool,
/// One past the highest index the scan probed.
pub probed_through: u32,
/// Indices whose probe never got an answer, ascending. Empty for a scan
/// that was cut off before it could fail anything.
pub failed_indices: Vec<u32>,
}

impl IdentityScanStateEntry {
/// A scan that answered every index it probed.
pub fn completed(probed_through: u32) -> Self {
Self {
complete: true,
probed_through,
failed_indices: Vec::new(),
}
}

/// A scan that left at least one index unanswered, or was abandoned
/// before it could finish.
pub fn incomplete(probed_through: u32, failed_indices: Vec<u32>) -> Self {
Self {
complete: false,
probed_through,
failed_indices,
}
}
}

/// One entry per registered account. Captures the per-account xpub
/// + type so a future load path can rebuild the wallet watch-only
/// via `Account::from_xpub`. Hardened derivation at the account
Expand Down Expand Up @@ -1606,6 +1667,18 @@ pub struct PlatformWalletChangeSet {
/// Per-wallet metadata emitted once at registration. See
/// [`WalletMetadataEntry`] for the merge policy.
pub wallet_metadata: Option<WalletMetadataEntry>,
/// Verdict of the most recent gap-limit identity scan. Emitted by
/// discovery and by the startup sequence when it abandons a scan; read on
/// the next launch to decide whether the warm-launch shortcut may skip
/// discovery. See [`IdentityScanStateEntry`].
///
/// Durability caveat, the same one [`Self::pending_contact_crypto_added`]
/// carries: no persister vtable has a slot for this field yet, so on
/// hosts that have not adopted it the verdict is process-lifetime only.
/// Within a process it still redirects a second bring-up, and a partial
/// scan is now retried inside its own launch — but closing
/// dashpay/platform#4365 across launches needs the host slot.
pub identity_scan_state: Option<IdentityScanStateEntry>,
/// Per-account registration entries emitted at registration / on
/// later `add_account` calls. See [`AccountRegistrationEntry`] for
/// the merge policy (plain `Vec::extend`, dedup is the apply-side
Expand Down Expand Up @@ -1741,6 +1814,13 @@ impl Merge for PlatformWalletChangeSet {
if let Some(meta) = other.wallet_metadata {
self.wallet_metadata = Some(meta);
}
// Identity-scan verdict: last-write-wins. A later scan's verdict
// wholly supersedes an earlier one's — merging two would have to
// invent a rule for combining a complete scan with an incomplete one,
// and either answer would be wrong for one of them.
if let Some(scan) = other.identity_scan_state {
self.identity_scan_state = Some(scan);
}
// Per-account specs and address-pool snapshots: append-only.
// See the type docstrings for the rationale (registration
// round emits each key once; snapshots are whole-pool, so
Expand Down Expand Up @@ -1779,6 +1859,7 @@ impl Merge for PlatformWalletChangeSet {
.as_ref()
.is_none_or(|m| m.is_empty())
&& self.wallet_metadata.is_none()
&& self.identity_scan_state.is_none()
&& self.account_registrations.is_empty()
&& self.provider_key_account_registrations.is_empty()
&& self.account_address_pools.is_empty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::collections::BTreeMap;

use dpp::prelude::Identifier;

use crate::changeset::IdentityScanStateEntry;
use crate::wallet::identity::ManagedIdentity;
use crate::wallet::identity::RegistrationIndex;
use crate::wallet::platform_wallet::WalletId;
Expand All @@ -26,4 +27,13 @@ pub struct IdentityManagerStartState {
/// Wallet-owned identities, outer-keyed by wallet id and
/// inner-keyed by BIP-9 registration index.
pub wallet_identities: BTreeMap<WalletId, BTreeMap<RegistrationIndex, ManagedIdentity>>,
/// Per-wallet verdict of the last gap-limit identity scan.
///
/// An absent entry means "no verdict was restored", which is NOT the same
/// as a complete scan — a host that does not persist the verdict yet, and
/// a wallet whose first scan has not run, both land here. Absence
/// therefore preserves the existing warm-launch behaviour rather than
/// claiming a guarantee nobody made; only a restored `complete: false`
/// forces a rescan. See [`IdentityScanStateEntry`].
pub scan_states: BTreeMap<WalletId, IdentityScanStateEntry>,
}
12 changes: 6 additions & 6 deletions packages/rs-platform-wallet/src/changeset/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ pub use changeset::{
AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet,
DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes,
IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry,
IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus,
KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey,
PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry,
PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry,
ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey,
SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry,
IdentityKeysChangeSet, IdentityScanStateEntry, InvitationChangeSet, InvitationEntry,
InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto,
PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp,
PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet,
ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey,
ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry,
};
pub use client_start_state::ClientStartState;
pub use client_wallet_start_state::ClientWalletStartState;
Expand Down
18 changes: 18 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,24 @@ pub enum PlatformWalletError {
wallet_id: String,
},

#[error(
"Contact-request sync reached none of the wallet's {identities} identities \
(Platform unreachable) — the pass did not complete"
)]
/// A contact-request pass had identities to fetch for and could not read a
/// single one of them. Distinct from an empty success, which means
/// "Platform answered, and there is nothing new": this one means we do not
/// know, so the caller must not record the pass as completed.
///
/// The sweep's per-identity log-and-continue collapsed the two, so a DAPI
/// outage returned `Ok(vec![])` and a startup sequence recorded a
/// successful contact sync — then reported `Ready`, promising that every
/// contact's DIP-15 addresses existed before Core SPV started.
ContactSyncUnreachable {
/// Identities the pass tried, and failed, to fetch for.
identities: usize,
},

#[error("SPV is already running — stop it before starting again")]
SpvAlreadyRunning,

Expand Down
Loading
Loading