Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
359 changes: 358 additions & 1 deletion packages/rs-platform-wallet-ffi/src/persistence.rs

Large diffs are not rendered by default.

98 changes: 98 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,73 @@ 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 `entry_id` (sha256 of the visible
/// output cmxs); re-persisting the same `entry_id` 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.
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
///
/// `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). Primary key.
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 +213,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
135 changes: 134 additions & 1 deletion packages/rs-platform-wallet/src/changeset/shielded_changeset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
use std::collections::BTreeMap;

use crate::changeset::merge::Merge;
use crate::wallet::shielded::{ShieldedNote, ShieldedOutgoingNote, SubwalletId};
use crate::wallet::shielded::{
ShieldedActivityEntry, ShieldedNote, ShieldedOutgoingNote, SubwalletId,
};

/// Aggregated delta of shielded state for one persister flush.
#[derive(Debug, Clone, Default)]
Expand All @@ -41,6 +43,14 @@ pub struct ShieldedChangeSet {
/// Latest per-subwallet `last_synced_note_index`. Last write
/// wins on merge (sync only ever advances this monotonically).
pub synced_indices: BTreeMap<SubwalletId, u64>,
/// Derived activity-log entries to persist, per subwallet. Keyed by
/// `(wallet_id, account_index)`; the persister upserts by
/// `entry.id` (sha256 of the visible output cmxs), so a `Pending`
/// entry's later `Confirmed`/`Failed` re-emit, or a scan-derived
/// `ShieldedSpend`'s later refinement, overwrites the existing row.
/// Defaults empty so every pre-activity flow rides the existing
/// changeset path unchanged.
pub activity_entries: BTreeMap<SubwalletId, Vec<ShieldedActivityEntry>>,
}

impl ShieldedChangeSet {
Expand All @@ -50,6 +60,7 @@ impl ShieldedChangeSet {
&& self.nullifiers_spent.is_empty()
&& self.outgoing_notes.is_empty()
&& self.synced_indices.is_empty()
&& self.activity_entries.is_empty()
}

/// Accumulator helper: record a saved note for `id`.
Expand All @@ -67,6 +78,13 @@ impl ShieldedChangeSet {
self.outgoing_notes.entry(id).or_default().push(note);
}

/// Accumulator helper: record a derived activity entry for `id`.
/// The persister upserts by `entry.id`, so re-recording the same id
/// with a refined kind / flipped status replaces the prior row.
pub fn record_activity_entry(&mut self, id: SubwalletId, entry: ShieldedActivityEntry) {
self.activity_entries.entry(id).or_default().push(entry);
}

/// Accumulator helper: advance the per-subwallet sync watermark.
pub fn record_synced_index(&mut self, id: SubwalletId, index: u64) {
let entry = self.synced_indices.entry(id).or_insert(index);
Expand All @@ -92,6 +110,7 @@ impl ShieldedChangeSet {
nullifiers_spent,
outgoing_notes,
synced_indices,
activity_entries,
} = self;
let mut out: BTreeMap<crate::wallet::platform_wallet::WalletId, ShieldedChangeSet> =
BTreeMap::new();
Expand Down Expand Up @@ -119,6 +138,12 @@ impl ShieldedChangeSet {
.synced_indices
.insert(id, idx);
}
for (id, entries) in activity_entries {
out.entry(id.wallet_id)
.or_default()
.activity_entries
.insert(id, entries);
}
// Defensive: drop empty entries so the persister doesn't
// see noise. `split_by_wallet_id` is called on the result
// of a sync pass where at least one map is non-empty
Expand Down Expand Up @@ -147,9 +172,117 @@ impl Merge for ShieldedChangeSet {
*entry = idx;
}
}
// Activity entries append; the persister upserts by `entry.id`,
// so a later flip/refinement of the same id (appended after the
// original) wins at persist time without needing to dedupe here.
for (id, entries) in other.activity_entries {
self.activity_entries.entry(id).or_default().extend(entries);
}
}

fn is_empty(&self) -> bool {
ShieldedChangeSet::is_empty(self)
}
}

