Skip to content
Closed
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
83f7d4f
feat(platform-wallet): seedless watch-only rehydration via load_from_…
lklimek Jun 29, 2026
35bd291
refactor(platform-wallet): drop backward-compat on_platform_event; co…
lklimek Jun 29, 2026
a5b7032
docs(platform-wallet): point seedless-load notes at the resolver wron…
lklimek Jun 29, 2026
22ff11c
perf(platform-wallet): O(n) UTXO spent-filter on rehydrate via outpoi…
lklimek Jun 29, 2026
2e71027
fix(platform-wallet)!: restore persisted address-pool used-state on r…
lklimek Jun 29, 2026
8832f6f
fix(platform-wallet): idempotent repeat restore — skip already-presen…
lklimek Jun 29, 2026
2c6e3e8
fix(platform-wallet-ffi): skip corrupt wallet on load instead of abor…
lklimek Jun 29, 2026
3876d0e
refactor(platform-wallet): mark unreleased #3692 load enums non_exhau…
lklimek Jun 29, 2026
1f8d6b6
test(platform-wallet): cover used-state-survives-spent-UTXO + deep-in…
lklimek Jun 29, 2026
f60160e
test(platform-wallet): drop duplicate deep-index rehydration test (#3…
lklimek Jun 29, 2026
8d88d49
refactor(platform-wallet): drop vestigial PlatformEvent enum (#3692 r…
lklimek Jun 29, 2026
b304268
fix(platform-wallet): restore background-sync generation guard droppe…
lklimek Jun 30, 2026
664c6bf
fix(platform-wallet): count skipped rows in ClientStartState::is_empt…
lklimek Jun 30, 2026
61c9fd7
fix(platform-wallet-ffi): surface MalformedXpub skip code for bad acc…
lklimek Jun 30, 2026
2ab0bed
Merge v4.1-dev into feat/platform-wallet-rehydration
lklimek Jul 1, 2026
ef7cdf2
chore(deps): bump rust-dashcore to dev head 78d10022
lklimek Jul 1, 2026
27d235f
fix(dpp): implement extended_public_key on shielded FixedKeySigner te…
lklimek Jul 1, 2026
36e2fed
chore(deps): bump rust-dashcore to PR #833 (Zeroize for ExtendedPrivKey)
lklimek Jul 1, 2026
3be6885
refactor(rs-sdk-ffi): wrap ExtendedPrivKey in Zeroizing, drop manual …
lklimek Jul 1, 2026
b175d50
refactor(rs-sdk-ffi): confine ExtendedPrivKey to resolve_and_derive v…
lklimek Jul 1, 2026
5a2b2b5
test(rs-sdk-ffi): make extended_public_key metadata asserts load-bear…
lklimek Jul 1, 2026
56612fd
chore(deps): bump rust-dashcore to a8c57fe (drop Copy, zeroize Extend…
lklimek Jul 1, 2026
374df98
refactor(rs-sdk-ffi): rely on ExtendedPrivKey Drop, drop redundant Ze…
lklimek Jul 1, 2026
35e5c46
Merge remote-tracking branch 'origin/v4.1-dev' into chore/bump-rust-d…
lklimek Jul 2, 2026
1aac44a
fix(deps): stage regenerated Cargo.lock for rust-dashcore a8a0968 bump
lklimek Jul 2, 2026
defa249
build: sync Cargo.lock to rust-dashcore a8a0968 (0.45.0)
lklimek Jul 2, 2026
f669691
docs(rs-sdk-ffi): correct ExtendedPrivKey zeroization comments for a8…
lklimek Jul 2, 2026
43b57af
Merge commit 'f669691f7a22f50f4fab7a056036a53ebf781b9b' into chore/bu…
lklimek Jul 2, 2026
5dfd441
fix(deps): bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185)
lklimek Jul 2, 2026
0feb7d6
docs(platform-wallet): fix dead doc-links and stale references in reh…
lklimek Jul 2, 2026
59dc04d
fix(platform-wallet-ffi): harden load-outcome FFI surface (findings #…
lklimek Jul 2, 2026
cd6807a
docs(platform-wallet): fix dead MnemonicResolverHandle doc-link in lo…
lklimek Jul 2, 2026
a9d3a76
refactor(platform-wallet-ffi): typed corrupt-kind discriminator + hon…
lklimek Jul 2, 2026
284a3aa
fix(platform-wallet): harden watch-only rehydration guards + document…
lklimek Jul 2, 2026
ee717c8
Merge branch 'fix/3692-b' into fix/3692-consolidated
lklimek Jul 2, 2026
037c7d2
Merge branch 'fix/3692-c' into fix/3692-consolidated
lklimek Jul 2, 2026
f73e40a
Merge branch 'fix/3692-d' into fix/3692-consolidated
lklimek Jul 2, 2026
127c565
Merge branch 'fix/3692-e' into fix/3692-consolidated
lklimek Jul 2, 2026
0b53866
docs(platform-wallet): document manifest-authentication trust boundar…
lklimek Jul 2, 2026
c6d58f0
Merge branch 'v4.1-dev' into feat/platform-wallet-rehydration
lklimek Jul 2, 2026
3a791c2
merge: #3976 Signer::extended_public_key (rs-sdk-ffi) into #3692
lklimek Jul 2, 2026
9dbca2f
fix(platform-wallet): update CoinJoin pool-topology test for key-wall…
lklimek Jul 2, 2026
7a2e06e
fix(platform-wallet): preserve persisted wallet state through seedles…
QuantumExplorer Jul 2, 2026
5460c0e
fix(platform-wallet): snapshot identity cross-check + typed persister…
Claudius-Maginificent Jul 2, 2026
8bcb192
Apply suggestions from code review
lklimek Jul 3, 2026
87f4b42
refactor(platform-wallet)!: require core_wallet_info, drop keyless-pr…
lklimek Jul 3, 2026
f36e191
refactor(platform-wallet): drop obsolete non-default-account warn com…
lklimek Jul 3, 2026
ed5fb78
feat(platform-wallet)!: 3-state LoadOutcome, report already-registere…
lklimek Jul 3, 2026
f8504f6
docs(platform-wallet): reframe manifest trust boundary, detail the at…
lklimek Jul 3, 2026
157410a
fix(platform-wallet-ffi): xpub-error classification, overflow guard, …
lklimek Jul 3, 2026
f4ab467
test(platform-wallet): rollback, empty-first-run, and concurrency reg…
lklimek Jul 3, 2026
60d48f6
merge: #3692 triage fix batch (7 waves) into feat/platform-wallet-reh…
lklimek Jul 3, 2026
14d83af
Merge remote-tracking branch 'origin/v4.1-dev' into feat/platform-wal…
lklimek Jul 3, 2026
e65c123
test(platform-wallet-ffi): cover slice_from_raw guard and end-to-end …
lklimek Jul 3, 2026
518c8d1
refactor(platform-wallet): drop latent LoadIncomplete conversion and …
lklimek Jul 3, 2026
3abc385
refactor(platform-wallet): make manager::rehydrate module private
lklimek Jul 3, 2026
f8c9722
merge: QA follow-up fixes for #3692 (test coverage, dead-code cleanup…
lklimek Jul 3, 2026
83d9eff
fix(dpp): drop stale extended_public_key impls broken by rust-dashcor…
lklimek Jul 3, 2026
df6955e
merge: reconcile #3692 with v4.1-dev; preserve ignored/unignored on r…
lklimek Jul 6, 2026
0af850a
refactor(platform-wallet): drop dead identity_keys/contacts from Clie…
lklimek Jul 6, 2026
19570ad
Merge remote-tracking branch 'origin/v4.1-dev' into claudius/pr3692-f…
lklimek Jul 6, 2026
29a3853
docs(platform-wallet): correct apply_contacts_and_keys + FFI build co…
lklimek Jul 6, 2026
d6b789e
docs(platform-wallet-ffi): mark missing end-to-end build_wallet_ident…
lklimek Jul 6, 2026
adec4b7
docs(platform-wallet-ffi): enrich build_wallet_identity_bucket TODO w…
lklimek Jul 6, 2026
a814932
fix(platform-wallet): fund asset-lock test fixtures via confirmed UTX…
lklimek Jul 6, 2026
6436891
Merge commit 'a814932d97' into claudius/pr3692-field-cleanup
lklimek Jul 6, 2026
d7c6846
Merge branch 'v4.1-dev' into feat/platform-wallet-rehydration
lklimek Jul 6, 2026
cec6d3b
Merge remote-tracking branch 'origin/v4.1-dev' into feat/platform-wal…
lklimek Jul 7, 2026
e9e98cc
feat(platform-wallet): deliver typed AddressNonceMismatch error to ca…
lklimek Jul 7, 2026
650c32a
fix(platform-wallet-ffi): deliver AddressNonceMismatch across FFI + r…
lklimek Jul 7, 2026
069ee6a
fix(swift-sdk): mirror ErrorAddressNonceMismatch (=21); pin FFI nonce…
lklimek Jul 7, 2026
6103ab6
Merge branch 'v4.1-dev' into feat/platform-wallet-rehydration
lklimek Jul 7, 2026
062caea
chore(deps): bump rust-dashcore pin to key-wallet asset-lock override…
lklimek Jul 7, 2026
a985d23
chore(deps): switch rust-dashcore pin from PR #850 to PR #851
lklimek Jul 8, 2026
b70062b
test(swift-sdk): replace removed sendToAddresses with builder+broadcast
lklimek Jul 8, 2026
dc77493
fix(swift-sdk): stop funding core-to-core sends from a Platform-Payme…
lklimek Jul 8, 2026
eb3c7ae
fix(platform-wallet-ffi): surface persister-load retry class across t…
lklimek Jul 8, 2026
ccc95e3
Merge remote-tracking branch 'origin/v4.1-dev' into feat/platform-wal…
lklimek Jul 8, 2026
c8c6a93
refactor(platform-wallet): apply PR #3692 self-review notes
lklimek Jul 8, 2026
f8ee551
Merge branch 'v4.1-dev' into feat/platform-wallet-rehydration
lklimek Jul 8, 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
6 changes: 4 additions & 2 deletions .cargo/audit.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[advisories]
Comment thread
Claudius-Maginificent marked this conversation as resolved.
# TODO Remove it from here
ignore = [ "RUSTSEC-2020-0071"] # advisory IDs to ignore e.g. ["RUSTSEC-2019-0001", ...]
# Advisory IDs to ignore, e.g. ["RUSTSEC-2019-0001", ...]. Each entry
# must point at a live advisory in the resolved graph and carry a dated
# rationale; an entry matching nothing trains reviewers to skim the list.
ignore = []
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,6 @@ __pycache__/

# Security audit reports (local-only, not committed)
audits/

# Review scratch (grumpy-review / triage output, local-only)
.review-*/
11 changes: 10 additions & 1 deletion packages/rs-dpp/src/state_transition/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3335,7 +3335,7 @@ mod tests {
use dashcore::secp256k1::{
ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey,
};
use key_wallet::bip32::DerivationPath;
use key_wallet::bip32::{DerivationPath, ExtendedPubKey};
use key_wallet::signer::{Signer as KwSigner, SignerMethod};

/// Fixed-key in-memory signer used only by this test. Mirrors how a
Expand Down Expand Up @@ -3370,6 +3370,15 @@ mod tests {
async fn public_key(&self, _path: &DerivationPath) -> Result<PublicKey, Self::Error> {
Ok(self.public)
}

async fn extended_public_key(
Comment thread
lklimek marked this conversation as resolved.
&self,
_path: &DerivationPath,
) -> Result<ExtendedPubKey, Self::Error> {
// Test stub holds a single raw key with no chain code; extended
// public key derivation is not meaningful here.
Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string())
}
}

// Generate a single random key. Using the same key on both sides is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs()
async fn try_from_asset_lock_with_signers_produces_matching_signature() {
use async_trait::async_trait;
use dashcore::secp256k1::{ecdsa, Message};
use key_wallet::bip32::DerivationPath;
use key_wallet::bip32::{DerivationPath, ExtendedPubKey};
use key_wallet::signer::{Signer as KwSigner, SignerMethod};

/// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the
Expand Down Expand Up @@ -237,6 +237,15 @@ async fn try_from_asset_lock_with_signers_produces_matching_signature() {
async fn public_key(&self, _path: &DerivationPath) -> Result<RawPublicKey, Self::Error> {
Ok(self.public)
}

async fn extended_public_key(
Comment thread
lklimek marked this conversation as resolved.
&self,
_path: &DerivationPath,
) -> Result<ExtendedPubKey, Self::Error> {
// Test stub holds a single raw key with no chain code; extended
// public key derivation is not meaningful here.
Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string())
}
}

let secp = Secp256k1::new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ use platform_version::version::PlatformVersion;

use async_trait::async_trait;
use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey};
use key_wallet::bip32::DerivationPath;
use key_wallet::bip32::{DerivationPath, ExtendedPubKey};
use key_wallet::signer::{Signer as KwSigner, SignerMethod};

/// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a
Expand Down Expand Up @@ -77,6 +77,15 @@ impl KwSigner for FixedKeySigner {
async fn public_key(&self, _path: &DerivationPath) -> Result<PublicKey, Self::Error> {
Ok(self.public)
}

async fn extended_public_key(
&self,
_path: &DerivationPath,
) -> Result<ExtendedPubKey, Self::Error> {
// Test stub holds a single raw key with no chain code; extended
// public key derivation is not meaningful here.
Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string())
}
}

fn make_chain_asset_lock_proof() -> AssetLockProof {
Expand Down
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
250 changes: 244 additions & 6 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,93 @@ unsafe fn create_wallet_from_mnemonic_impl(
PlatformWalletFFIResult::ok()
}

/// `reason_code`: the persisted row had no usable account manifest to
/// rebuild the account collection from.
pub const LOAD_SKIP_REASON_MISSING_MANIFEST: u32 = 100;
/// `reason_code`: a manifest `account_xpub` failed to parse as a
/// well-formed extended public key.
pub const LOAD_SKIP_REASON_MALFORMED_XPUB: u32 = 101;
/// `reason_code`: any other structural decode / projection failure on
/// the persisted row.
pub const LOAD_SKIP_REASON_DECODE_ERROR: u32 = 102;
/// `reason_code`: the carried managed-info snapshot does not describe its
/// persisted row (wallet_id/network differ, or its account set diverges
/// from the row's account manifest) — a wrong-row snapshot.
pub const LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH: u32 = 103;
/// `reason_code`: an unrecognized `CorruptKind` — forward-compat
/// fallback until this crate maps a newly added corrupt-row family.
pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199;
/// `reason_code`: an unrecognized `SkipReason` — forward-compat
/// fallback until this crate maps a newly added skip reason.
pub const LOAD_SKIP_REASON_OTHER: u32 = 200;
/// `reason_code`: the wallet was already registered before this load
/// pass reached it (a prior load, or a runtime-created wallet), so its
/// persisted row was not freshly loaded. Not corruption.
pub const LOAD_SKIP_REASON_ALREADY_REGISTERED: u32 = 300;

/// One wallet skipped during `load_from_persistor` because its
/// persisted row was structurally corrupt (per-row decode failure).
/// The load path is seedless and watch-only, so this is the only skip
/// reason. `reason_code` is per-`CorruptKind` family — see its table.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct SkippedWalletFFI {
/// The (public) 32-byte wallet id that was skipped.
pub wallet_id: [u8; 32],
/// Skip reason — one of the `LOAD_SKIP_REASON_*` constants:
/// [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100),
/// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101),
/// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102),
/// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103),
/// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199),
/// [`LOAD_SKIP_REASON_OTHER`] (200), or
/// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). 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.
///
/// The count pair encodes the Rust `LoadOutcome` 3-state: `skipped_count
/// == 0` is a full load, `loaded_count == 0` with skips is
/// nothing-usable, and both non-zero is a partial load.
///
/// `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 {
use platform_wallet::manager::load_outcome::CorruptKind;
match reason {
platform_wallet::SkipReason::CorruptPersistedRow { kind } => match kind {
CorruptKind::MissingManifest => LOAD_SKIP_REASON_MISSING_MANIFEST,
CorruptKind::MalformedXpub => LOAD_SKIP_REASON_MALFORMED_XPUB,
CorruptKind::SnapshotIdentityMismatch => LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH,
CorruptKind::DecodeError(_) => LOAD_SKIP_REASON_DECODE_ERROR,
// `CorruptKind` is #[non_exhaustive]; a future variant maps to a
// generic corrupt-row code until this mapping is extended.
_ => LOAD_SKIP_REASON_CORRUPT_OTHER,
},
platform_wallet::SkipReason::AlreadyRegistered => LOAD_SKIP_REASON_ALREADY_REGISTERED,
// `SkipReason` is #[non_exhaustive]; a future reason maps to a
// generic skip code until this mapping is extended.
_ => LOAD_SKIP_REASON_OTHER,
}
}

/// 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 +383,115 @@ 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 registers
/// each one as a **watch-only** wallet. No signing keys are derived
/// here — signing happens later, on demand, via the configured
/// `MnemonicResolverHandle` (`sign_with_mnemonic_resolver` and its
/// siblings), which fail-closed gate the resolver-supplied seed
/// against the loaded `wallet_id`. Does not produce wallet handles —
/// follow up with [`platform_wallet_manager_get_wallet`] per
/// `wallet_id`.
///
/// A wallet whose persisted row is structurally corrupt is
/// **skipped**, not failed: the call still returns `Success`, every
/// skipped `(wallet_id, reason)` is logged, and — when `out_outcome`
/// is non-null — surfaced through it.
///
/// # Safety
/// - `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,
out_outcome: *mut LoadOutcomeFFI,
) -> PlatformWalletFFIResult {
// Initialize the out-param first so every early-return path below
// leaves it releasable (zeroed counts, null `skipped`) — matches this
// crate's null-init-first out-pointer idiom and keeps
// `platform_wallet_load_outcome_free` safe on the error paths too.
if !out_outcome.is_null() {
std::ptr::write(
out_outcome,
LoadOutcomeFFI {
loaded_count: 0,
skipped_count: 0,
skipped: std::ptr::null_mut(),
},
);
}

let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| {
runtime().block_on(manager.load_from_persistor())
});
let result = unwrap_option_or_return!(option);
unwrap_result_or_return!(result);
let outcome = unwrap_result_or_return!(result);
Comment thread
Claudius-Maginificent marked this conversation as resolved.

