Skip to content

fix(platform-wallet): harden FFI runtime vs panic-abort + #4360 preflight findings - #4428

Closed
bfoss765 wants to merge 1 commit into
v4.2-devfrom
fix/ffi-panic-and-preflight
Closed

fix(platform-wallet): harden FFI runtime vs panic-abort + #4360 preflight findings#4428
bfoss765 wants to merge 1 commit into
v4.2-devfrom
fix/ffi-panic-and-preflight

Conversation

@bfoss765

Copy link
Copy Markdown
Collaborator

Summary

Hardens the shielded/platform-wallet FFI surface against a crate-wide panic-abort hazard and fixes the actionable preflight findings from #4360. Four independent fixes, one PR.

Verified with cargo test -p platform-wallet --features shielded (839 passed, 0 failed) and cargo test -p platform-wallet-ffi --features shielded (all passed); cargo clippy on both crates is clean.


1. HIGH — FFI panic-abort (rs-platform-wallet-ffi/src/runtime.rs)

block_on_worker did rt.spawn(future).await.expect("tokio worker panicked"). A panic in a spawned future becomes a JoinError; .expect(...) re-raised it, and that re-panic unwound across the extern "C" boundary → SIGABRT on the unwind (Android/host) build. The JNI shim's own catch_unwind (rs-unified-sdk-jni) sits above this callee and cannot intercept it, violating the workspace policy at Cargo.toml ("a JNI library must never abort the app process"). The crate had zero catch_unwind. #4348's 22 new marketplace entry points surfaced it, but it affected the whole async FFI surface.

Fix. Both shared runtime helpers now catch the panic and return a typed result instead of re-raising:

  • block_on_worker wraps the block_on body in catch_unwind and converts a panicked/cancelled task into the future's own output type via a new fail-closed RecoverWorkerPanic trait bound — Result<_, PlatformWalletError>Err(PlatformWalletError::InternalPanic(msg)), best-effort sync outputs ((), counters, sync summaries) → a logged empty value. InternalPanic reuses the generic ErrorUnknown FFI code (per "add or reuse an internal-panic code"); the panic text rides the message.
  • run_on_big_stack_thread maps a caught thread panic to an io::Error (already its return type — no bound needed); its lone caller already threads that into a PlatformWalletFFIResult.

On the iOS panic = "abort" profiles (dev-ios/release-ios) catch_unwind is inert by design — the process aborts at the panic site before any JoinError/catch_unwind is observed. This matches the in-tree note that catch_unwind cannot protect an abort build; the fix hardens the unwind builds without pretending to protect iOS, and says so in a comment. The success path is unchanged (a normally-completing future returns its value directly). The RecoverWorkerPanic bound is fail-closed: a future new call site with an unrecoverable output type will not compile until it opts into a recovery.

Test: a panicking future returns Err(InternalPanic) (gated to cfg(panic = "unwind")); the success path and the big-stack io::Error mapping are covered too.

