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
e43aab1
feat(platform-wallet): own the TXO-store reconcile policy in the wallet
HashEngineering Nov 5, 2026
63a0398
feat(platform-wallet-ffi): expose the TXO reconcile policy over the C…
HashEngineering Nov 5, 2026
51bc0bb
refactor(swift-sdk): ask the Rust wallet for every TXO reconcile deci…
HashEngineering Nov 5, 2026
498edde
fix(swift-sdk): heal a coin only when its address is saved under its …
HashEngineering Nov 5, 2026
dfd5590
refactor(platform-wallet): simplify the TXO reconcile surface
HashEngineering Nov 5, 2026
8a8a116
test(swift-sdk): file heal fixtures' addresses under their accounts
HashEngineering Nov 6, 2026
e8141d6
fix(swift-sdk): stop a heal step when its parent or pending-input rea…
HashEngineering Nov 6, 2026
8461945
fix(platform-wallet): keep reconcile page guards until SPV has stopped
HashEngineering Nov 6, 2026
7a9f8da
fix(platform-wallet): keep only chain-locked records' inputs as final…
HashEngineering Nov 8, 2026
bc0582b
fix(platform-wallet): keep every wallet due across a new steady state…
HashEngineering Nov 8, 2026
f6849b4
fix(swift-sdk): refuse a second reconcile request for a wallet alread…
HashEngineering Nov 8, 2026
1aa83f1
fix(swift-sdk): read only the wallet's own unspent rows in the classi…
HashEngineering Nov 8, 2026
044ba88
fix(platform-wallet-ffi): report an invalid manager handle as such on…
HashEngineering Nov 8, 2026
c8294d9
perf(platform-wallet-ffi): borrow the host's store cursor instead of …
HashEngineering Nov 8, 2026
03c6fd3
docs(platform-wallet-ffi): say KNOWN_UNCREDITED needs a ChainLocked s…
HashEngineering Nov 8, 2026
728502e
fix(platform-wallet): forget a wallet's reconcile schedule while it s…
HashEngineering Nov 8, 2026
7d27936
style(platform-wallet): rustfmt the review fixes
HashEngineering Nov 8, 2026
640c75d
fix(platform-wallet): keep an unfinished or deferred reconcile cycle due
HashEngineering Nov 8, 2026
8c639f0
fix(platform-wallet): a begin refused for an unknown wallet keeps no …
HashEngineering Nov 9, 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
43 changes: 16 additions & 27 deletions packages/rs-platform-wallet-ffi/src/core_wallet_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2306,9 +2306,9 @@ mod tests {
/// `AccountBalanceEntryFFI` use, so a store that keys rows by account can
/// file a healed row under the right one.
///
/// Returned by `platform_wallet_wallet_utxos_page`; every row's `address`
/// and `script_pubkey` allocations belong to Rust and are released by
/// `platform_wallet_wallet_utxos_page_free`.
/// Returned in `platform_wallet_txo_reconcile_heal_page`'s rows; every
/// row's `address` and `script_pubkey` allocations belong to Rust and are
/// released by `platform_wallet_txo_reconcile_heal_page_free`.
#[repr(C)]
#[derive(Debug)]
pub struct WalletUtxoEntryFFI {
Expand All @@ -2334,23 +2334,8 @@ pub struct WalletUtxoEntryFFI {
pub is_locked: bool,
}

/// Cursor for `platform_wallet_wallet_utxos_page`: the owning account (the
/// raw tag layout of `AccountSpecFFI`, validated on the Rust side) and
/// outpoint of the LAST row of the previous page. Pass null to start.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct WalletUtxoCursorFFI {
pub type_tag: u8,
pub standard_tag: u8,
pub index: u32,
pub registration_index: u32,
pub key_class: u32,
pub user_identity_id: [u8; 32],
pub friend_identity_id: [u8; 32],
pub outpoint: OutPointFFI,
}

/// One store row handed to `platform_wallet_classify_outpoints`: the
/// One store row handed to
/// `platform_wallet_txo_reconcile_classify_outpoints`: the
/// account the store files the coin under (raw `AccountSpecFFI` tag
/// layout), the outpoint, and the script the store recorded for it. Every
/// pointer is valid for the duration of the call only.
Expand All @@ -2369,18 +2354,22 @@ pub struct OutpointOwnershipQueryFFI {
pub script_pubkey_len: usize,
}

/// `platform_wallet_classify_outpoints` answer: the engine has no opinion
/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the engine has no opinion
/// (a funding transaction this session never processed — after a restart
/// the finalized set is empty, so absence proves nothing).
pub const OUTPOINT_CLASS_UNKNOWN: u8 = 0;
/// `platform_wallet_classify_outpoints` answer: the coin is in a funds
/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the coin is in a funds
/// account's live UTXO set.
pub const OUTPOINT_CLASS_UNSPENT: u8 = 1;
/// `platform_wallet_classify_outpoints` answer: the owning account knows
/// the funding txid, owns the script, and does not hold the coin — the
/// engine skipped it for a spent reason or consumed it. The one class a
/// reconciler may act on.
/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the owning account knows
/// the funding txid, owns the script, and does not hold the coin, AND a
/// funds account holds a record whose transaction spends the outpoint in a
/// block the wallet's last applied ChainLock covers (or held one the
/// ChainLock already dropped), so no fork can give the coin back. A coin
/// whose spender is not final yet — mempool, InstantSend-locked, or in a
/// block no ChainLock covers — is [`OUTPOINT_CLASS_UNKNOWN`]. The one class
/// a reconciler may act on.
pub const OUTPOINT_CLASS_KNOWN_UNCREDITED: u8 = 2;
/// `platform_wallet_classify_outpoints` answer: the owning account's pools
/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the owning account's pools
/// do not monitor the script; the engine could never have credited it.
pub const OUTPOINT_CLASS_NOT_OWNED: u8 = 3;
50 changes: 50 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,56 @@ impl From<anyhow::Error> for PlatformWalletFFIResult {
}
}

