Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f1b03f3
test(platform-wallet): reproduce duplicate commitment appends from co…
lklimek Oct 23, 2026
ac9693e
fix(platform-wallet): take the shielded store write lock before snaps…
lklimek Oct 23, 2026
e3d42b2
test(platform-wallet): reproduce inconsistent shielded trees and spec…
lklimek Oct 23, 2026
7ff9831
feat(platform-wallet)!: detect inconsistent shielded commitment trees…
lklimek Oct 23, 2026
a20ff66
Merge branch 'v5.1-dev' into fix/shielded-sync-concurrent-append
lklimek Oct 28, 2026
bf2b82b
Merge branch 'fix/shielded-sync-concurrent-append' into fix/shielded-…
lklimek Oct 28, 2026
50a5de2
fix(platform-wallet): drop stale scan coverage when rebuilding the sh…
lklimek Oct 28, 2026
a7b633c
test(platform-wallet): cover background sync reporting an inconsisten…
lklimek Oct 28, 2026
df54528
test(platform-wallet): describe the shared-read race the concurrent s…
lklimek Oct 28, 2026
ef91f21
test(platform-wallet): tie the mocked shielded chunk window to the st…
lklimek Oct 28, 2026
c430d89
Merge branch 'fix/shielded-sync-concurrent-append' into fix/shielded-…
lklimek Oct 28, 2026
b1de231
test(platform-wallet): size tree consistency mocks by the stream's fe…
lklimek Oct 28, 2026
54b8fa6
feat(platform-wallet): type the background tree-inconsistency outcome…
lklimek Oct 28, 2026
53e203c
feat: expose the shielded tree rebuild and inconsistency code to mobi…
lklimek Oct 28, 2026
3a57842
fix(platform-wallet): let a stop issued during a shielded tree rebuil…
lklimek Oct 28, 2026
b450f86
fix(platform-wallet): make the shielded tree reset all or nothing
lklimek Oct 29, 2026
7f39c60
fix(platform-wallet): keep sync admission open after a rebuild quiesc…
lklimek Oct 29, 2026
f666fe5
fix(platform-wallet): attribute a failed shielded pass to the wallets…
lklimek Oct 29, 2026
4a5924a
docs(platform-wallet): state what a failed tree reset checkpoint means
lklimek Oct 30, 2026
56880f5
fix(platform-wallet): restart shielded sync off the worker, never int…
lklimek Oct 30, 2026
e1a4b6e
Merge commit 'a0d14144b7^' into fix/shielded-tree-consistency
lklimek Oct 30, 2026
1d62b08
Merge commit 'a0d14144b7' into fix/shielded-tree-consistency (ours)
lklimek Oct 30, 2026
0e843c5
fix(platform-wallet): report a busy checkpoint after a tree reset
lklimek Oct 30, 2026
a30cafc
fix(platform-wallet): serialize shielded tree rebuilds
lklimek Oct 30, 2026
2f57807
docs(platform-wallet): correct what a tree reset checkpoint protects …
lklimek Oct 30, 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
12 changes: 12 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,18 @@ pub enum PlatformWalletError {
#[error("Shielded commitment tree update failed: {0}")]
ShieldedTreeUpdateFailed(String),

/// The local tree exceeds the maximum proven count from a sync containing
/// a response at least as fresh as the tree's recorded height bound.
/// Stale responses and unknown tree provenance cannot trigger this error.
/// No automatic repair is performed.
#[error("Shielded commitment tree has {local_leaf_count} leaves, but a sufficiently fresh sync reports only {chain_leaf_count}; explicitly rebuild the shielded tree and resync")]
ShieldedTreeInconsistent {
/// Number of leaves in the local tree after consuming the stream.
local_leaf_count: u64,
/// Largest commitment count reported by the stream's proofs.
chain_leaf_count: u64,
},

#[error("Shielded store error: {0}")]
ShieldedStoreError(String),

Expand Down
23 changes: 23 additions & 0 deletions packages/rs-platform-wallet/src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,6 +1028,29 @@ impl<P: PlatformWalletPersistence + 'static> PlatformWalletManager<P> {
}
}

/// Rebuild the network's shielded commitment tree on the next sync.
/// Preserves notes, nullifiers, spent status, and wallet registrations while
/// clearing the tree and rewinding scan cursors. The full rescan from zero
/// costs bandwidth and trial decryption proportional to the entire pool.
/// Stops background shielded sync like [`Self::clear_shielded`]; restart it
/// or request a sync after this explicit repair. Never invoked automatically.
#[cfg(feature = "shielded")]
pub async fn rebuild_shielded_tree(&self) -> Result<(), PlatformWalletError> {
let _clearing = self.registry.hold_clearing(WalletWorker::ShieldedSync);
let Some(_quiesced) = self.shielded_sync_manager.quiesce_held().await else {
return Err(PlatformWalletError::ShutdownIncomplete(
"shielded sync pass did not drain within the quiesce budget; rebuild aborted"
.to_string(),
));
};
let coordinator = self.shielded_coordinator().await.ok_or_else(|| {
PlatformWalletError::ShieldedStoreError(
"shielded rebuild requested but no coordinator is configured".to_string(),
)
})?;
coordinator.rebuild_shielded_tree().await
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
}

