Skip to content

perf(platform-wallet)!: build the Orchard proving key once off the async runtime and prove off tokio workers - #5278

Closed
PastaPastaPasta wants to merge 5 commits into
v5.1-devfrom
perf/shielded-prover-prepare
Closed

PastaPastaPasta wants to merge 5 commits into
v5.1-devfrom
perf/shielded-prover-prepare

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Shielded sends had avoidable latency, and proving starved the async runtime:

  • Lazy key build blocked a worker. The Halo 2 proving key was built lazily inside OnceLock::get_or_init (~1.4 s release, ~30 s debug). A send that arrived mid-build parked a tokio worker for the rest of the build.
  • Proving on runtime workers. Every proof ran synchronously on a tokio worker (~0.9 s release), competing with networking and shielded sync. In a release-build measurement, an inline Shield proof stalled a 10 ms heartbeat task for up to 886 ms.
  • Background-priority builds on iOS. A warm-up started from a background-priority Swift task could leave the key build, and halo2's rayon threads, at background QoS: Darwin threads inherit their creator's QoS. A send confirmed mid-build then waited on throttled threads.
  • No way to wait for readiness. warmUpShieldedProver() is fire-and-forget, so the only readiness signal was polling isShieldedProverReady.

Part of a series cutting Dash shielded send latency:

What was done?

  • Shared preparation (rs-platform-wallet/src/wallet/shielded/prover.rs):
    • The key is built once per process on tokio's blocking pool, never on an async worker. Concurrent async callers await that single build; the legacy blocking warm_up joins it.
    • A build that panics leaves the cache empty, and the next caller retries.
    • New CachedOrchardProver::prepare(), and a ShieldedProver trait (ready()) that the send operations await before proving.
  • Off-runtime proving. Transfer, unshield, withdraw and identity top-up prove on the blocking pool. The spend keyset moves in by Arc, so the spend authority is never copied. Shield, shield-from-identity and identity-create await the shared preparation.
  • Darwin QoS. On Apple targets, key builds and proofs run on a dedicated rayon pool:
    • user-initiated QoS (pthread_set_qos_class_self_np), 8 MiB stacks;
    • halo2's nested parallel work runs on the same pool (single rayon-core);
    • top-level jobs are serialized with a mutex, so two concurrent proofs can't nest on one worker stack.
  • FFI. New blocking platform_wallet_shielded_prepare_prover(), which returns once the key is ready. The existing warm-up entry point now joins the shared preparation.
  • Swift. New PlatformWalletManager.prepareShieldedProver() async throws (nonisolated). It uses a checked continuation on a user-initiated dispatch queue, so it never holds a cooperative-pool thread. warmUpShieldedProver() and isShieldedProverReady are unchanged apart from becoming nonisolated.

The proof itself isn't faster here; that's the halo2/orchard PRs. What this PR fixes:

  • async workers stay free during proving;
  • a send started mid-warm-up waits only for the remaining build (on one shared build);
  • builds and proofs can't be throttled at background QoS on iOS;
  • hosts can await readiness.

How Has This Been Tested?

  • cargo test -p platform-wallet -p platform-wallet-ffi --features platform-wallet/shielded,platform-wallet-ffi/shielded: 1401 + 426 lib tests plus integration suites pass. The prover tests passed 9 times in a row, including with --test-threads=64.
  • New prover tests. None use latency thresholds; they rely on gated builds, channels and 120 s hang guards:
    • one build for 32 concurrent callers;
    • warm-up and prepare share one build;
    • an aborted prepare waiter doesn't cause a second build;
    • a current-thread runtime stays responsive while the key builds;
    • a send started mid-warm-up waits for the in-flight build;
    • a panicked build is retried;
    • with no runtime, the key builds on the calling thread;
    • proving off-runtime keeps the runtime ticking;
    • a panic during proving reaches the caller;
    • the Apple pool runs one top-level job at a time (fails with the mutex removed);
    • Apple QoS is user-initiated even when the request comes from a background-QoS thread.
  • Release measurements (ignored tests, real key, M4 Pro):
    • key build 1.43 s;
    • heartbeat worst gap during a real Shield proof: 886 ms inline vs 24.5 ms off-runtime.
  • Clippy -D warnings (wallet, FFI) and cargo fmt --check are clean.
  • Independent code review: approved. Its medium finding (proofs nesting on the shared pool) and the follow-up suggestions are applied in 27afa59735.
  • Swift: SwiftDashSDK compiles for the iOS Simulator (xcodebuild, Swift 6 mode) against a header with the new declaration. It was not linked or run, and the XCFramework was not rebuilt.
  • Non-Apple targets use the plain blocking-thread path; that path is covered by the tests above.

