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
72 changes: 64 additions & 8 deletions packages/rs-platform-wallet-ffi/src/shielded_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_transfer(
// calling thread.
let result = block_on_worker(async move {
let prover = CachedOrchardProver::new();
wallet
let r = wallet
.shielded_transfer_to(&coordinator, account, &recipient, amount, memo, &prover)
.await
.await;
poke_sync_on_unconfirmed(&r, handle);
r
});
map_spend_result(result, "shielded transfer")
}
Expand Down Expand Up @@ -363,9 +365,11 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_unshield(

let result = block_on_worker(async move {
let prover = CachedOrchardProver::new();
wallet
let r = wallet
.shielded_unshield_to(&coordinator, account, &to_addr_str, amount, &prover)
.await
.await;
poke_sync_on_unconfirmed(&r, handle);
r
});
map_spend_result(result, "shielded unshield")
}
Expand Down Expand Up @@ -412,7 +416,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw(

let result = block_on_worker(async move {
let prover = CachedOrchardProver::new();
wallet
let r = wallet
.shielded_withdraw_to(
&coordinator,
account,
Expand All @@ -421,11 +425,61 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw(
core_fee_per_byte,
&prover,
)
.await
.await;
poke_sync_on_unconfirmed(&r, handle);
r
});
map_spend_result(result, "shielded withdraw")
}

/// On the AMBIGUOUS outcome (broadcast accepted, result unconfirmed),
/// kick an immediate forced shielded sync so the first re-drive check —
/// nullifier re-check, then re-broadcast of the persisted transition —
/// happens now instead of at the next background tick.
///
/// Routed through the manager's [`ShieldedSyncManager::sync_now`] so the
/// pass respects the same `is_syncing` CAS + `quiescing` drain barrier as
/// the periodic loop and the host's Sync Now button — a raw
/// `coordinator.sync(...)` here would race both. `force = true` bypasses
/// only the caught-up cooldown, never the serialization gate. If a pass
/// is already in flight the poke no-ops (empty summary) and the next
/// tick's pass picks the redrive up — that pass's pre-scan snapshot may
/// predate this arm, which is fine.
///
/// Fire-and-forget: the spend's own result is already decided and the
/// sync pass owns resolution from here (`redrive_pending_spends` + the
/// prune backstop); the pass outcome is logged, not surfaced.
///
/// [`ShieldedSyncManager::sync_now`]: platform_wallet::manager::shielded_sync::ShieldedSyncManager::sync_now
fn poke_sync_on_unconfirmed<T>(result: &Result<T, PlatformWalletError>, handle: Handle) {
let ambiguous = matches!(
result,
Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. })
| Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { .. })
);
if !ambiguous {
return;
}
let Some(sync_manager) =
PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| manager.shielded_sync_arc())
else {
return;
};
runtime().spawn(async move {
let summary = sync_manager.sync_now(true).await;
if summary.sync_unix_seconds == 0 {
tracing::debug!(
"post-unconfirmed shielded sync poke skipped (a pass was already in flight or shielded is unconfigured); the next pass owns the re-drive"
);
} else {
tracing::debug!(
wallets = summary.wallet_results.len(),
"post-unconfirmed shielded sync pass completed"
);
}
Comment on lines +470 to +479

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Stray run of spaces in the poke-skipped debug log, and the message slightly mischaracterizes the sync_unix_seconds == 0 branch

Two small issues in the new poke_sync_on_unconfirmed debug log:

  1. Line 472 has ~18 literal spaces baked into the string between "in flight" and "or shielded is unconfigured" — an editor-unwrap artifact that rustfmt won't catch inside a string literal. It will appear verbatim in operator logs and become a grep landmine.

  2. The message says sync_unix_seconds == 0 covers both "a pass was already in flight" and "shielded is unconfigured". Looking at ShieldedSyncManager::sync_now (packages/rs-platform-wallet/src/manager/shielded_sync.rs:336-398): only the CAS-lost and quiescing short-circuits leave sync_unix_seconds == 0. The no-coordinator path returns a default summary and then falls through to the summary.sync_unix_seconds = now stamp + completion-event dispatch, so "unconfigured" actually lands in the "...pass completed" branch (with wallets = 0). The unconfigured case is also practically unreachable here since the spend that produced the ambiguous result required a configured coordinator. Purely cosmetic — no runtime impact.

Suggested change
if summary.sync_unix_seconds == 0 {
tracing::debug!(
"post-unconfirmed shielded sync poke skipped (a pass was already in flight or shielded is unconfigured); the next pass owns the re-drive"
);
} else {
tracing::debug!(
wallets = summary.wallet_results.len(),
"post-unconfirmed shielded sync pass completed"
);
}
tracing::debug!(
"post-unconfirmed shielded sync poke skipped (a pass was already in flight or shielded is unconfigured); the next pass owns the re-drive"
);

source: ['claude']

});
Comment thread
QuantumExplorer marked this conversation as resolved.
}