/// Reset the platform-address (BLAST/DIP-17) incremental-sync
/// watermark and drop every cached balance across **all**
/// registered wallets, forcing a full rescan on the next sync.
Expand Down
10 changes: 9 additions & 1 deletion packages/rs-platform-wallet/src/manager/shielded_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,15 @@ impl ShieldedSyncManager {
return Ok(None);
}

let pass = coordinator.sync(force).await;
if !coordinator
.registered_subwallets()
.await
.iter()
.any(|id| &id.wallet_id == wallet_id)
{
return Ok(None);
}
let pass = coordinator.try_sync(force).await?;

// Extract this wallet's slice from the network-wide pass
// summary. If the wallet is registered, we'll get back an
Expand Down
63 changes: 50 additions & 13 deletions packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,29 @@ impl NetworkShieldedCoordinator {
Ok(())
}

/// Empty only the commitment tree and rewind all subwallet scan cursors.
/// Notes, nullifiers, spent status, pending spends, and registrations survive.
/// The next sync performs a full rescan from zero, costing bandwidth and
/// trial decryption proportional to the entire pool. Never called automatically.
pub async fn rebuild_shielded_tree(&self) -> Result<(), PlatformWalletError> {
let _lifecycle = self.lifecycle.lock().await;
let mut store = self.store.write().await;
// Rewind even if the durable reset fails partway through. Rescanning
// an intact tree is harmless; skipping a partially reset tree is not.
store.rewind_scan_watermarks();
if let Ok(mut caught_up) = self.last_caught_up_at.lock() {
*caught_up = None;
}
store.reset_commitment_tree()?;
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
let remaining = store.tree_size()?;
if remaining != 0 {
return Err(PlatformWalletError::ShieldedTreeUpdateFailed(format!(
"commitment tree still has {remaining} leaves after rebuild reset"
)));
}
Ok(())
}