Breaking Changes

The PlatformWallet shielded send methods (transfer, unshield, withdraw, identity top-up) now take:

  • P: ShieldedProver instead of any OrchardProver (implemented by CachedOrchardProver and &CachedOrchardProver, so existing callers compile unchanged);
  • keys: &Arc<OrchardKeySet>.

All in-repo callers (FFI, JNI via FFI symbols) are updated.

Not changed here (follow-up): fund_from_asset_lock / seed_pool still prove inline through OrchardProver.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed
  • If I added or changed GroveDB structure, I described it in the area's structure.rs, regenerated grovedb-structure.json, and checked the structure viewer link posted on this pull request

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

PR Hygiene · 797c1aa

  • Bots — coderabbitai skipped after the window · thepastaclaw ✓
  • Self-review — post /self-reviewed
  • Reviewer requests paused — your 5 review slots are occupied; this PR is excluded from reviewers' queues. Required approvals still count without a slot.
  • Build green
  • Approvals
    • files with no dedicated owner (Cargo.lock) — QuantumExplorer or shumkov
    • rs-platform-wallet-ffi (packages/rs-platform-wallet-ffi/src/shielded_send.rs) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • rs-platform-wallet (packages/rs-platform-wallet/Cargo.toml, packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet/src/wallet/shielded/mod.rs and 2 more) — HashEngineering or ZocoLini or llbartekll or romchornyi
    • swift-sdk (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift) — llbartekll or romchornyi

When every merge requirement is met, the PR Hygiene check passes. Reviewer limits do not block merging; other required GitHub checks and protections still apply.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: dashpay/platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6eec1318-e158-447f-ae87-8b2641e366e6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the v5.0.0 milestone Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added the waiting-bots Waiting for the review bots to report on this head label Oct 5, 2026
@thepastaclaw

thepastaclaw commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 797c1aa) · triage: critical

@thepastaclaw thepastaclaw left a comment

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.

Final validation — Phase 1 + Phase 2

Verified the supplied findings against head 27afa59. The new proof await exposes stranded note reservations on cancellation, and four preparation/API/test issues also remain; these are client-side suggestions under the supplied severity policy, not consensus blockers. This was a static review: the supplied CI snapshot reports Rust workspace and Kotlin checks passing, dedicated Rust wallet tests skipped, and Swift build/tests still pending.

🟡 5 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The intricate changes in shielded/prover.rs alter cryptographic proving-key initialization, sharing and panic recovery, while shielded/operations.rs moves spend-authority-bearing proof construction across thread boundaries, directly changing key handling.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/operations.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/operations.rs:1843-1844: Make pre-broadcast note reservations cancellation-safe at the new proof await
  The new await can suspend after reserve_unspent_notes marks the selected notes pending but before arm_pending_release attaches their anchor. If a Rust caller times out, drops the send future through select!, or aborts its task during preparation/proving, the outer result match is dropped too, so cancel_pending never runs. The blocking proof can finish after cancellation, but its closure owns neither reservation cleanup nor broadcasting. These reservations retain anchor: None: store.rs excludes them from spendable balance and note selection, while stale_pending_spends deliberately omits them, so normal sync cannot release them. The same newly exposed window exists in unshield, withdrawal, and identity top-up. Give pre-broadcast reservations cancellation-safe ownership in the wallet operations layer, then transfer ownership to conservative recovery before broadcasting. Add a gated operation-level cancellation test that verifies the notes become selectable again; the existing prepare-waiter cancellation test only verifies cache behavior.

