Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/rs-platform-wallet-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub mod platform_address_sync;
pub mod platform_address_types;
pub mod platform_addresses;
pub mod platform_wallet_info;
pub mod rehydration_seed_provider;
mod runtime;
#[cfg(feature = "shielded")]
pub mod shielded_persistence;
Expand Down
140 changes: 133 additions & 7 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,47 @@ unsafe fn create_wallet_from_mnemonic_impl(
PlatformWalletFFIResult::ok()
}

/// One wallet skipped during `load_from_persistor` because its seed
/// was unavailable (recoverable — retry after the host provides /
/// unlocks the mnemonic). Never a wrong-seed wallet.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct SkippedWalletFFI {
/// The (public) 32-byte wallet id that was skipped.
pub wallet_id: [u8; 32],
/// Structural skip reason: `0` = seed absent, `1` = store
/// locked/unavailable (retry after unlock), `2` = other store
/// error. No secret material is ever carried.
pub reason_code: u32,
}

/// C-visible summary of one `load_from_persistor` pass so the host can
/// see which wallets loaded and which were skipped (and why) instead
/// of the outcome being silently discarded.
///
/// `skipped` is a heap array of length `skipped_count`; pass this
/// struct (by pointer) to
/// [`platform_wallet_load_outcome_free`] exactly once to release it.
#[repr(C)]
#[derive(Debug)]
pub struct LoadOutcomeFFI {
/// Number of wallets fully reconstructed + registered.
pub loaded_count: usize,
/// Length of the `skipped` array.
pub skipped_count: usize,
/// Heap-allocated skipped-wallet array (null iff `skipped_count`
/// is 0). Owned by Rust until `platform_wallet_load_outcome_free`.
pub skipped: *mut SkippedWalletFFI,
}

fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 {
match reason {
platform_wallet::SkipReason::SeedAbsent => 0,
platform_wallet::SkipReason::StoreUnavailable(_) => 1,
platform_wallet::SkipReason::StoreError(_) => 2,
}
}

/// Create a wallet from raw seed bytes (64 bytes).
///
/// On success, `out_wallet_handle` is set to a `PlatformWallet` handle and
Expand Down Expand Up @@ -296,23 +337,108 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit
///
/// Triggers `on_load_wallet_list_fn` on the persistence callbacks to
/// fetch the persisted wallet list from the client side (SwiftData),
/// reconstructs each wallet as **watch-only** via its stored root +
/// per-account xpubs, and registers them inside the manager. Does not
/// produce wallet handles — the caller should follow up with
/// [`platform_wallet_manager_get_wallet`] per `wallet_id` it knows
/// about.
/// builds a keyless reconstruction payload per wallet, then re-derives
/// each signing wallet from the supplied mnemonic `resolver` and runs
/// the fail-closed wrong-seed gate before registering it. Does not
/// produce wallet handles — follow up with
/// [`platform_wallet_manager_get_wallet`] per `wallet_id`.
///
/// A wallet whose mnemonic the `resolver` cannot supply is **skipped**
/// (recoverable), not failed: the call still returns `Success`, every
/// skipped `(wallet_id, reason)` is logged, and — when `out_outcome`
/// is non-null — surfaced through it so the host can re-attempt the
/// skipped set after unlocking. A *wrong* mnemonic is a hard error
/// (returned via the result code), never a silent skip.
///
/// # Safety
/// - `resolver` must be a live handle from
/// `dash_sdk_mnemonic_resolver_create`, outliving this call.
/// - `out_outcome` may be null (caller doesn't want the summary);
/// otherwise it must point to writable `LoadOutcomeFFI` storage and
/// the caller must later release it via
/// [`platform_wallet_load_outcome_free`].
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor(
manager_handle: Handle,
resolver: *const rs_sdk_ffi::MnemonicResolverHandle,
out_outcome: *mut LoadOutcomeFFI,
) -> PlatformWalletFFIResult {
check_ptr!(resolver);
// SAFETY: the caller's contract guarantees `resolver` is a live
// handle that outlives this synchronous call.
let seeds = crate::rehydration_seed_provider::ResolverSeedProvider::new(resolver);
let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| {
runtime().block_on(manager.load_from_persistor())
runtime().block_on(manager.load_from_persistor(&seeds))
});
let result = unwrap_option_or_return!(option);
unwrap_result_or_return!(result);
let outcome = unwrap_result_or_return!(result);