// Never silently drop the outcome: log a structured summary plus
// one line per skipped wallet (the host can inspect / clear the
// corrupt rows). The `loaded_count`/`skipped_count` pair below
// encodes the Rust `LoadOutcome` 3-state for the host: skipped == 0
// is a full load, loaded == 0 with skips is nothing-usable, and both
// non-zero is a partial load.
tracing::info!(
loaded = outcome.loaded().len(),
skipped = outcome.skipped().len(),
"platform_wallet_manager_load_from_persistor complete"
Comment thread
Claudius-Maginificent marked this conversation as resolved.
);
for (wid, reason) in outcome.skipped() {
tracing::warn!(
wallet_id = %hex::encode(wid),
reason = %reason,
"load_from_persistor skipped a persisted wallet"
);
}

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 = crate::core_wallet_types::vec_to_ptr(skipped_vec);
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 Expand Up @@ -415,4 +594,63 @@ mod tests {
assert_eq!(birth_height_override_opt(false, 0), None);
assert_eq!(birth_height_override_opt(false, 99), None);
}

#[test]
fn load_skip_reason_wire_values_are_stable() {
// FFI consumers hardcode these numbers; the ABI must not drift.
assert_eq!(LOAD_SKIP_REASON_MISSING_MANIFEST, 100);
assert_eq!(LOAD_SKIP_REASON_MALFORMED_XPUB, 101);
assert_eq!(LOAD_SKIP_REASON_DECODE_ERROR, 102);
assert_eq!(LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, 103);
assert_eq!(LOAD_SKIP_REASON_CORRUPT_OTHER, 199);
assert_eq!(LOAD_SKIP_REASON_OTHER, 200);
}