In `packages/rs-platform-wallet/src/wallet/shielded/prover.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/prover.rs:118-120: Preserve preparation's panic-to-error contract outside Tokio
  The supported no-runtime fallback calls get_or_build_blocking directly. An unwinding build panic therefore escapes prepare instead of returning ShieldedBuildError, unlike the Tokio path, which converts the blocking job's JoinError. This contradicts the preparation method's documented error behavior and makes failure handling depend on the executor. Catch unwinding build panics in this fallback and add a futures::executor::block_on test asserting that the first attempt returns Err, the cache remains empty, and a later attempt succeeds. This recovery applies only to unwind-enabled builds, not panic=abort profiles.

In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`:
- [SUGGESTION] packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift:633-635: isShieldedProverReady should be nonisolated like warmUp/prepare
  PlatformWalletManager is @MainActor, so this static getter remains main-actor isolated even though the PR explicitly says readiness polling becomes nonisolated. Background callers still need an actor hop to read the indicator in Swift 6, unlike the newly nonisolated warm-up and preparation methods. The getter only reads the Rust process-global OnceLock and accesses no manager state. Mark it nonisolated to complete the stated API change.

In `packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/shielded_send.rs:177-180: Guard the new preparation export against outer runtime panics
  Preparation already converts a panic in the blocking key-build job into an error, but the surrounding block_on_worker bridge remains unguarded. runtime() can panic during runtime creation, and Runtime::block_on panics when invoked from an existing Tokio async execution context; block_on_worker also expects its worker join to succeed. An unwind from these paths reaches the extern "C" frame and aborts the host rather than returning PlatformWalletFFIResult. Wrap the new export with the existing catch_panic_to_code helper and a definitive wallet-operation error, since preparation cannot broadcast a spend. This protects unwind-enabled builds only; the iOS panic=abort profiles remain unrecoverable.
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/shielded_send.rs:2337-2341: Ignored prepare test asserts a 100ms wall-clock bound
  The cached preparation call still goes through block_on_worker, including task scheduling and a JoinHandle round-trip. Scheduler delays can exceed 100ms without rebuilding the key or violating preparation correctness, so explicitly running this ignored test can fail solely because of machine load. Remove the elapsed-time assertion and verify successful repeated preparation and readiness instead. The gated cell tests already check build counts without using a latency threshold.
Out-of-scope follow-up suggestions (4)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Complete panic containment for legacy shielded spend exports — The transfer, unshield, and withdrawal C exports still call block_on_worker without catch_spend_panic. On unwind-enabled builds, a worker panic is re-raised inside extern "C" and terminates the host before JNI or Swift can translate it. The same unguarded calls exist before this PR's four commits, so this is a concrete legacy host-crash issue rather than a new blocker.
    • Follow-up: Track a separate issue or maintainer-requested PR to apply the existing ambiguous-outcome panic guards to these legacy spend exports.
  • Unify proving execution for signer-dependent send builders — Outside scope: the PR explicitly limits proof offloading to transfer, unshield, withdrawal, and identity top-up. Shield, shield-from-identity, and identity-create retain their existing inline proof execution while gaining shared preparation. Completing the intentionally deferred migration is not necessary for this change to work.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
  • Finish off-runtime proving for the signer-dependent send paths — Outside scope and duplicates the intentionally deferred signer-dependent migration. The synchronous DPP builder execution predates this PR and is not worsened by awaiting preparation.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
  • shield / shield-from-identity / identity-create still prove inline — Outside scope: retained inline proving is consistent with the PR description's explicit distinction between four offloaded spend builders and signer-dependent paths that only await shared preparation.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment on lines +1843 to +1844
})
.await?

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.

🟡 Suggestion: Make pre-broadcast note reservations cancellation-safe at the new proof await

The new await can suspend after reserve_unspent_notes marks the selected notes pending but before arm_pending_release attaches their anchor. If a Rust caller times out, drops the send future through select!, or aborts its task during preparation/proving, the outer result match is dropped too, so cancel_pending never runs. The blocking proof can finish after cancellation, but its closure owns neither reservation cleanup nor broadcasting. These reservations retain anchor: None: store.rs excludes them from spendable balance and note selection, while stale_pending_spends deliberately omits them, so normal sync cannot release them. The same newly exposed window exists in unshield, withdrawal, and identity top-up. Give pre-broadcast reservations cancellation-safe ownership in the wallet operations layer, then transfer ownership to conservative recovery before broadcasting. Add a gated operation-level cancellation test that verifies the notes become selectable again; the existing prepare-waiter cancellation test only verifies cache behavior.

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining this one as out of scope. The cancellation window isn't new in this PR. In transfer, unshield, withdraw and identity_top_up_from_pool_with_metadata, the notes are already reserved before extract_spends_and_anchor(...).await, which does a network round-trip (ShieldedAnchors::fetch_current(sdk).await), and before record_pending_activity(...).await. All of that comes before arm_pending_release. So a caller that drops the send future could already strand anchor: None reservations before this change. Moving the proof onto the blocking pool makes that window longer but doesn't create it. The FFI hosts drive these futures through block_on_worker to completion, so they never drop them mid-flight. Making pre-broadcast reservations cancellation-safe needs a reservation ownership/recovery change across the operations layer, and that belongs in its own PR.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Deferred (re-reviewed at 797c1aae): Your scope argument is supported by the baseline: all four operations already reserve notes before the anchor-fetch and activity-recording awaits, and the FFI bridge drives the send future to completion. The new proof await extends that existing window without broadcasting from detached work, so reservation ownership can remain a separate operations-layer follow-up.

Comment on lines +118 to +120
if tokio::runtime::Handle::try_current().is_err() {
return Ok(self.get_or_build_blocking());
}

@thepastaclaw thepastaclaw Oct 5, 2026 •

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.

✅ Resolved at 797c1aae; see the replies below.

🟡 Suggestion: Preserve preparation's panic-to-error contract outside Tokio

The supported no-runtime fallback calls get_or_build_blocking directly. An unwinding build panic therefore escapes prepare instead of returning ShieldedBuildError, unlike the Tokio path, which converts the blocking job's JoinError. This contradicts the preparation method's documented error behavior and makes failure handling depend on the executor. Catch unwinding build panics in this fallback and add a futures::executor::block_on test asserting that the first attempt returns Err, the cache remains empty, and a later attempt succeeds. This recovery applies only to unwind-enabled builds, not panic=abort profiles.

Suggested change
if tokio::runtime::Handle::try_current().is_err() {
return Ok(self.get_or_build_blocking());
}
if tokio::runtime::Handle::try_current().is_err() {
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.get_or_build_blocking()
}))
.map_err(|_| {
PlatformWalletError::ShieldedBuildError(
"Orchard proving key preparation failed: the build panicked".to_string(),
)
});
}

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 180b997. The no-runtime fallback now catches an unwinding build panic and returns ShieldedBuildError, matching the blocking-pool path. Added panicking_build_without_runtime_reports_error_and_a_later_call_retries, which uses futures::executor::block_on and checks that the first attempt errors, the cell stays empty, and the retry succeeds.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Resolved (re-reviewed at 797c1aae): Your no-runtime fallback now converts an unwinding build panic into ShieldedBuildError. The added regression test checks the failed attempt leaves the cell empty and a later attempt succeeds; I verified those assertions statically.

Comment on lines 633 to 635
public static var isShieldedProverReady: Bool {
platform_wallet_shielded_prover_is_ready()
}

@thepastaclaw thepastaclaw Oct 5, 2026 •

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.

✅ Resolved at 797c1aae; see the replies below.

🟡 Suggestion: isShieldedProverReady should be nonisolated like warmUp/prepare

PlatformWalletManager is @mainactor, so this static getter remains main-actor isolated even though the PR explicitly says readiness polling becomes nonisolated. Background callers still need an actor hop to read the indicator in Swift 6, unlike the newly nonisolated warm-up and preparation methods. The getter only reads the Rust process-global OnceLock and accesses no manager state. Mark it nonisolated to complete the stated API change.

Suggested change
public static var isShieldedProverReady: Bool {
platform_wallet_shielded_prover_is_ready()
}
nonisolated public static var isShieldedProverReady: Bool {
platform_wallet_shielded_prover_is_ready()
}

source: muse-spark-1.3-contributor (phase1-reviewer: general)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 180b997. isShieldedProverReady is now nonisolated, as the PR description says.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Resolved (re-reviewed at 797c1aae): Your readiness getter is now explicitly nonisolated, matching warm-up and preparation. It reads only the Rust process-global readiness state, so background polling no longer requires a main-actor hop.

Comment on lines +177 to +180
match block_on_worker(CachedOrchardProver::prepare()) {
Ok(()) => PlatformWalletFFIResult::ok(),
Err(e) => e.into(),
}

@thepastaclaw thepastaclaw Oct 5, 2026 •

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.

✅ Resolved at 797c1aae; see the replies below.

🟡 Suggestion: Guard the new preparation export against outer runtime panics

Preparation already converts a panic in the blocking key-build job into an error, but the surrounding block_on_worker bridge remains unguarded. runtime() can panic during runtime creation, and Runtime::block_on panics when invoked from an existing Tokio async execution context; block_on_worker also expects its worker join to succeed. An unwind from these paths reaches the extern "C" frame and aborts the host rather than returning PlatformWalletFFIResult. Wrap the new export with the existing catch_panic_to_code helper and a definitive wallet-operation error, since preparation cannot broadcast a spend. This protects unwind-enabled builds only; the iOS panic=abort profiles remain unrecoverable.

Suggested change
match block_on_worker(CachedOrchardProver::prepare()) {
Ok(()) => PlatformWalletFFIResult::ok(),
Err(e) => e.into(),
}
catch_panic_to_code(
"shielded prover preparation",
PlatformWalletFFIResultCode::ErrorWalletOperation,
"No spend was broadcast.",
|| match block_on_worker(CachedOrchardProver::prepare()) {
Ok(()) => PlatformWalletFFIResult::ok(),
Err(e) => e.into(),
},
)

source: muse-spark-1.3-contributor (phase1-reviewer: general, rust-quality); gpt-6.1-sol (phase2-reviewer: ffi-engineer)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 180b997. The export is now wrapped in catch_panic_to_code with ErrorWalletOperation and "No spend was broadcast." Preparation never spends, so a definitive failure code is correct here.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Resolved (re-reviewed at 797c1aae): Your guard encloses the entire runtime bridge and result conversion, returning ErrorWalletOperation with "No spend was broadcast." This addresses the identified unwinding paths without implying recovery under panic=abort.

Comment on lines +2337 to +2341
// Ready: a second call is immediate.
let start = std::time::Instant::now();
let result = platform_wallet_shielded_prepare_prover();
assert_eq!(result.code, PlatformWalletFFIResultCode::Success);
assert!(start.elapsed() < std::time::Duration::from_millis(100));

@thepastaclaw thepastaclaw Oct 5, 2026 •

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.

✅ Resolved at 797c1aae; see the replies below.

🟡 Suggestion: Ignored prepare test asserts a 100ms wall-clock bound

The cached preparation call still goes through block_on_worker, including task scheduling and a JoinHandle round-trip. Scheduler delays can exceed 100ms without rebuilding the key or violating preparation correctness, so explicitly running this ignored test can fail solely because of machine load. Remove the elapsed-time assertion and verify successful repeated preparation and readiness instead. The gated cell tests already check build counts without using a latency threshold.

Suggested change
// Ready: a second call is immediate.
let start = std::time::Instant::now();
let result = platform_wallet_shielded_prepare_prover();
assert_eq!(result.code, PlatformWalletFFIResultCode::Success);
assert!(start.elapsed() < std::time::Duration::from_millis(100));
// Ready: repeated preparation succeeds and preserves readiness.
let result = platform_wallet_shielded_prepare_prover();
assert_eq!(result.code, PlatformWalletFFIResultCode::Success);
assert!(platform_wallet_shielded_prover_is_ready());

source: muse-spark-1.3-contributor (phase1-reviewer: rust-quality)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 180b997. Removed the 100 ms bound. The test now checks that repeated preparation succeeds and readiness still holds.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Resolved (re-reviewed at 797c1aae): Your fix is present at the current head: repeated preparation checks success and readiness, and the 100 ms assertion is gone. The remaining elapsed-time output cannot fail the test because of scheduler load.

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. and removed waiting-bots Waiting for the review bots to report on this head labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai skipped after its own rate limit · thepastaclaw not yet. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 5, 2026
@github-actions github-actions Bot added the bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

@thepastaclaw review

No review for 180b9976 yet, so PR Hygiene is asking once. If nothing arrives, the requirement is dropped for this commit and the pull request is labelled bot-review-skipped.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed and removed waiting-bots Waiting for the review bots to report on this head labels Oct 6, 2026
PastaPastaPasta and others added 5 commits October 6, 2026 12:50
… off the async runtime

The Halo 2 proving key lived in a `OnceLock` filled lazily inside
`OrchardProver::proving_key`, so a send that arrived while the key was
still building blocked a tokio worker in `get_or_init` for up to the
whole build (~30 s debug, ~1.4 s release), and every proof then ran
synchronously on a tokio worker, competing with networking and shielded
sync.

- `ProvingKeyCell`: one build per process. Concurrent async callers
  coalesce on a `tokio::sync::OnceCell` and wait on one blocking-pool
  job; the sync `warm_up` path shares the same `OnceLock`, so the two
  never build twice. A panicked build leaves the cell empty for a retry.
- `CachedOrchardProver::prepare()` (async) and the new `ShieldedProver`
  trait: send operations await `ready()` (the shared preparation) and
  get a `'static` `ReadyOrchardProver`.