// Never silently drop the outcome: log a structured summary plus
// one line per skipped wallet (recoverable; the host can retry the
// skipped set after unlocking the keychain).
tracing::info!(
loaded = outcome.loaded.len(),
skipped = outcome.skipped.len(),
"platform_wallet_manager_load_from_persistor complete"
);
for (wid, reason) in &outcome.skipped {
tracing::warn!(
wallet_id = %hex::encode(wid),
reason = %reason,
"load_from_persistor skipped wallet (seed unavailable — recoverable)"
);
}

if !out_outcome.is_null() {
let skipped_vec: Vec<SkippedWalletFFI> = outcome
.skipped
.iter()
.map(|(wid, reason)| SkippedWalletFFI {
wallet_id: *wid,
reason_code: skip_reason_code(reason),
})
.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
};
std::ptr::write(
out_outcome,
LoadOutcomeFFI {
loaded_count: outcome.loaded.len(),
skipped_count,
skipped: skipped_ptr,
},
);
}
PlatformWalletFFIResult::ok()
}

/// Release the heap `skipped` array a successful
/// [`platform_wallet_manager_load_from_persistor`] wrote into a
/// `LoadOutcomeFFI`. Idempotent: nulls the pointer after freeing, and
/// a null `outcome` (or already-freed array) is a no-op.
///
/// # Safety
/// `outcome` must point to a `LoadOutcomeFFI` previously populated by
/// `platform_wallet_manager_load_from_persistor`, not freed already.
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_load_outcome_free(outcome: *mut LoadOutcomeFFI) {
if outcome.is_null() {
return;
}
let o = &mut *outcome;
if !o.skipped.is_null() && o.skipped_count > 0 {
let slice = std::slice::from_raw_parts_mut(o.skipped, o.skipped_count);
drop(Box::from_raw(slice as *mut [SkippedWalletFFI]));
}
o.skipped = std::ptr::null_mut();
o.skipped_count = 0;
}

/// Get a `PlatformWallet` handle for a wallet registered in the
/// manager. Returns `NotFound` if no wallet with the given
/// id is currently held.
Expand Down
38 changes: 36 additions & 2 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2829,9 +2829,43 @@ 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 new persister contract
// requires (SECRETS.md: no `Wallet`/seed crosses `load()`). The
// manager re-derives the signing wallet from the runtime
// `SeedProvider` (here the Swift mnemonic resolver), runs the
// wrong-seed gate, then re-applies this `core_state` projection.
// 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()
.into_iter()
.map(|a| AccountRegistrationEntry {
account_type: a.account_type,
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),
..Default::default()
};

let wallet_state = ClientWalletStartState {
wallet,
wallet_info,
network,
birth_height: entry.birth_height,
account_manifest,
core_state,
identity_manager,
unused_asset_locks,
};
Expand Down
101 changes: 101 additions & 0 deletions packages/rs-platform-wallet-ffi/src/rehydration_seed_provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! `MnemonicResolverHandle` → `platform_wallet::SeedProvider` adapter.
//!
//! `load_from_persistor` now requires a runtime
//! [`SeedProvider`](platform_wallet::seed_provider::SeedProvider) to
//! re-derive each signing wallet. The iOS host already owns a
//! Swift-side mnemonic store reachable through the
//! [`MnemonicResolverHandle`] vtable (same mechanism used by the
//! on-demand signer in `sign_with_mnemonic_resolver.rs`), so wrapping
//! that resolver is the minimal correct seed source — no second
//! secret-plumbing path, no mnemonic round-tripping through Swift.
//!
//! The resolver yields a BIP-39 mnemonic for a `wallet_id`; this
//! adapter hands it to the manager as
//! [`WalletSecret::Mnemonic`](platform_wallet::seed_provider::WalletSecret),
//! borrowing it only across the wrapper and zeroizing the transient
//! buffer on drop. No secret byte is logged or placed in an error.

use std::os::raw::{c_char, c_void};