#[test]
fn skip_reason_code_maps_known_kinds_to_constants() {
use platform_wallet::manager::load_outcome::CorruptKind;
use platform_wallet::SkipReason;

let corrupt = |kind| SkipReason::CorruptPersistedRow { kind };
assert_eq!(
skip_reason_code(&corrupt(CorruptKind::MissingManifest)),
LOAD_SKIP_REASON_MISSING_MANIFEST
);
assert_eq!(
skip_reason_code(&corrupt(CorruptKind::MalformedXpub)),
LOAD_SKIP_REASON_MALFORMED_XPUB
);
assert_eq!(
skip_reason_code(&corrupt(CorruptKind::SnapshotIdentityMismatch)),
LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH
);
assert_eq!(
skip_reason_code(&corrupt(CorruptKind::DecodeError("boom".into()))),
LOAD_SKIP_REASON_DECODE_ERROR
);
}

#[test]
fn load_from_persistor_initializes_out_param_on_early_return() {
// An unknown handle early-returns before the success block. The
// out-param must be reset to a releasable zeroed state so a caller
// that later calls `platform_wallet_load_outcome_free` never does
// `Box::from_raw` on the uninitialized `skipped` pointer.
let mut outcome = LoadOutcomeFFI {
loaded_count: 42,
skipped_count: 7,
skipped: std::ptr::NonNull::<SkippedWalletFFI>::dangling().as_ptr(),
};

let result =
unsafe { platform_wallet_manager_load_from_persistor(NULL_HANDLE, &mut outcome) };

assert_ne!(result.code, PlatformWalletFFIResultCode::Success);
assert_eq!(outcome.loaded_count, 0);
assert_eq!(outcome.skipped_count, 0);
assert!(outcome.skipped.is_null());

// Null `skipped` now makes the release path a safe no-op.
unsafe { platform_wallet_load_outcome_free(&mut outcome) };
}
}
Loading
Loading