- transfer, unshield, withdraw and identity top-up build their
  transitions in `prove_on_blocking_thread`; the spend keyset moves in
  by `Arc`, so the spend authority is never copied. Shield,
  shield-from-identity and identity-create await the shared preparation.
- On Apple targets key builds and proofs run on a dedicated rayon pool
  whose threads request USER_INITIATED QoS (8 MiB stacks). Darwin
  threads inherit their creator's QoS, so a warm-up started from a
  background-priority host task could otherwise leave the build, and
  the lazily created global rayon pool halo2 uses, throttled.

`PlatformWallet`'s shielded send methods now take `P: ShieldedProver`
(implemented by `CachedOrchardProver` and `&CachedOrchardProver`)
instead of any `OrchardProver`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`platform_wallet_shielded_prepare_prover()` blocks the calling thread
until the shared Halo 2 proving-key build finishes (starting it if
needed) and returns a `PlatformWalletFFIResult`, so hosts can wait for
readiness instead of polling. `platform_wallet_shielded_warm_up_prover`
stays fire-and-forget but now joins the same shared preparation, and
`platform_wallet_shielded_prover_is_ready` is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`PlatformWalletManager.prepareShieldedProver() async throws` completes
only once the proving key is built. The blocking FFI wait runs on a
global user-initiated dispatch queue through a checked continuation, so
it never pins a Swift cooperative-pool thread. Document that
`warmUpShieldedProver()` only schedules the build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rover tests