#[cfg(test)]
mod activity_changeset_tests {
use super::*;
use crate::wallet::shielded::{
ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection,
};

fn sub(account: u32) -> SubwalletId {
SubwalletId::new([0xDD; 32], account)
}

fn entry(id: u8, status: ShieldedActivityStatus) -> ShieldedActivityEntry {
ShieldedActivityEntry {
id: [id; 32],
kind: ShieldedActivityKind::Sent,
direction: ShieldedDirection::Out,
amount: 100,
fee: Some(1),
counterparty: None,
memo: None,
block_height: None,
status,
created_at_ms: 0,
note_cmxs: vec![[id; 32]],
spent_nullifiers: vec![],
}
}

/// A default `ShieldedChangeSet` (no activity) stays empty — the new
/// field must not perturb the old flush short-circuit.
#[test]
fn default_changeset_with_no_activity_is_empty() {
let cs = ShieldedChangeSet::default();
assert!(cs.is_empty());
assert!(crate::changeset::merge::Merge::is_empty(&cs));
}

/// Recording an activity entry makes the changeset non-empty so it
/// rides the existing flush.
#[test]
fn recording_activity_makes_changeset_nonempty() {
let mut cs = ShieldedChangeSet::default();
cs.record_activity_entry(sub(0), entry(1, ShieldedActivityStatus::Pending));
assert!(!cs.is_empty());
assert_eq!(cs.activity_entries.get(&sub(0)).map(|v| v.len()), Some(1));
}

/// Merge appends activity entries; the persister upserts by id, so a
/// later Confirmed re-emit of the same id appears after the Pending
/// one and wins at persist time.
#[test]
fn merge_appends_activity_entries_in_order() {
let mut a = ShieldedChangeSet::default();
a.record_activity_entry(sub(0), entry(7, ShieldedActivityStatus::Pending));
let mut b = ShieldedChangeSet::default();
b.record_activity_entry(sub(0), entry(7, ShieldedActivityStatus::Confirmed));

crate::changeset::merge::Merge::merge(&mut a, b);
let entries = a.activity_entries.get(&sub(0)).expect("entries present");
assert_eq!(
entries.len(),
2,
"merge appends both (upsert-by-id at flush)"
);
assert_eq!(entries[0].status, ShieldedActivityStatus::Pending);
assert_eq!(
entries[1].status,
ShieldedActivityStatus::Confirmed,
"the Confirmed re-emit lands after the Pending one"
);
}

/// `split_by_wallet_id` routes activity entries to the owning wallet.
#[test]
fn split_by_wallet_id_routes_activity() {
let wallet_a = [0x01; 32];
let wallet_b = [0x02; 32];
let mut cs = ShieldedChangeSet::default();
cs.record_activity_entry(
SubwalletId::new(wallet_a, 0),
entry(1, ShieldedActivityStatus::Confirmed),
);
cs.record_activity_entry(
SubwalletId::new(wallet_b, 0),
entry(2, ShieldedActivityStatus::Confirmed),
);

let split = cs.split_by_wallet_id();
assert_eq!(split.len(), 2);
assert!(split[&wallet_a]
.activity_entries
.contains_key(&SubwalletId::new(wallet_a, 0)));
assert!(split[&wallet_b]
.activity_entries
.contains_key(&SubwalletId::new(wallet_b, 0)));
// No cross-leakage.
assert!(!split[&wallet_a]
.activity_entries
.contains_key(&SubwalletId::new(wallet_b, 0)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
//! [`ShieldedWallet`]: crate::wallet::shielded::ShieldedWallet
//! [`SubwalletId`]: crate::wallet::shielded::SubwalletId

use crate::wallet::shielded::{ShieldedNote, ShieldedOutgoingNote, SubwalletId};
use crate::wallet::shielded::{
ShieldedActivityEntry, ShieldedNote, ShieldedOutgoingNote, SubwalletId,
};
use std::collections::BTreeMap;

/// Per-subwallet snapshot — every note (spent + unspent) the
Expand All @@ -29,6 +31,13 @@ pub struct ShieldedSubwalletStartState {
/// in-memory store's send history survives a cold start without
/// re-recovering every note. Idempotent on re-record by `cmx`.
pub outgoing_notes: Vec<ShieldedOutgoingNote>,
/// Derived activity-log entries persisted on prior sessions (live
/// recordings + scan derivations). Rehydrated so the scan deriver's
/// `existing_ids` set includes them — otherwise a cold-started scan
/// would re-derive a coarse `Sent` / `ShieldedSpend` for a cluster a
/// rich live entry already owns and overwrite it (the persister
/// upserts by `entry.id`). Idempotent on re-save by `id`.
pub activity: Vec<ShieldedActivityEntry>,
/// Sync watermark: count of note positions scanned = the next
/// global index to scan (exclusive). `0` = nothing scanned yet.
pub last_synced_index: u64,
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-platform-wallet/src/wallet/platform_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,8 @@ impl PlatformWallet {
})?;
super::shielded::operations::shield(
&self.sdk,
Some(&self.persister),
self.wallet_id,
keyset,
shielded_account,
inputs,
Expand Down
Loading
Loading