use platform_wallet::seed_provider::{
SecretPhrase, SecretStoreErrorKind, SeedProvider, SeedUnavailable, WalletSecret,
};
use rs_sdk_ffi::{
mnemonic_resolver_result, MnemonicResolverHandle, MNEMONIC_RESOLVER_BUFFER_CAPACITY,
};
use zeroize::Zeroizing;

/// Wraps a Swift-owned [`MnemonicResolverHandle`] as a
/// [`SeedProvider`]. Holds the raw handle pointer as a `usize` so the
/// adapter is `Send + Sync` — the Swift side promises both the vtable
/// and `ctx` are thread-stable for the resolver's lifetime (the same
/// contract the on-demand signer relies on), and the resolver must
/// outlive the `load_from_persistor` call it is passed into.
pub struct ResolverSeedProvider {
resolver_addr: usize,
}

impl ResolverSeedProvider {
/// # Safety
/// `resolver` must be a valid pointer produced by
/// `dash_sdk_mnemonic_resolver_create`, not yet destroyed, and it
/// must outlive every `seed_for` call (i.e. the whole
/// `load_from_persistor` invocation it is handed to).
pub unsafe fn new(resolver: *const MnemonicResolverHandle) -> Self {
Self {
resolver_addr: resolver as usize,
}
}
}

impl SeedProvider for ResolverSeedProvider {
fn seed_for(&self, wallet_id: [u8; 32]) -> Result<WalletSecret, SeedUnavailable> {
if self.resolver_addr == 0 {
return Err(SeedUnavailable::StoreUnavailable(
SecretStoreErrorKind::BackendUnavailable,
));
}

let mut buf: Zeroizing<[u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]> =
Zeroizing::new([0u8; MNEMONIC_RESOLVER_BUFFER_CAPACITY]);
let mut out_len: usize = 0;

// SAFETY: `resolver_addr` was a valid `*const
// MnemonicResolverHandle` at construction; the caller's
// unsafety contract guarantees it (and its thread-stable vtable
// + ctx) outlive this call.
let resolver = unsafe { &*(self.resolver_addr as *const MnemonicResolverHandle) };
let vtable = unsafe { &*resolver.vtable };
let rc = unsafe {
(vtable.resolve)(
resolver.ctx as *const c_void,
wallet_id.as_ptr(),
buf.as_mut_ptr() as *mut c_char,
MNEMONIC_RESOLVER_BUFFER_CAPACITY,
&mut out_len,
)
};
match rc {
x if x == mnemonic_resolver_result::SUCCESS => {}
x if x == mnemonic_resolver_result::NOT_FOUND => {
return Err(SeedUnavailable::Absent);
}
x if x == mnemonic_resolver_result::BUFFER_TOO_SMALL => {
return Err(SeedUnavailable::StoreError(SecretStoreErrorKind::Other));
}
_ => {
return Err(SeedUnavailable::StoreError(SecretStoreErrorKind::Other));
}
}
if out_len == 0 || out_len > MNEMONIC_RESOLVER_BUFFER_CAPACITY {
return Err(SeedUnavailable::StoreError(SecretStoreErrorKind::Other));
}

// The phrase is copied once into the zeroize-on-drop
// `SecretPhrase`; `buf` is wiped on drop. No secret reaches a
// log line or an error payload.
let phrase = std::str::from_utf8(&buf[..out_len])
.map_err(|_| SeedUnavailable::StoreError(SecretStoreErrorKind::Other))?;
Ok(WalletSecret::Mnemonic(SecretPhrase::new(phrase)))
}
}
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-storage/src/secrets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
mod file;
mod keyring;
mod secret;
mod seed_provider_adapter;
mod validate;

#[cfg(any(test, feature = "__secrets-test-helpers"))]
Expand All @@ -54,6 +55,7 @@ pub use file::error_bridge::{downcast_failure, FileStoreFailure};
pub use file::{EncryptedFileCredential, EncryptedFileStore, SERVICE_PREFIX};
pub use keyring::default_credential_store;
pub use secret::{SecretBytes, SecretString};
pub use seed_provider_adapter::CredentialStoreSeedProvider;
pub use validate::WalletId;

#[cfg(any(test, feature = "__secrets-test-helpers"))]
Expand Down
Loading
Loading