Review follow-ups for the shared proving-key preparation:

- Top-level jobs enter the Apple proving pool one at a time. With two
  proofs in flight, a worker blocked in a `join` inside proof A could
  steal proof B's whole top-level job and run it on A's stack (A then
  finishes after A + B, sharing one 8 MiB stack). A call made from a pool
  thread runs inline instead of re-taking the gate.
- Document the invariant that the key is never lazily built from a rayon
  worker (deadlock), why proving awaits `ready()` first, and that a
  persistently failing build is retried once per queued waiter.
- Replace wall-clock thresholds in the prover tests with gated builds
  and channels, add a test that aborting the waiter that started the
  build does not trigger a second build, and a test for the pool gate.
- Swift: `prepareShieldedProver()` and `warmUpShieldedProver()` are
  `nonisolated` (no main-actor hop for the wait); document that prepare
  ignores task cancellation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- ProvingKeyCell::prepare: catch an unwinding build panic in the
  no-runtime fallback too, so it returns ShieldedBuildError (and a
  later call retries) exactly like the blocking-pool path.
- platform_wallet_shielded_prepare_prover: wrap the export in
  catch_panic_to_code so a panic in the runtime bridge returns
  ErrorWalletOperation instead of unwinding across the C ABI.
- Swift: make isShieldedProverReady nonisolated, as the PR states.
- Drop the 100 ms wall-clock assertion from the ignored FFI prepare
  test; assert repeated success and readiness instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta changed the base branch from v5.0-dev to v5.1-dev October 6, 2026 18:14
