Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,11 @@ fn tx_record_to_ffi(
}
}

fn vec_to_ptr<T>(v: Vec<T>) -> *mut T {
/// Convert a `Vec` into a raw heap pointer for a C out-array: null for
/// empty, `Box::into_raw(boxed_slice)` otherwise. The caller owns the
/// allocation and must free it by reconstructing the boxed slice with
/// the ORIGINAL length.
pub(crate) fn vec_to_ptr<T>(v: Vec<T>) -> *mut T {
if v.is_empty() {
std::ptr::null_mut()
} else {
Expand Down
7 changes: 1 addition & 6 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,7 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor(
})
.collect();
let skipped_count = skipped_vec.len();
let skipped_ptr = if skipped_count == 0 {
std::ptr::null_mut()
} else {
let boxed = skipped_vec.into_boxed_slice();
Box::into_raw(boxed) as *mut SkippedWalletFFI
};
let skipped_ptr = crate::core_wallet_types::vec_to_ptr(skipped_vec);
std::ptr::write(
out_outcome,
LoadOutcomeFFI {
Expand Down
73 changes: 22 additions & 51 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3451,16 +3451,20 @@ fn build_wallet_start_state(
// status without rebroadcasting.
let unused_asset_locks = build_unused_asset_locks(entry)?;

// Project the reconstructed `wallet` + `wallet_info` into the
// keyless `ClientWalletStartState` the persister contract requires
// (SECRETS.md: no `Wallet`/seed crosses `load()`). The manager
// rebuilds a watch-only wallet from this manifest via
// `Wallet::new_watch_only` and applies this `core_state` projection.
// Signing happens later via the on-demand
// `sign_with_mnemonic_resolver` path, which fail-closed gates the
// resolver-supplied seed against the loaded `wallet_id`. The
// locally-built `wallet` is dropped — it was only needed to shape
// the account collection / UTXO routing above.
// Hand the fully-restored `wallet_info` across as the keyless
// snapshot (SECRETS.md: no `Wallet`/seed crosses `load()` —
// `ManagedWalletInfo` carries balances / pools / UTXOs, never key
// material). The manager rebuilds a watch-only wallet from the
// manifest via `Wallet::new_watch_only` and consumes this snapshot
// directly, so everything the decode blocks above restored survives
// verbatim: per-account UTXO and tx-record attribution (including
// the unresolved asset-lock funding records), exact pool contents
// with per-index `used` flags (the address-reuse guard and the SPV
// watch set), and the sync metadata / chainlock. Signing happens
// later via the on-demand `sign_with_mnemonic_resolver` path, which
// fail-closed gates the resolver-supplied seed against the loaded
// `wallet_id`. The locally-built `wallet` is dropped — it was only
// needed to shape the account collection / UTXO routing above.
let account_manifest: Vec<AccountRegistrationEntry> = wallet
.accounts
.all_accounts()
Expand All @@ -3470,23 +3474,6 @@ fn build_wallet_start_state(
account_xpub: a.account_xpub,
})
.collect();
let new_utxos: Vec<key_wallet::Utxo> = wallet_info
.accounts
.all_funding_accounts()
.into_iter()
.flat_map(|acct| acct.utxos.values().cloned())
.collect();
let core_state = platform_wallet::changeset::CoreChangeSet {
new_utxos,
last_processed_height: (wallet_info.metadata.last_processed_height > 0)
.then_some(wallet_info.metadata.last_processed_height),
synced_height: (wallet_info.metadata.synced_height > 0)
.then_some(wallet_info.metadata.synced_height),
// Carry the decoded chainlock through the keyless projection;
// `apply_persisted_core_state` re-applies it onto the rebuilt wallet.
last_applied_chain_lock: wallet_info.metadata.last_applied_chain_lock.clone(),
..Default::default()
};

// `contacts` / `identity_keys` are the PR-3 keyless feed the
// manager layers onto the managed identities via
Expand All @@ -3498,38 +3485,22 @@ fn build_wallet_start_state(
// would need a new cross-boundary struct field + Swift wiring,
// tracked as a follow-up. Empty slots make `apply_contacts_and_keys`
// a no-op for this path, preserving the established iOS behaviour.
// Carry the persisted pool used-state through the keyless projection.
// The pool-decode block above already merged the persisted `used`
// flags into `wallet_info`; project the used addresses out so
// `apply_persisted_core_state` can re-mark them used on rehydrate.
// Without this a previously-used address whose funds were since spent
// comes back marked unused and could be handed out again as a fresh
// receive address — an address-reuse privacy leak.
let used_core_addresses: Vec<key_wallet::Address> = {
use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait;
let mut used = Vec::new();
for acct in wallet_info.accounts.all_funding_accounts() {
for pool in acct.managed_account_type().address_pools() {
for info in pool.addresses.values() {
if info.used {
used.push(info.address.clone());
}
}
}
}
used
};

//
// `core_state` / `used_core_addresses` stay empty: they are the
// projection fallback for persisters that cannot reconstruct a full
// snapshot (the SQLite path until dashpay/platform#3968), and the
// manager ignores them when `core_wallet_info` is `Some`.
let wallet_state = ClientWalletStartState {
network,
birth_height: entry.birth_height,
account_manifest,
core_state,
core_wallet_info: Some(Box::new(wallet_info)),
core_state: Default::default(),
identity_manager,
unused_asset_locks,
contacts: Default::default(),
identity_keys: Default::default(),
used_core_addresses,
used_core_addresses: Vec::new(),
};

let platform_address_state = if per_account.is_empty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//!
//! **Keyless by type.** This carries everything needed to *reconstruct*
//! a watch-only wallet — network, birth height, the account manifest,
//! the rebuilt core-state projection, identities, filtered asset locks —
//! the managed-state snapshot (or its keyless projection), identities,
//! filtered asset locks —
//! but **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister
//! can never mint a `Wallet`; the manager rebuilds a watch-only one via
//! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only)
Expand All @@ -18,6 +19,7 @@ use crate::changeset::{
};
use crate::wallet::asset_lock::tracked::TrackedAssetLock;
use dashcore::OutPoint;
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
use key_wallet::{Address, Network};

/// Keyless per-wallet slice of the startup snapshot.
Expand All @@ -36,13 +38,33 @@ pub struct ClientWalletStartState {
/// Keyless account manifest — the account-set oracle for building the
/// watch-only wallet (one watch-only account per entry's xpub).
pub account_manifest: Vec<AccountRegistrationEntry>,
/// Full keyless managed-wallet snapshot for persisters that can
/// reconstruct one — pools with exact derivation indices and `used`
/// flags, per-account UTXO and tx-record attribution, IS-lock set,
/// and sync metadata. [`ManagedWalletInfo`] carries **no key
/// material** (see its docs: balances, account metadata, UTXO set),
/// so the SECRETS.md boundary holds: still no `Wallet`, no seed.
///
/// When `Some`, the manager consumes it directly (after validating
/// its `wallet_id`/`network` against the row) instead of minting a
/// `ManagedWalletInfo::from_wallet` skeleton and replaying the
/// projection below — preserving per-account attribution, the full
/// SPV watch set, and pool used-state verbatim, without re-deriving
/// anything. The FFI/iOS persister populates this. When `None` (the
/// native/SQLite persister until dashpay/platform#3968), the manager
/// falls back to the skeleton + [`core_state`](Self::core_state) /
/// [`used_core_addresses`](Self::used_core_addresses) replay.
pub core_wallet_info: Option<Box<ManagedWalletInfo>>,
/// Keyless projection of the persisted core rows (UTXOs, tx
/// records, IS-locks, sync watermarks, `last_applied_chain_lock`).
/// The manager applies this onto a fresh
/// `ManagedWalletInfo::from_wallet` skeleton built from the
/// watch-only wallet. Populated by the persister's
/// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load)
/// implementation reading the persisted core rows.
///
/// Ignored when [`core_wallet_info`](Self::core_wallet_info) is
Comment thread
lklimek marked this conversation as resolved.
/// `Some` — the full snapshot supersedes the projection.
pub core_state: CoreChangeSet,
/// Lean snapshot of this wallet's
/// [`IdentityManager`](crate::wallet::identity::IdentityManager).
Expand Down
29 changes: 0 additions & 29 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,6 @@ use key_wallet::account::StandardAccountType;
use key_wallet::managed_account::address_pool::AddressPoolType;
use key_wallet::Network;

use crate::manager::load_outcome::CorruptKind;

/// Per-row failure surfacing during watch-only rehydration of a single
/// persisted wallet. Maps 1:1 to [`CorruptKind`] for the
/// [`SkipReason`](crate::manager::load_outcome::SkipReason) the load loop
/// records.
#[derive(Debug)]
pub(crate) enum RehydrateRowError {
/// Manifest was empty — no account to rebuild the wallet around.
MissingManifest,
/// Building a watch-only [`Account`](key_wallet::account::Account) from a
/// manifest entry failed (xpub structurally malformed for its
/// [`AccountType`](key_wallet::account::AccountType)).
MalformedXpub,
/// `AccountCollection::insert` rejected an account (typically a
/// duplicate `account_type` within the manifest).
DecodeError(String),
}

impl From<RehydrateRowError> for CorruptKind {
fn from(e: RehydrateRowError) -> Self {
match e {
RehydrateRowError::MissingManifest => CorruptKind::MissingManifest,
RehydrateRowError::MalformedXpub => CorruptKind::MalformedXpub,
RehydrateRowError::DecodeError(s) => CorruptKind::DecodeError(s),
}
}
}

/// Errors that can occur in platform wallet operations
#[derive(Debug, thiserror::Error)]
pub enum PlatformWalletError {
Expand Down
113 changes: 91 additions & 22 deletions packages/rs-platform-wallet/src/manager/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;

use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence};
use crate::error::PlatformWalletError;
use crate::manager::load_outcome::{LoadOutcome, SkipReason};
use crate::manager::load_outcome::{CorruptKind, LoadOutcome, SkipReason};
use crate::wallet::core::WalletBalance;
use crate::wallet::identity::IdentityManager;
use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId};
Expand All @@ -21,8 +21,20 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
/// keyless reconstruction snapshot; each wallet is rebuilt via
/// [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only)
/// from its [`AccountRegistrationEntry`](crate::changeset::AccountRegistrationEntry)
/// manifest, the keyless core-state projection is applied, and the
/// result is registered into the manager.
/// manifest, the managed core state is restored, and the result is
/// registered into the manager.
///
/// Core state comes in one of two shapes, per wallet:
/// - a full keyless snapshot
/// ([`ClientWalletStartState::core_wallet_info`]) — consumed
/// directly, preserving per-account UTXO/record attribution and
/// exact pool contents (the FFI/iOS persister); or
/// - the keyless projection
/// ([`core_state`](ClientWalletStartState::core_state) +
/// [`used_core_addresses`](ClientWalletStartState::used_core_addresses)),
/// replayed onto a fresh skeleton via
/// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state)
/// (persisters that cannot reconstruct the snapshot).
///
/// The load path never touches the seed, so it performs no wrong-seed
/// check. Signing happens later, on demand, via the configured
Expand Down Expand Up @@ -105,6 +117,7 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
network,
birth_height,
account_manifest,
core_wallet_info,
core_state,
identity_manager,
unused_asset_locks,
Expand All @@ -113,6 +126,20 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
used_core_addresses,
} = wallet_state;

// Idempotency, checked FIRST: a wallet already registered (a
// prior load pass, or a runtime create) is already-satisfied.
// Checking before any reconstruction work matters — the
// rebuild below derives eager gap windows (and possibly a
// deep discovery scan), all of which the `WalletExists` arm
// at insert time would only throw away.
{
let wm = self.wallet_manager.read().await;
if wm.get_wallet(&expected_wallet_id).is_some() {
outcome.loaded.push(expected_wallet_id);
continue 'load;
}
}

// Build the watch-only wallet from the keyless manifest. A
// structural decode failure skips this row (per-row
// resilience) — it never aborts the batch and never inserts
Expand All @@ -123,32 +150,74 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
&account_manifest,
) {
Ok(w) => w,
Err(row_err) => {
let reason = SkipReason::CorruptPersistedRow {
kind: row_err.into(),
};
Err(kind) => {
let reason = SkipReason::CorruptPersistedRow { kind };
outcome.skipped.push((expected_wallet_id, reason.clone()));
self.event_manager
.on_wallet_skipped_on_load(expected_wallet_id, &reason);
continue 'load;
}
};

// Mint the managed-info skeleton from the watch-only wallet,
// then apply the keyless persisted core state (UTXOs, sync
// watermarks, per-account balances). A wallet with persisted
// UTXOs but no funds account hard-fails here rather than
// reconstructing a silent zero balance.
let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height);
if let Err(e) = super::rehydrate::apply_persisted_core_state(
&mut wallet_info,
&account_manifest,
&core_state,
&used_core_addresses,
) {
load_error = Some(e);
break 'load;
}
let wallet_info = match core_wallet_info {
// Full keyless snapshot carried by the persister (the
// FFI/iOS path): consume it directly. This preserves
// per-account UTXO/record attribution, the exact pool
// contents (derived-but-unused addresses stay in the SPV
// watch set), and per-index used flags — none of which
// the projection replay below can reconstruct — and
// skips a second eager gap-window derivation.
Some(info) => {
let mut info = *info;
// The snapshot must describe this row's wallet; a
// mismatch is a corrupt row, skipped like any other
// structural failure.
if info.wallet_id != expected_wallet_id || info.network != network {
let reason = SkipReason::CorruptPersistedRow {
kind: CorruptKind::DecodeError(format!(
"managed-info snapshot (wallet {}, network {:?}) does not \
match its row (wallet {}, network {:?})",
hex::encode(info.wallet_id),
info.network,
hex::encode(expected_wallet_id),
network,
)),
};
outcome.skipped.push((expected_wallet_id, reason.clone()));
self.event_manager
.on_wallet_skipped_on_load(expected_wallet_id, &reason);
continue 'load;
}
Comment thread
lklimek marked this conversation as resolved.
// Recompute totals from the carried UTXO set so the
// lock-free balance mirrored below can never drift
// from it (no-silent-zero holds by recomputation).
{
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
info.update_balance();
}
info
}
// No snapshot (native/SQLite persister until
// dashpay/platform#3968): mint the managed-info skeleton
// from the watch-only wallet, then replay the keyless
// projection (UTXOs, sync watermarks, used addresses). A
// wallet with persisted UTXOs but no funds account
// hard-fails here rather than reconstructing a silent
// zero balance.
None => {
let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height);
if let Err(e) = super::rehydrate::apply_persisted_core_state(
&mut wallet_info,
&account_manifest,
&core_state,
&used_core_addresses,
) {
load_error = Some(e);
break 'load;
}
wallet_info
}
};
Comment thread
lklimek marked this conversation as resolved.

// Flatten the (account → outpoint → lock) map.
let mut tracked_asset_locks = BTreeMap::new();
Expand Down
Loading
Loading