/// Render a caught panic payload as a human-readable string.
fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}

/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the
/// operation's contract-appropriate result `code` (with `guidance` appended to the message)
/// instead of letting it reach the `extern "C"` frame.
///
/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer
/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but
/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the
/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it
/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis,
/// note bookkeeping, the SDK) re-panics right here inside the export.
///
/// The `code` must be chosen per operation to preserve that operation's result contract — a
/// panic can strike after side effects (note reservation, a broadcast) have happened, so the
/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See
/// `shielded_send::catch_spend_panic` and the per-export call sites.
///
/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and
/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles
/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size
/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the
/// process before this guard can see it.
pub(crate) fn catch_panic_to_code(
operation: &str,
code: PlatformWalletFFIResultCode,
guidance: &str,
body: impl FnOnce() -> PlatformWalletFFIResult,
) -> PlatformWalletFFIResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
Ok(result) => result,
Err(payload) => PlatformWalletFFIResult::err(
code,
format!(
"{operation} panicked: {}. {guidance}",
panic_payload_message(payload.as_ref())
),
),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ pub mod token_persistence;
pub mod tokens;
pub mod tracked_masternode;
pub mod tx_decode;
pub mod txo_reconcile;
pub mod types;
pub mod utils;
pub mod wallet;
Expand Down Expand Up @@ -170,6 +171,7 @@ pub use spv::*;
pub use token_persistence::*;
pub use tokens::*;
pub use tx_decode::*;
pub use txo_reconcile::*;
pub use types::*;
pub use utils::*;
pub use wallet::*;
Expand Down
48 changes: 39 additions & 9 deletions packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1583,7 +1583,11 @@ mod tests {
#[test]
fn classify_outpoints_answers_unknown_for_an_unmappable_account_tag() {
use crate::core_wallet_types::{OutPointFFI, OutpointOwnershipQueryFFI};
use crate::manager_diagnostics::platform_wallet_classify_outpoints;
use crate::txo_reconcile::{
platform_wallet_txo_reconcile_classify_outpoints,
platform_wallet_txo_reconcile_held_verdicts_free, TxoReconcileHeldVerdictsFFI,
};
use platform_wallet::manager::txo_reconcile::TxoReconcileFinish;
let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk");
let callbacks = persistence_callbacks();
let event_cbs = event_callbacks();
Expand Down Expand Up @@ -1617,26 +1621,52 @@ mod tests {
// An identity-key account tag, a mappable BIP44 tag, and a tag from
// the future, in one batch.
let queries = [query(15), query(0), query(200)];
// The classifier answers only a wallet that holds a run claim, and
// holds nothing otherwise; every slot still reads Unknown.
let mut out = [0xFFu8; 3];
let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut();
let result = unsafe {
platform_wallet_classify_outpoints(
platform_wallet_txo_reconcile_classify_outpoints(
handle,
wallet_id.as_ptr(),
queries.as_ptr(),
queries.len(),
out.as_mut_ptr(),
&mut false,
&mut held,
)
};
assert_eq!(
result.code,
PlatformWalletFFIResultCode::Success,
"an unmappable tag degrades that query to Unknown, it does not fail the batch"
);
assert_eq!(
out,
[0, 0, 0],
"every slot is answered, Unknown where nothing can be said"
PlatformWalletFFIResultCode::ErrorWalletOperation
);
assert_eq!(out, [0, 0, 0]);
assert!(held.is_null());

// With a run claim, an unmappable tag degrades that query to
// Unknown rather than failing the batch, every slot is answered and
// the guard comes back.
let reconciler = PLATFORM_WALLET_MANAGER_STORAGE
.with_item(handle, |m| m.txo_reconciler())
.expect("manager");
assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some());
let mut out = [0xFFu8; 3];
let result = unsafe {
platform_wallet_txo_reconcile_classify_outpoints(
handle,
wallet_id.as_ptr(),
queries.as_ptr(),
queries.len(),
out.as_mut_ptr(),
&mut false,
&mut held,
)
};
assert_eq!(result.code, PlatformWalletFFIResultCode::Success);
assert_eq!(out, [0, 0, 0]);
assert!(!held.is_null());
unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) };
reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun);
let destroyed = unsafe { platform_wallet_manager_destroy(handle) };
assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success);
}
Expand Down
Loading
Loading