/// 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
Expand Down Expand Up @@ -600,7 +654,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p
// `Signer<IdentityPublicKey>`.
let identity_signer: &VTableSigner = &*(signer_identity_addr as *const VTableSigner);
let prover = CachedOrchardProver::new();
wallet
let r = wallet
.shielded_identity_create_from_pool(
&coordinator,
account,
Expand All @@ -611,7 +665,9 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_identity_create_from_p
identity_signer,
&prover,
)
.await
.await;
poke_sync_on_unconfirmed(&r, handle);
r
});

match result {
Expand Down
45 changes: 39 additions & 6 deletions packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,13 +638,46 @@ impl NetworkShieldedCoordinator {
// `notes` result and `notes.changeset` the receipts do.
let newly_spent_per_sub = notes.per_subwallet_newly_spent.clone();

// Residual-spend reconcile: `sync_notes_across` above marked every
// landed spend (clearing its reservation). Now release any still-
// pending pre-scan reservation whose recorded anchor Platform had
// already pruned before the scan — a spend broadcast-accepted but
// never landed, otherwise stranded for the session. Runs before the
// balance read so freed notes are reflected in this pass's balances.
// Residual-spend resolution: `sync_notes_across` above marked every
// landed spend (clearing its reservation and dropping its redrive
// record via the store hook). Two passes over what's left, both
// judged against the PRE-scan recorded-anchor set:
//
// 1. Re-drive — for each armed unconfirmed spend whose anchor is
// still recorded, re-broadcast the stored byte-identical
// transition (bounded by MAX_REDRIVE_ATTEMPTS) to actively
// resolve the ambiguity instead of waiting out the retention
// window.
// 2. Prune backstop — release any still-pending pre-scan
// reservation whose anchor was already pruned (the spend can
// never execute).
//
// Runs before the balance read so freed notes are reflected in
// this pass's balances.
if let Some((snapshot, recorded)) = stranded_release {
// Snapshot the per-wallet persisters BEFORE the loop and drop
// the read guard: `redrive_pending_spends` performs network
// broadcasts, and holding the persisters lock across those
// awaits would block wallet register/unregister for the
// duration of the round trips.
let subwallet_persisters: Vec<(SubwalletId, Option<WalletPersister>)> = {
let persisters = self.persisters.read().await;
subwallets
.iter()
.map(|(id, _)| (*id, persisters.get(&id.wallet_id).cloned()))
.collect()
};
for (id, persister) in &subwallet_persisters {
super::operations::redrive_pending_spends(
&self.sdk,
&self.store,
persister.as_ref(),
id.wallet_id,
*id,
&recorded,
)
.await;
}
self.release_stranded_spends(snapshot, &recorded).await;
Comment thread
QuantumExplorer marked this conversation as resolved.
}

Expand Down
Loading
Loading