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
276 changes: 276 additions & 0 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,80 @@ fn map_spend_result(
}
}

/// 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
/// [`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.
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())
),
),
}
}

/// Post-panic guidance for the note-spending exports. Paired with
/// `ErrorShieldedSpendUnconfirmed` in [`catch_spend_panic`].
const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast — do NOT \
retry; the next shielded sync reconciles the outcome.";

/// [`catch_panic_to_code`] specialized for the note-spending exports.
///
/// The panic is mapped to [`PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed`], NOT to
/// a definitive failure code: a panic can strike after the notes were reserved and even after the
/// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is
/// exactly the conservative one this needs — the host must not auto-retry, the reservation stays
/// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed.
fn catch_spend_panic(
operation: &str,
body: impl FnOnce() -> PlatformWalletFFIResult,
) -> PlatformWalletFFIResult {
catch_panic_to_code(
operation,
PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed,
SPEND_PANIC_GUIDANCE,
body,
)
}

/// Preserve the typed "already consumed" funding report across the FFI
/// boundary while keeping every other funding failure on the existing generic
/// error path. The wallet retains nonterminal consumption-unknown state; the
Expand Down Expand Up @@ -978,6 +1052,158 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
map_spend_result(result, "shielded shield")
}

/// Shield: spend credits from a Platform Payment account into a
/// THIRD-PARTY shielded pool — the Type 15 shield with the note
/// assigned to `recipient_raw_43` (the recipient's raw 43-byte
/// Orchard payment address, same shape
/// `platform_wallet_manager_shielded_transfer` takes) instead of the
/// wallet's own default address.
///
/// Input selection, fees, and error shapes are identical to
/// [`platform_wallet_manager_shielded_shield`]; the wallet still needs
/// a bound shielded sub-wallet at `shielded_account` because the send
/// is OVK-encrypted to (and its activity recorded under) that account.
///
/// The recipient must actually be a third party: an address the
/// account's own IVK recognizes (default or any diversified index) is
/// rejected with a wallet-operation error — self-shields go through
/// [`platform_wallet_manager_shielded_shield`].
///
/// `memo_text` is an optional NUL-terminated UTF-8 string attached to
/// the recipient's note — same rules as
/// `platform_wallet_manager_shielded_transfer`: `null` or empty means
/// no memo; a non-empty memo's UTF-8 byte length must be ≤ 32.
///
/// `signer_address_handle` is a `*mut SignerHandle` produced by
/// `dash_sdk_signer_create_with_ctx` (typically Swift's
/// `KeychainSigner.handle`). The caller retains ownership; this
/// function does not destroy the handle.
///
/// # Safety
/// - `wallet_id_bytes` must point to 32 readable bytes.
/// - `recipient_raw_43` must point to 43 readable bytes.
/// - `memo_text`, when non-null, must be a valid NUL-terminated UTF-8
/// C string for the duration of the call.
/// - `signer_address_handle` must be a valid, non-destroyed
/// `*const SignerHandle` that outlives this call and points at a
/// `VTableSigner` with the callback variant (the native variant
/// doesn't satisfy `Signer<PlatformAddress>`).
#[no_mangle]
pub unsafe extern "C" fn platform_wallet_manager_shielded_shield_to_recipient(
handle: Handle,
wallet_id_bytes: *const u8,
shielded_account: u32,
payment_account: u32,
recipient_raw_43: *const u8,
amount: u64,
memo_text: *const c_char,
signer_address_handle: *const SignerHandle,
) -> PlatformWalletFFIResult {
// The whole body runs under `catch_unwind`: a panic (most concretely `block_on_worker`'s
// `.expect` on a panicking proving task) must NOT reach this `extern "C"` frame, where it
// would abort the process instead of surfacing to the host as a typed error. A shield
// reserves no notes, but the transition may already have been broadcast when the panic
// struck, so the same ambiguous spend-unconfirmed contract applies (matching
// `map_spend_result`'s mapping for this operation); a later manual retry self-heals through
// the address-nonce check.
catch_spend_panic("shielded shield to recipient", || {
shielded_shield_to_recipient_inner(
handle,
wallet_id_bytes,
shielded_account,
payment_account,
recipient_raw_43,
amount,
memo_text,
signer_address_handle,
)
})
}

/// Body of [`platform_wallet_manager_shielded_shield_to_recipient`], as an ordinary Rust
/// function so a panic unwinds into [`catch_spend_panic`] instead of across the C ABI.
///
/// # Safety
/// Identical contract to the export that calls it.
#[allow(clippy::too_many_arguments)]
unsafe fn shielded_shield_to_recipient_inner(
handle: Handle,
wallet_id_bytes: *const u8,
shielded_account: u32,
payment_account: u32,
recipient_raw_43: *const u8,
amount: u64,
memo_text: *const c_char,
signer_address_handle: *const SignerHandle,
) -> PlatformWalletFFIResult {
check_ptr!(wallet_id_bytes);
check_ptr!(recipient_raw_43);
check_ptr!(signer_address_handle);

let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);
let mut recipient = [0u8; 43];
std::ptr::copy_nonoverlapping(recipient_raw_43, recipient.as_mut_ptr(), 43);

// Decode the optional memo string before resolving the wallet so a
// malformed memo fails fast without touching wallet state.
let memo_str = if memo_text.is_null() {
None
} else {
match CStr::from_ptr(memo_text).to_str() {
Ok(s) => Some(s),
Err(e) => {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorUtf8Conversion,
format!("memo_text is not valid UTF-8: {e}"),
);
}
}
};
let memo = match encode_memo_text(memo_str) {
Ok(m) => m,
Err(result) => return result,
};

// Shield writes its live activity entry to the coordinator's shared
// in-memory store, so resolve the coordinator alongside the wallet
// (same resolver the transfer / unshield / withdraw spends use).
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};

// Signer pointer round-trip through `usize` — same rationale as
// `platform_wallet_manager_shielded_shield`.
let signer_addr = signer_address_handle as usize;

// Run the proof on a worker thread (8 MB stack). Halo 2 circuit
// synthesis recurses past the ~512 KB iOS dispatch-thread stack
// and crashes with EXC_BAD_ACCESS at the first
// `synthesize(... measure(pass))` call when polled on the
// calling thread.
let result = block_on_worker(async move {
// SAFETY: re-materialize the borrow under the caller's
// documented lifetime contract; valid for the duration of
// this synchronously-awaited task.
let address_signer: &VTableSigner = &*(signer_addr as *const VTableSigner);
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account_to_recipient(
&coordinator,
shielded_account,
payment_account,
&recipient,
amount,
memo,
address_signer,
&prover,
)
.await
});
map_spend_result(result, "shielded shield to recipient")
}

/// Fund the shielded pool from a Core L1 asset lock, orchestrated
/// through the wallet's `AssetLockManager` (build → IS-or-CL →
/// submit → consume). The asset-lock-proof signature is produced
Expand Down Expand Up @@ -1718,6 +1944,56 @@ mod tests {
.into_owned()
}

/// A non-panicking body passes its result straight through — the guard must be invisible on
/// the happy path.
#[test]
fn catch_spend_panic_passes_results_through() {
let ok = catch_spend_panic("test", PlatformWalletFFIResult::ok);
assert_eq!(ok.code, PlatformWalletFFIResultCode::Success);

let err = catch_spend_panic("test", || {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidParameter,
"bad input",
)
});
assert_eq!(err.code, PlatformWalletFFIResultCode::ErrorInvalidParameter);
assert_eq!(message_of(&err), "bad input");
}

/// A panic inside a shielded-spend export must NOT unwind into the `extern "C"` frame (that
/// aborts the process). It becomes `ErrorShieldedSpendUnconfirmed` — the conservative
/// "may have been broadcast, do NOT retry" contract, because a panic can strike after the
/// transition is submitted.
///
/// The panic hook is deliberately left alone: it is process-global, `cargo test` runs tests
/// concurrently, and `take_hook` + restore from two tests can interleave so that one restores
/// the other's temporary hook last — suppressing panic diagnostics for the rest of the
/// process, including unrelated concurrent panics. The libtest harness already captures this
/// test's output, so the deliberate panic's backtrace does not reach the console anyway.
#[test]
fn catch_spend_panic_maps_a_panic_to_the_unconfirmed_contract() {
let result = catch_spend_panic("shielded shield to recipient", || {
panic!("tokio worker panicked");
});

assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed,
"a panic must map to the ambiguous, do-not-retry code"
);
let message = message_of(&result);
assert!(
message.contains("shielded shield to recipient panicked")
&& message.contains("tokio worker panicked"),
"the panic payload must survive into the FFI message: {message}"
);
assert!(
message.contains("do NOT retry"),
"the message must carry the do-not-retry guidance: {message}"
);
}

/// `map_spend_result` pins the retry-relevant code split the three spend
/// entry points depend on:
/// - `ShieldedSpendUnconfirmed` → `ErrorShieldedSpendUnconfirmed` (host
Expand Down
Loading
Loading