Skip to content
Merged
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
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
93 changes: 93 additions & 0 deletions packages/rs-platform-wallet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,99 @@ The package is structured as follows:
- Active/inactive status
- Note: Credit balance and revision are accessed from the Identity itself

## Persistence architecture

This section is normative: it records the agreed model for how wallet
state, the persister, and clients relate. Changes that violate these
invariants need an explicit architecture discussion first, not just a
code review.

```
commands (send, register, sync, …)
client ──────────────────────────────────────▶ platform-wallet
│ │
│ reads (display) changesets │ (single writer)
▼ ▼
┌─────────────────────── persisted store ──────────────────────┐
│ wallet-state tables: written ONLY by platform-wallet │
│ client-owned tables (UI prefs etc.): written by client │
└───────────────────────────────────────────────────────────────┘
│ load(persister) at launch — verbatim
platform-wallet
```

### Roles

- **platform-wallet** is the authority for state *transitions*. Every
mutation of wallet state happens here and is emitted as a changeset
to the persister. Its in-memory state is volatile — a cache that is
empty at process start.
- **The persisted store** is the authority for state *history*: it is
the only copy of the wallet that survives a restart, and it doubles
as the client's **read model** — UIs render persisted rows directly
and reactively. Display therefore never blocks on platform-wallet
being loaded, unlocked, or synced.
- **Clients** (dash-evo-tool, the iOS SDK app, …) issue commands to
platform-wallet and read the store freely. They never write
wallet-state rows.

### Invariants

1. **Single writer.** Only platform-wallet's changesets mutate
wallet-state tables. Clients may keep their own tables (UI
preferences, view state) in the same database; ownership is per
table family, never shared.
2. **The store schema is a versioned public contract.** Two parties
depend on it — the persister's writes and every client's reads — so
schema changes are breaking changes for clients, not private
refactors.
3. **Reads never feed back into writes** except through platform-wallet
commands. A client that computes something from persisted rows and
wants it stored must go through a platform-wallet API.
4. **`load()` is verbatim.** At launch, platform-wallet reconstructs
itself from the store through
[`PlatformWalletPersistence::load`]; the store contains exactly what
platform-wallet wrote, so the load path must consume it as-is.
Re-deriving, re-inferring, or "repairing" state during load is
forbidden — a lossy round-trip here silently diverges the wallet
from its own history (per-account attribution, address-pool
`used` flags, and SPV watch-set coverage are the historical
casualties). Anything genuinely missing from the store re-warms on
the next sync, never inside `load()`.
5. **Persist errors are hard errors.** The store is the only durable
copy, and part of it — the account manifest, address used-flags,
birth heights, identity/contact associations — is *local-only*: no
chain rescan can ever reconstruct it. A swallowed persister write
error is silent, permanent data loss discovered at the next launch.
6. **Load is seedless.** The store never carries a seed or a
`Wallet`; restore produces watch-only wallets
(`Wallet::new_watch_only`) and signing keys are derived on demand
via the resolver-backed sign paths. See the trust-boundary notes on
[`PlatformWalletPersistence::load`] for what is (and is not)
authenticated on this path.

### What restore is for

Because the store is the read model, restoring platform-wallet at
launch is **not** about showing balances or history — the client
already renders those from the store. It exists to refill the
operational state that only lives in platform-wallet's memory:

- **Detection** — the SPV watch set is the address-pool contents;
without it, incoming payments to existing addresses are not seen.
- **Spending** — coin/input selection runs against the in-memory UTXO
set.
- **Resume** — sync watermarks, tracked asset locks mid-registration,
and fresh-receive-address (`used`) state.

Persisters that can reconstruct the full keyless snapshot hand it back
as `ClientWalletStartState::core_wallet_info` (consumed verbatim, per
invariant 4). The flattened projection fields
(`core_state`/`used_core_addresses`) are a transitional fallback for
persisters that cannot build a snapshot yet, and are slated for
removal once every in-tree persister produces snapshots.

## Key Features

### Wallet Operations (via ManagedWalletInfo)
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
Loading
Loading