/// Run one shielded sync pass for every registered wallet on
/// this coordinator's network. Returns a per-wallet outcome
/// summary suitable for emission to UI / persistence layers
Expand All @@ -1315,6 +1338,27 @@ impl NetworkShieldedCoordinator {
/// [`PlatformEventManager::on_shielded_sync_completed`]:
/// crate::events::PlatformEventManager::on_shielded_sync_completed
pub async fn sync(&self, force: bool) -> ShieldedSyncPassSummary {
match self.try_sync(force).await {
Ok(summary) => summary,
Err(error) => {
let subwallets = self
.accounts
.read()
.await
.iter()
.map(|(id, views)| (*id, views.clone()))
.collect::<Vec<_>>();
self.fail_all_wallets(&subwallets, &error)
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
}
}
}
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved

/// Run the same pass as [`Self::sync`], preserving typed failures for callers
/// that need to distinguish an inconsistent tree from a transient sync error.
pub async fn try_sync(
&self,
force: bool,
) -> Result<ShieldedSyncPassSummary, PlatformWalletError> {
// Network-wide cooldown gate. Snapshot the remaining
// window into a local before any await — `std::sync::Mutex`
// is `!Send` across await points (clippy's
Expand All @@ -1336,7 +1380,7 @@ impl NetworkShieldedCoordinator {
cooldown_total_secs = CAUGHT_UP_COOLDOWN.as_secs(),
"Coordinator sync skipped — within caught-up cooldown"
);
return Self::cooldown_skip_summary(self).await;
return Ok(Self::cooldown_skip_summary(self).await);
}

// Snapshot the flat subwallet registry. This Vec is both
Expand All @@ -1350,7 +1394,7 @@ impl NetworkShieldedCoordinator {
let mut summary = ShieldedSyncPassSummary::default();
if subwallets.is_empty() {
summary.sync_unix_seconds = Self::now_unix();
return summary;
return Ok(summary);
}

// Snapshot armed reservations and prefetch Platform's recorded
Expand Down Expand Up @@ -1378,18 +1422,14 @@ impl NetworkShieldedCoordinator {
// Second, distinct signal: commitments committed to the local
// tree as the interleaved consumer drains the SDK stream.
let on_tree_progress = self.tree_progress_handler();
let notes = match super::sync::sync_notes_across(
let notes = super::sync::sync_notes_across(
&self.sdk,
&self.store,
&subwallets,
on_progress.as_ref(),
on_tree_progress.as_ref(),
)
.await
{
Ok(r) => r,
Err(e) => return self.fail_all_wallets(&subwallets, &e),
};
.await?;
// Scan-based spend detection now happens INSIDE
// `sync_notes_across`: every scanned action's nullifier is
// replayed against each subwallet's store as part of the note
Expand Down Expand Up @@ -1441,10 +1481,7 @@ impl NetworkShieldedCoordinator {
self.release_stranded_spends(snapshot, &recorded).await;
}

let balances_per_sub = match super::sync::balances_across(&self.store, &subwallets).await {
Ok(r) => r,
Err(e) => return self.fail_all_wallets(&subwallets, &e),
};
let balances_per_sub = super::sync::balances_across(&self.store, &subwallets).await?;

// Restore-path activity derivation: reconstruct per-operation
// activity entries best-effort from the notes / outgoing notes
Expand Down Expand Up @@ -1524,7 +1561,7 @@ impl NetworkShieldedCoordinator {
summary =
build_per_wallet_summary(&subwallets, &notes, &newly_spent_per_sub, &balances_per_sub);
summary.sync_unix_seconds = Self::now_unix();
summary
Ok(summary)
}

/// Build a `ShieldedSyncPassSummary` where every registered
Expand Down
78 changes: 77 additions & 1 deletion packages/rs-platform-wallet/src/wallet/shielded/file_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ impl FileBackedShieldedStore {
}
}

/// Rewind all scan cursors without discarding receipts or spend guards.
/// Scan coverage is dropped with the tree; retained notes count as restored.
pub(super) fn rewind_scan_watermarks(&mut self) {
for state in self.subwallets.values_mut() {
state.last_synced_index = 0;
state.last_scanned_index = None;
state.balance_source = if state.notes.is_empty() {
ShieldedBalanceSource::NoHistory
} else {
ShieldedBalanceSource::Restored
};
}
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Resolved
}

/// Open or create a shielded store at `path`.
///
/// SQLite is opened with **WAL journal + synchronous=NORMAL + temp_store=MEMORY**
Expand Down Expand Up @@ -194,6 +208,19 @@ impl FileBackedShieldedStore {
.map_err(|e| {
FileShieldedStoreError::Storage(format!("create pending_spends table: {e}"))
})?;
// The bound shares the tree's database/WAL: committing it before an
// append prevents a restart from pairing new leaves with an old bound.
pending_conn
.execute(
"CREATE TABLE IF NOT EXISTS shielded_tree_sync_height (
id INTEGER PRIMARY KEY CHECK (id = 0),
height INTEGER NOT NULL CHECK (height >= 0)
)",
[],
)
.map_err(|e| {
FileShieldedStoreError::Storage(format!("create tree sync height: {e}"))
})?;
// Additive, idempotent migration: old guards remain active.
let columns = {
let mut statement = pending_conn
Expand Down Expand Up @@ -542,6 +569,17 @@ impl ShieldedStore for FileBackedShieldedStore {
.unwrap_or_default())
}

fn note_spent_status(
&self,
id: SubwalletId,
nullifier: &[u8; 32],
) -> Result<Option<bool>, Self::Error> {
Ok(self
.subwallets
.get(&id)
.and_then(|state| state.note_spent_status(nullifier)))
}

fn mark_spent(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result<bool, Self::Error> {
let Some(sw) = self.subwallets.get_mut(&id) else {
return Ok(false);
Expand Down Expand Up @@ -857,6 +895,43 @@ impl ShieldedStore for FileBackedShieldedStore {
Ok(size)
}

fn tree_sync_height(&self) -> Result<Option<u64>, Self::Error> {
let conn = self
.pending_conn
.lock()
.map_err(|e| FileShieldedStoreError::Storage(format!("tree sync height mutex: {e}")))?;
let height: Option<i64> = conn
.query_row(
"SELECT MAX(height) FROM shielded_tree_sync_height",
[],
|row| row.get(0),
)
.map_err(|e| FileShieldedStoreError::Storage(format!("read tree sync height: {e}")))?;
height
.map(u64::try_from)
.transpose()
.map_err(|e| FileShieldedStoreError::Storage(format!("invalid tree sync height: {e}")))
}

fn set_tree_sync_height(&mut self, height: u64) -> Result<(), Self::Error> {
let height = i64::try_from(height).map_err(|e| {
FileShieldedStoreError::Storage(format!(
"tree sync height exceeds SQLite integer range: {e}"
))
})?;
let conn = self
.pending_conn
.lock()
.map_err(|e| FileShieldedStoreError::Storage(format!("tree sync height mutex: {e}")))?;
conn.execute(
"INSERT INTO shielded_tree_sync_height (id, height) VALUES (0, ?1)
ON CONFLICT(id) DO UPDATE SET height = MAX(height, excluded.height)",
[height],
)
.map_err(|e| FileShieldedStoreError::Storage(format!("write tree sync height: {e}")))?;
Ok(())
}

fn last_synced_note_index(&self, id: SubwalletId) -> Result<u64, Self::Error> {
Ok(self
.subwallets
Expand Down Expand Up @@ -974,7 +1049,8 @@ impl ShieldedStore for FileBackedShieldedStore {
"DELETE FROM commitment_tree_checkpoint_marks_removed;
DELETE FROM commitment_tree_checkpoints;
DELETE FROM commitment_tree_shards;
DELETE FROM commitment_tree_cap;",
DELETE FROM commitment_tree_cap;
DELETE FROM shielded_tree_sync_height;",
)
.map_err(|e| {
FileShieldedStoreError::Storage(format!("reset commitment tree tables: {e}"))
Expand Down
Loading
Loading