2. MEDIUM-HIGH (#4360) — fee-reserve scaling (rs-platform-wallet/src/wallet/shielded/operations.rs)

shield_fee_reserve_credits returned a flat 2 × compute_minimum_shielded_fee(2) (~325.7M credits) regardless of input count, while the executor deducts the actual metered fee from input 0 (DeductFromInput(0)) and meters one SetBalanceToAddress per input, admitting up to 16. Headroom therefore thinned as inputs grew (to ~1.84x at 16, blind to count) — the estimate-vs-actual band that risks the InternalError/TxAction::Removed app-hash-divergence class (family of the mainnet shield-halt).

Fix. The reserve now scales with the admitted input count:
reserve(n) = F + n × per_input_write + F_headroom, where F = compute_minimum_shielded_fee(2) and per_input_write = SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES × (disk+processing) credits/byte — priced off the same versioned constants the executor reads, never hardcoded. The planner passes min(funded_candidates, max_address_inputs), an upper bound on the inputs any shield from the account uses; preflight and execution stay consistent because both re-plan through the one shielded_shield_plan_for_account. At n = 0 it equals the old flat 2×F (backward-compatible base).

Safety statement (per request). This is consensus-adjacent, so it is deliberately conservative and derived only from version constants. SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES (222) models a new-address AddBalanceToAddress write; a shield's SetBalanceToAddress reduces an existing balance (a value replacement), which meters no more than a fresh subtree write — so pricing every input at that figure is a provable upper bound. Numerically (latest constants): F = 162,851,200, per_input = 6,082,800, so reserve(16) = 423,027,200 = 2.6×F, which stays within the old 4×F guard (kept, not loosened) while restoring ~2.4x headroom against the modeled worst case at 16 inputs. Residual flagged for reviewer attention: the per-input term is an upper bound from an existing calibrated constant, not a re-derivation of drive's exact SetBalanceToAddress metering. If drive can ever charge more than a new-address write per input, raise the per-input byte model or SHIELD_FEE_RESERVE_HEADROOM_FEES. New tests cover the 16-input boundary and strict monotonicity in input count.

3. MEDIUM (#4360) — per-input shortfall mis-mapped to account capacity (operations.rs::map_shield_input_fetch_error)

A strictly per-address AddressNotEnoughFundsError was remapped to PlatformShieldCapacityExceeded { available: short.balance(), required: short.required_balance() } — account-capacity semantics — and the offending address was dropped. A host reading available as the account max understates capacity by up to the input-count cap (~16x).

Fix. Mapped instead to a distinct PlatformShieldInputShortfall { address, available, required } variant whose Display restores the bech32m address and frames the figures as one input's live values. It rides the same FFI code (ErrorShieldedInsufficientBalance = 41) — the host's corrective action (refresh preflight, retry) is identical, and a distinct FFI code was deliberately avoided so as not to collide with the in-flight code-space frontier (46 per #4318); the disambiguation lives in the variant + message. map_spend_result routes both variants to code 41 (an or-pattern) so the new variant does not regress to the generic ErrorWalletOperation.

4. MEDIUM (#4360) — self-contradictory available on a fragmented account (platform_wallet.rs::select_inputs)

When usable_candidates was empty, available fell back to account_balance_credits while required stayed amount + fee_reserve, so a failed shield could report available > required (fragmented account with a large total but no usable input).

Fix. available is now the usable-for-shield amount (usable_balance_credits, i.e. 0 when no candidate can retain the reserve), coherent with required. The full account balance remains on the preflight snapshot / reason for display. Test added; the one existing test that encoded the old behavior was updated to the coherent value.


Deferred (noted, not in this PR)

🤖 Generated with Claude Code

…ight findings

Four fixes across the shielded/platform-wallet FFI surface:

1. HIGH — FFI panic-abort. `block_on_worker`'s `.expect("tokio worker
   panicked")` re-raised a spawned-task `JoinError`, unwinding across the
   `extern "C"` boundary and SIGABRT-ing the host on the unwind (Android/host)
   build; the JNI shim's catch_unwind sits above the callee and cannot
   intercept it (Cargo.toml policy). Both shared runtime helpers now catch the
   panic and convert it to a typed result — `block_on_worker` via a fail-closed
   `RecoverWorkerPanic` bound (-> `PlatformWalletError::InternalPanic`, reusing
   the `ErrorUnknown` FFI code), `run_on_big_stack_thread` via `io::Error` —
   instead of re-raising. Inert on the abort-configured iOS profiles by design.

2. MEDIUM-HIGH (#4360) — the shield input-0 fee reserve now scales with the
   admitted input count, pricing the metered per-input `SetBalanceToAddress`
   write off the same versioned storage rate the executor reads (conservative
   upper bound via `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`). A 16-input Max
   shield keeps healthy headroom instead of thinning to ~1.84x; reserve stays
   <= 4xF at the protocol-max input count.

3. MEDIUM (#4360) — a strictly per-input `AddressNotEnoughFundsError` is mapped
   to a distinct `PlatformShieldInputShortfall` variant that restores the
   bech32m address in the message, instead of the account-capacity
   `PlatformShieldCapacityExceeded` (whose `available` a host misreads as the
   account max). Rides the same FFI code (41) — same host action.

4. MEDIUM (#4360) — a fragmented-account shield failure no longer reports
   `available` = full account balance (self-contradictory `available >
   required`); `available` is now the usable-for-shield amount (0 when no
   candidate can retain the reserve).

Tests: cargo test -p platform-wallet --features shielded (839 passed);
cargo test -p platform-wallet-ffi --features shielded (all passed); clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@bfoss765, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 57 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5bab8ead-7d3b-42fa-a8ea-a38c9ac3373f

📥 Commits

Reviewing files that changed from the base of the PR and between 2fd1684 and 7e96d12.

📒 Files selected for processing (6)
  • packages/rs-platform-wallet-ffi/src/error.rs
  • packages/rs-platform-wallet-ffi/src/runtime.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/error.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs

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.

@thepastaclaw

thepastaclaw commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

🕓 Ready for review — 3 ahead in queue (commit 7e96d12)
Queue position: 4/4 · 2 reviews active
ETA: start ~21:34 UTC · complete ~21:49 UTC (median 14m across 30 recent reviews; 2 slots)
Queued 1h 12m ago · Last checked: 2026-08-19 21:10 UTC

@github-actions github-actions Bot added this to the v4.2.0 milestone Aug 19, 2026
@bfoss765

Copy link
Copy Markdown
Collaborator Author

Closing to keep review surfaces clean: the panic-containment half of this PR is superseded by #4424, which covers the same runtime.rs seam plus the ~46 direct runtime().block_on sites this PR's own body notes as unhardened. The unique half — the #4360 preflight fixes (input-count-scaled fee reserve, typed per-input shortfall with the address restored, coherent available/required on fragmented accounts) — is being re-cut as a focused standalone PR so it can be reviewed without the overlapping panic diff. Apologies for the noise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants