Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
69aad4b
feat(platform): shielded transaction history
QuantumExplorer Jun 12, 2026
1fc46d9
fix: cover PersistentShieldedActivity in the storage explorer
QuantumExplorer Jun 12, 2026
00133c3
fix: address review on shielded activity (pending confirmation + reco…
QuantumExplorer Jun 12, 2026
a35bc9a
fix: partition and badge shielded activity rows by status, not height
QuantumExplorer Jun 12, 2026
97e190c
fix: address CodeRabbit review on shielded activity
QuantumExplorer Jun 12, 2026
ae31193
fix: write live activity entries to the in-memory store; overlap-base…
QuantumExplorer Jun 12, 2026
d64de16
docs: drop stale doc fragment above decode_cmx_array
QuantumExplorer Jun 12, 2026
60b7259
fix: stage Shield broadcast so ambiguous wait failures stay Pending
QuantumExplorer Jun 12, 2026
4196462
fix: address review on activity sorting, FFI marshalling, and lock scope
QuantumExplorer Jun 12, 2026
50b98ae
fix: per-batch note heights and stale-snapshot races in activity reco…
QuantumExplorer Jun 12, 2026
f1ce3f7
fix: preserve shield retry-safety code over FFI and purge activity ro…
QuantumExplorer Jun 12, 2026
ac2f2b4
docs: cover the shield path in map_spend_result's unconfirmed-arm com…
QuantumExplorer Jun 12, 2026
75406eb
fix: key activity rows by model identity, not entryId
QuantumExplorer Jun 12, 2026
433f099
docs: activity FFI rows are keyed by (wallet_id, account_index, entry…
QuantumExplorer Jun 12, 2026
b4479ff
docs: align activity-key docs with the (wallet_id, account_index, ent…
QuantumExplorer Jun 12, 2026
e3cddf8
fix: reject out-of-range activity tags on load and skip status flip o…
QuantumExplorer Jun 12, 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
372 changes: 370 additions & 2 deletions packages/rs-platform-wallet-ffi/src/persistence.rs

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions packages/rs-platform-wallet-ffi/src/shielded_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,78 @@ pub struct ShieldedSyncedIndexFFI {
pub last_synced_index: u64,
}

/// One derived shielded-activity entry for the host to persist.
///
/// Mirror of `platform_wallet::wallet::shielded::ShieldedActivityEntry`.
/// The host writes one row keyed by `(wallet_id, account_index,
/// entry_id)` — NOT `entry_id` alone: the same entry id (sha256 of the
/// visible output cmxs) legitimately appears under two accounts of one
/// wallet, e.g. an intra-wallet transfer producing a Sent row on the
/// sending account and a Received row on the receiving account.
/// Re-persisting the same tuple is an upsert that refines the row
/// (Pending→Confirmed/Failed, or a scan-derived `ShieldedSpend`
/// upgraded to a richer kind). All pointers are valid only for the
/// callback window — the host must copy.
///
/// `Option<T>` fields are flattened to a value + a `has_*` flag (`u8`,
/// 1 = present) rather than a sentinel, so `0`/empty is unambiguous.
#[repr(C)]
pub struct ShieldedActivityFFI {
/// 32-byte wallet identifier.
pub wallet_id: [u8; 32],
/// ZIP-32 account index.
pub account_index: u32,
/// Entry id (sha256 of sorted visible output cmxs). Unique only
/// within `(wallet_id, account_index)` — see the struct doc.
pub entry_id: [u8; 32],
/// Kind discriminant (see `ShieldedActivityKind::tag`):
/// 0 Shield, 1 ShieldFromAssetLock, 2 Received, 3 Sent, 4 Unshield,
/// 5 Withdrawal, 6 IdentityCreate, 7 ShieldedSpend.
pub kind_tag: u8,
/// Direction: 0 In, 1 Out, 2 Self.
pub direction: u8,
/// Status: 0 Pending, 1 Confirmed, 2 Failed.
pub status: u8,
/// Display amount in credits (principal; excludes self-change and
/// zero-value fillers).
pub amount: u64,
/// Exact fee in credits when `has_fee == 1`.
pub fee: u64,
/// `1` if `fee` is meaningful, `0` if the fee is unknown.
pub has_fee: u8,
/// Block height when `has_block_height == 1`.
pub block_height: u64,
/// `1` if `block_height` is meaningful (confirmed), `0` while pending.
pub has_block_height: u8,
/// Created-at time in ms since the Unix epoch (display-only;
/// `block_height` is the canonical sort key).
pub created_at_ms: u64,
/// Created identity id (only meaningful when `kind_tag == 6` /
/// IdentityCreate); all-zero and ignored otherwise.
pub identity_id: [u8; 32],
/// `1` when `identity_id` is meaningful (IdentityCreate), else `0`.
pub has_identity_id: u8,
/// Counterparty bytes pointer (43B Orchard / 21B PlatformAddress /
/// Core script) or null. Valid for the callback window only.
pub counterparty_ptr: *const u8,
/// Length of `counterparty_ptr` in bytes (0 when null).
pub counterparty_len: usize,
/// 36-byte memo pointer or null. Valid for the callback window only.
pub memo_ptr: *const u8,
/// Length of `memo_ptr` in bytes (0 when null).
pub memo_len: usize,
/// Pointer to the concatenated visible-output cmxs (`note_cmxs_count`
/// × 32 bytes). Valid for the callback window only.
pub note_cmxs_ptr: *const u8,
/// Number of 32-byte cmxs at `note_cmxs_ptr`.
pub note_cmxs_count: usize,
/// Pointer to the concatenated spent nullifiers (`spent_nullifiers_count`
/// × 32 bytes). Valid for the callback window only.
pub spent_nullifiers_ptr: *const u8,
/// Number of 32-byte nullifiers at `spent_nullifiers_ptr`.
pub spent_nullifiers_count: usize,
}

// ── Restore (load) ──────────────────────────────────────────────────────

/// One persisted note as the host hands it back at boot. Mirrors
Expand Down Expand Up @@ -146,6 +218,37 @@ pub struct ShieldedSubwalletSyncStateFFI {
pub last_synced_index: u64,
}

/// One persisted activity entry as the host hands it back at boot.
/// Mirrors [`ShieldedActivityFFI`] but lives in a Swift-allocated array,
/// so the buffer ownership / free contract differs (see the matching
/// `on_load_shielded_activity_free_fn`). Field semantics are identical
/// to [`ShieldedActivityFFI`].
#[repr(C)]
pub struct ShieldedActivityRestoreFFI {
pub wallet_id: [u8; 32],
pub account_index: u32,
pub entry_id: [u8; 32],
pub kind_tag: u8,
pub direction: u8,
pub status: u8,
pub amount: u64,
pub fee: u64,
pub has_fee: u8,
pub block_height: u64,
pub has_block_height: u8,
pub created_at_ms: u64,
pub identity_id: [u8; 32],
pub has_identity_id: u8,
pub counterparty_ptr: *const u8,
pub counterparty_len: usize,
pub memo_ptr: *const u8,
pub memo_len: usize,
pub note_cmxs_ptr: *const u8,
pub note_cmxs_count: usize,
pub spent_nullifiers_ptr: *const u8,
pub spent_nullifiers_count: usize,
}

// The `on_load_shielded_*_fn` callback types are inlined inside
// [`PersistenceCallbacks`] (rather than declared as `pub type`
// aliases here) so cbindgen sees the full signature, walks into
Expand Down
82 changes: 34 additions & 48 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,20 +426,22 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw(
map_spend_result(result, "shielded withdraw")
}

/// Map a shielded spend outcome (unshield / transfer / withdraw) to a typed
/// FFI result, mirroring the identity-create sibling's code split so hosts
/// can tell "definitively failed, safe to retry" from "may have executed,
/// do NOT retry".
/// Map a shielded operation outcome (shield / unshield / transfer /
/// withdraw) to a typed FFI result, mirroring the identity-create sibling's
/// code split so hosts can tell "definitively failed, safe to retry" from
/// "may have executed, do NOT retry".
fn map_spend_result(
result: Result<(), PlatformWalletError>,
operation: &str,
) -> PlatformWalletFFIResult {
match result {
Ok(()) => PlatformWalletFFIResult::ok(),
// Ambiguous: the broadcast was accepted but its execution result
// couldn't be confirmed. The notes stay reserved wallet-side and the
// next nullifier sync (or an app restart) reconciles them; the typed
// Display already carries the operation name and guidance.
// couldn't be confirmed — the host must NOT re-submit. For the
// spend-based operations the notes stay reserved wallet-side; a
// shield reserves nothing. Either way the next sync (or an app
// restart) reconciles the outcome; the typed Display already
// carries the operation name and guidance.
Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed,
Expand Down Expand Up @@ -674,8 +676,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);

let wallet = match resolve_wallet(handle, &wallet_id) {
Ok(w) => w,
// 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,
};

Expand Down Expand Up @@ -704,6 +709,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
let prover = CachedOrchardProver::new();
wallet
.shielded_shield_from_account(
&coordinator,
shielded_account,
payment_account,
amount,
Expand All @@ -712,13 +718,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_shield(
)
.await
});
if let Err(e) = result {
return PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("shielded shield failed: {e}"),
);
}
PlatformWalletFFIResult::ok()
map_spend_result(result, "shielded shield")
}

/// Fund the shielded pool from a Core L1 asset lock, orchestrated
Expand Down Expand Up @@ -802,8 +802,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock(
Err(result) => return result,
};

let wallet = match resolve_wallet(handle, &wallet_id) {
Ok(w) => w,
// The Type 18 live activity recorder writes to the coordinator's
// shared in-memory store, so resolve the coordinator alongside the
// wallet.
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};
let network = wallet.network();
Expand All @@ -830,6 +833,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock(
let prover = CachedOrchardProver::new();
wallet
.shielded_fund_from_asset_lock(
&coordinator,
AssetLockFunding::FromWalletBalance {
amount_duffs,
account_index,
Expand Down Expand Up @@ -950,8 +954,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset
vout: out_point_ffi.vout,
};

let wallet = match resolve_wallet(handle, &wallet_id) {
Ok(w) => w,
// The Type 18 live activity recorder writes to the coordinator's
// shared in-memory store, so resolve the coordinator alongside the
// wallet.
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};
let network = wallet.network();
Expand All @@ -971,6 +978,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset
let prover = CachedOrchardProver::new();
wallet
.shielded_fund_from_asset_lock(
&coordinator,
AssetLockFunding::FromExistingAssetLock {
out_point: resume_outpoint,
},
Expand Down Expand Up @@ -1056,8 +1064,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes(
let mut wallet_id = [0u8; 32];
std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32);

let wallet = match resolve_wallet(handle, &wallet_id) {
Ok(w) => w,
// Each seeding batch's Type 18 live activity recorder writes to the
// coordinator's shared in-memory store, so resolve the coordinator
// alongside the wallet.
let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) {
Ok(p) => p,
Err(result) => return result,
};
let network = wallet.network();
Expand Down Expand Up @@ -1107,6 +1118,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes(

wallet
.shielded_seed_pool_notes(
&coordinator,
&wallet_id,
account,
target_total_notes,
Expand All @@ -1127,32 +1139,6 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_seed_pool_notes(
}
}

/// Resolve the wallet `Arc` for the given manager handle, or
/// produce a `PlatformWalletFFIResult` describing why we couldn't.
fn resolve_wallet(
handle: Handle,
wallet_id: &[u8; 32],
) -> Result<std::sync::Arc<platform_wallet::PlatformWallet>, PlatformWalletFFIResult> {
let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| {
runtime().block_on(manager.get_wallet(wallet_id))
});
let inner_option = match option {
Some(v) => v,
None => {
return Err(PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorInvalidHandle,
format!("invalid manager handle: {handle}"),
));
}
};
inner_option.ok_or_else(|| {
PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorWalletOperation,
format!("wallet not found: {}", hex::encode(wallet_id)),
)
})
}

/// Resolve both the wallet `Arc` and the network-scoped shielded
/// coordinator `Arc` for the given manager handle. Shielded
/// spend operations need the coordinator's shared store, so this
Expand Down
Loading
Loading