Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bad2093
fix(platform-wallet): stop the startup sequence reporting integrity i…
bfoss765 Aug 19, 2026
c968b30
fix(platform-wallet): close three gaps review found in the bring-up h…
bfoss765 Aug 19, 2026
63b69b2
fix(platform-wallet): gate the payment-path drain and report a local …
bfoss765 Aug 26, 2026
83e5ac7
Merge branch 'v4.2-dev' into fix/wallet-startup-integrity
HashEngineering Aug 27, 2026
4dd0aff
fix(platform-wallet): gate the auto-accept pass and make a failed con…
bfoss765 Aug 27, 2026
f640503
Merge branch 'v4.2-dev' into fix/wallet-startup-integrity
HashEngineering Aug 27, 2026
89ea0dc
fix(platform-wallet): bound the seed-binding check by the caller's de…
shumkov Aug 27, 2026
91f6c0b
fix(platform-wallet): tie the provider binding to the provider it ver…
shumkov Aug 27, 2026
4b50e6f
fix(platform-wallet): a scan verdict may only clear the gaps it covered
shumkov Aug 27, 2026
2383632
Merge branch 'v4.2-dev' into fix/wallet-startup-integrity
HashEngineering Aug 27, 2026
e5f6b36
fix(platform-wallet): an unlocated gap must survive the folds in between
shumkov Aug 28, 2026
77e3ec7
fix(platform-wallet): a from-zero scan that never finished covers not…
shumkov Aug 28, 2026
b52d3a0
fix(platform-wallet): the raw contact-crypto drains are not a public …
shumkov Aug 28, 2026
609cb86
fix(platform-wallet): a local scan fault does not erase an identity a…
shumkov Aug 28, 2026
8725606
docs(platform-wallet): the seed-binding deadline cannot interrupt a b…
shumkov Aug 28, 2026
4c30f02
fix(platform-wallet): bound the DPNS enrichment tail separately from …
HashEngineering Aug 29, 2026
b989989
Merge branch 'v4.2-dev' into fix/wallet-startup-integrity
HashEngineering Aug 29, 2026
c4b0fe6
fix(platform-wallet): keep the enrichment deadline off the public opt…
HashEngineering Aug 29, 2026
739ceb5
refactor(platform-wallet): move IdentityScanStateEntry into its own c…
HashEngineering Aug 29, 2026
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
103 changes: 84 additions & 19 deletions packages/rs-platform-wallet-ffi/src/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,20 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests(
/// The wallet seed is never made resident; every signature is produced
/// inside the signer's atomic derive-and-sign step.
///
/// # Seed binding
///
/// The send begins by draining any deferred contact-crypto build for this
/// contact, which derives and registers contact accounts from whatever seed
/// the resolver resolves. That drain runs behind the same gate as
/// `platform_wallet_drain_pending_contact_crypto`: whenever there is drainable
/// work, the resolver is checked against this wallet's persisted BIP44
/// account-0 xpub first. A resolver mapped to a different wallet fails the
/// call with `ErrorInvalidParameter`, derives NOTHING, and leaves the queue
/// intact. Without the gate the wrong-seed contact account would be written
/// permanently (`register_contact_account` keys its existence check on the
/// contact pair, not the xpub) and the payment would then fail anyway on the
/// funding signatures — corruption first, error second.
///
/// # Safety
/// - `core_signer_handle` must be a valid, non-destroyed
/// `*mut MnemonicResolverHandle`. Ownership is retained by the caller —
Expand Down Expand Up @@ -664,7 +678,22 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment(
})
});
let result = unwrap_option_or_return!(option);
let (txid, _entry, fee_duffs) = unwrap_result_or_return!(result);
// The send opens with the SEED-VERIFIED contact-crypto drain, so a
// resolver mapped to a different wallet is refused here instead of
// registering a wrong-seed contact account and only then failing on the
// funding signatures. Reported with the same code the standalone verify
// and the drain entry point use, so a host recognizes the wrong-seed
// condition identically however it arrives.
let (txid, _entry, fee_duffs) = match result {
Ok(v) => v,
Err(e @ platform_wallet::PlatformWalletError::SeedMismatch { .. }) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
e.to_string(),
);
}
Err(e) => return e.into(),
};
// Exact network fee of the broadcast transaction — Σ(selected input
// values) − Σ(output values), computed by the transaction builder
// itself since rust-dashcore#872, so a sub-dust change remainder
Expand Down Expand Up @@ -839,6 +868,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 +903,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 +917,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 +928,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 @@ -5035,6 +5035,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
25 changes: 25 additions & 0 deletions packages/rs-platform-wallet/src/changeset/changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType};
use key_wallet::managed_account::transaction_record::TransactionRecord;
use key_wallet::{AddressInfo, Network, PlatformP2PKHAddress, Utxo};

use crate::changeset::identity_scan_state::IdentityScanStateEntry;
use crate::wallet::platform_wallet::WalletId;

use dpp::balances::credits::Credits;
Expand Down Expand Up @@ -1884,6 +1885,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 @@ -2019,6 +2032,17 @@ impl Merge for PlatformWalletChangeSet {
if let Some(meta) = other.wallet_metadata {
self.wallet_metadata = Some(meta);
}
// Identity-scan verdict: the later scan's verdict folded over the
// earlier one, on the rule the manager applies — see
// `IdentityScanStateEntry::superseding`. Overwriting instead would let
// a scan batched into the same persist round clear a gap it never
// probed, which is the whole reason the verdict is recorded.
if let Some(scan) = other.identity_scan_state {
self.identity_scan_state = Some(match self.identity_scan_state.take() {
Some(previous) => scan.superseding(&previous),
None => 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 @@ -2057,6 +2081,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>,
}
Loading
Loading