@PastaPastaPasta
PastaPastaPasta force-pushed the perf/shielded-prover-prepare branch from 180b997 to 797c1aa Compare October 6, 2026 18:14
@github-actions github-actions Bot removed this from the v5.0.0 milestone Oct 6, 2026
@github-actions github-actions Bot added this to the v5.1.0 milestone Oct 6, 2026
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Waiting for bot review — coderabbitai not yet · thepastaclaw ✓. Wait for the missing reviews, or a writer can post /skip-bots to proceed without them; blocking findings still need addressing.
Full checklist in the description.

@github-actions github-actions Bot added waiting-bots Waiting for the review bots to report on this head and removed waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. labels Oct 6, 2026

@thepastaclaw thepastaclaw left a comment

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.

Re-review — Final validation — Phase 1 + Phase 2

Verified the complete diff at 797c1aa: no in-scope blocking defects were identified, and four prior findings are fixed. One non-blocking suggestion remains: report Apple proving-pool initialization failures before permanently caching the inline fallback. Validation was static only; Rust workspace tests were pending in the supplied CI snapshot, the Kotlin failure log confirms an action-download timeout before compilation, and Swift linking/runtime validation remains unverified.

🟡 1 suggestion(s)

Review provenance

Source: reviewer 1: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 5: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); reviewer 6: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 7: muse-spark-1.3-contributor (agent: phase1-reviewer, role: rust-quality); reviewer 8: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 9: gpt-6.1-sol (agent: phase2-reviewer, role: architecture-layering); reviewer 10: gpt-6.1-sol (agent: phase2-reviewer, role: ffi-engineer); reviewer 11: gpt-6.1-sol (agent: phase2-reviewer, role: rust-quality); reviewer 12: gpt-6.1-sol (agent: phase2-reviewer, role: security-auditor); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: critical by gpt-6.1-sol (effort low) — The intricate changes in shielded/prover.rs and shielded/operations.rs alter proving-key initialization, panic recovery, concurrent proof execution, and spend-authority ownership during funds-moving transition construction.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer, muse-spark-1.3-contributor — rust-quality (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Single stage: Phase 1 and Phase 2 reviewed this head side by side, with no blocker gate between them (triage tier)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — general (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — architecture-layering (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — ffi-engineer (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — rust-quality (completed, effort xhigh); agent phase2-reviewer, gpt-6.1-sol — security-auditor (completed, effort xhigh); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/shielded/prover.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/prover.rs:223-224: Log the permanent fallback when the Apple proving pool cannot initialize
  The inline fallback preserves functionality, but `.ok()` discards the pool initialization error and the process-global `OnceLock` permanently caches `None`. Even a transient thread-creation failure therefore disables the dedicated proving pool for every subsequent job, while preparation can still succeed and readiness reports true. Since isolating proving from inherited background QoS is a stated goal of this change, log the source error before caching the fallback so persistent latency regressions can be diagnosed.
Out-of-scope follow-up suggestions (2)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Make pre-broadcast shielded note reservations cancellation-safe — The baseline already reserves notes before awaiting the recorded-anchor network query and activity recording, with cleanup performed only by a later result match. Dropping a native Rust send future can leave anchor-less reservations excluded from note selection and spendable balance; stale-pending recovery skips those reservations. The new proof await extends this existing cancellation window, but its closure does not broadcast and the synchronous FFI bridge drives sends to completion.
    • Follow-up: Track cancellation-safe reservation ownership separately, with an explicit handoff to conservative recovery before broadcasting and an operation-level cancellation test.
  • Guard the legacy shielded C spend exports against unwinding panics — The transfer, unshield, and withdrawal exports at shielded_send.rs lines 411, 489, and 563 call block_on_worker without an enclosing panic guard. On unwind-enabled builds, a worker panic reaches the bridge's join expect and then the extern "C" boundary, aborting the host before Swift or JNI can handle it. These exports are identically unguarded in the assigned baseline, and the new proof helper preserves their existing panic propagation.
    • Follow-up: Track boundary hardening separately: guard these exports with conservative spend-outcome codes and add unwind-enabled FFI panic coverage.

Comment on lines +223 to +224
.build()
.ok()

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.

🟡 Suggestion: Log the permanent fallback when the Apple proving pool cannot initialize

The inline fallback preserves functionality, but .ok() discards the pool initialization error and the process-global OnceLock permanently caches None. Even a transient thread-creation failure therefore disables the dedicated proving pool for every subsequent job, while preparation can still succeed and readiness reports true. Since isolating proving from inherited background QoS is a stated goal of this change, log the source error before caching the fallback so persistent latency regressions can be diagnosed.

Suggested change
.build()
.ok()
.build()
.inspect_err(|error| {
tracing::warn!(
%error,
"could not create Orchard proving pool; using inline work with inherited QoS for the process lifetime"
);
})
.ok()

source: gpt-6.1-sol (phase2-reviewer: rust-quality)

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Bots are done — your move: post /self-reviewed.
Full checklist in the description.

@github-actions github-actions Bot added waiting-self-review Waiting for the author to post /self-reviewed bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. and removed waiting-bots Waiting for the review bots to report on this head labels Oct 8, 2026
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Closing: after measuring, this PR doesn't give the app a speedup a user can see, and its main premise doesn't hold.

Darwin QoS is not inherited by new threads. A pthread created from a background-QoS pthread, or from a background or user-initiated GCD worker, starts at DEFAULT (checked with qos_class_self() on macOS 27 and in an iOS 27 simulator; tokio spawn_blocking threads and rayon's global pool behave the same). So the old code never built the key or proved at background QoS. The dedicated user-initiated rayon pool, the proving gate, and the new libc/rayon dependencies fix a problem that doesn't occur. They also put proof threads above the DEFAULT-QoS tokio workers that do networking and sync.

The other claims, measured (M4 Max, release, real proving key and a 2-action Shield proof, 8 interleaved old/new runs; desktop numbers):

  • A send started during warm-up waits only for the rest of the build: the old OnceLock::get_or_init already did this. Key ready after warm-up: 1180 ms before vs 1166 ms after.
  • Async workers aren't starved (886 → 24.5 ms): this only reproduces on a 1-worker runtime. platform-wallet-ffi's runtime is new_multi_thread() with the default worker count. On a 6-worker runtime the old inline proof caused no stall above noise (22–35 ms); with this PR it was 47–99 ms, because of the QoS pre-emption above.
  • Hosts can await readiness: this only replaces dashwallet-ios#1183's 250 ms readiness poll for its "preparing…" indicator. Sends never waited on that poll.

The cost is +994/-140 lines and a breaking Rust API change (ShieldedProver, &Arc<OrchardKeySet>). The latency wins in this series come from the prover itself (dashpay/halo2, dashpay/orchard) and from overlapping proving with network waits (#5282, which has its own spawn_blocking proof path).


🤖 Posted autonomously by Claude on behalf of pasta.

PastaPastaPasta added a commit to dashpay/dashwallet-ios that referenced this pull request Oct 14, 2026
The awaitable prepare API proposed in dashpay/platform#5278 was closed,
so polling `isShieldedProverReady` after `warmUpShieldedProver()` is the
supported way to wait for the key, not a stopgap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PastaPastaPasta added a commit to dashpay/dashwallet-ios that referenced this pull request Oct 29, 2026
The awaitable prepare API proposed in dashpay/platform#5278 was closed,
so polling `isShieldedProverReady` after `warmUpShieldedProver()` is the
supported way to wait for the key, not a stopgap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PastaPastaPasta added a commit to dashpay/dashwallet-ios that referenced this pull request Nov 4, 2026
The awaitable prepare API proposed in dashpay/platform#5278 was closed,
so polling `isShieldedProverReady` after `warmUpShieldedProver()` is the
supported way to wait for the key, not a stopgap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot-review-skipped A required review bot did not report; it was skipped by the window or by a person. waiting-self-review Waiting for the author to post /self-reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants