Skip to content

feat(platform-wallet): multi-output shielded transfers + output-aware fee predictor (two-note invites) - #4312

Open
bfoss765 wants to merge 2 commits into
v4.2-devfrom
feat/shielded-two-note-invites
Open

feat(platform-wallet): multi-output shielded transfers + output-aware fee predictor (two-note invites)#4312
bfoss765 wants to merge 2 commits into
v4.2-devfrom
feat/shielded-two-note-invites

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Continues #4301 — moved from a fork branch to an in-repo branch (rebased onto v4.2-dev post-#4305) so maintainers can push changes directly, per review request. Full review history on #4301.


What this closes

A shielded invite is funded as one note. When that note is later spent to
claim the invite, Orchard's BundleType::DEFAULT pads the single-spend bundle
up to MIN_ACTIONS = 2, and the padding action's dummy nullifier is randomly
generated
(orchard builder.rs:37,76-99; note.rs:227-243;
nullifier.rs:53-55).

The new identity's id is double_sha256(sorted PUBLISHED nullifiers)
identity_id_from_nullifiers, computed over every action's nullifier,
padding included, because consensus re-derives it the same way and dummies are
indistinguishable by design.

So with one real spend the claim's identity id contains fresh randomness. It
cannot be predicted before the build, and — the part that actually hurts — it
cannot be re-derived on a retry: a second attempt builds a different dummy
and therefore a different id. Any idempotent claim-recovery step that asks "did
my earlier attempt already create this identity?" has no expected id to compare
against and must fail closed.

With two or more real spends no padding action is added, every published
nullifier is the deterministic nullifier of a real note, and the id is a pure
function of the spent note set — predictable and reproducible.

The fix: fund with two notes, not one

Fund the one-time address with two notes that each hold less than the
target
, in one atomic transfer — D split as floor(D/2) + ceil(D/2).

Note selection is greedy largest-first and breaks as soon as the accumulated
value covers the target (note_selection.rs:117-135). Neither half covers D
on its own, so both are structurally forced into the claim bundle. There is
no heuristic to tune and no way for the selector to pick just one.

This is why it is two sub-target notes rather than "a main note plus a small
anchor": with a main note that already covers the target, the selector would
stop after it and the padding action would come straight back.

The claim path needs no change. It already branches on
selected_notes.len() >= 2 when deciding whether an expected identity id can
be derived. This PR changes the note layout so that branch is always taken;
the recovery logic itself is untouched.

The fee predictor — why it must ship in the same PR

build_shielded_transfer_transition sized its fee from
spends.len().max(2), ignoring the output count.

An Orchard action is a joined spend/output slot: the on-wire action count is
max(num_spends, num_outputs), padded to MIN_ACTIONS = 2. A
ShieldedTransfer's value_balance is its fee, and consensus pins it to
compute_minimum_shielded_fee(actions.len()) exactly
validate_minimum_shielded_fee rejects under-payment and over-payment for
this transition (amount_is_pure_fee).

Two-note funding means 2 recipient outputs + change = 3 outputs. A
spends-only predictor would carve min_fee(2) while consensus demanded
min_fee(3), and the transfer would be rejected on chain. So the two-note
layout is simply not constructible until this is fixed — the two changes cannot
be split.

The old form was correct by accident for every existing caller, because
max(n, 1).max(2) == max(n, 2).max(2) — with at most two outputs the output
side can never set the action count. That is why the bug is latent today rather
than a live failure.

Both builders now size the fee through a shared shielded_bundle_action_count,
which delegates to Orchard's own BundleType::num_actions rather than
re-deriving the rule, so the predictor cannot drift from the builder that
actually lays out the bundle.

Deterministic bundle shape

The multi-output builder always emits a change output and requires the
spent value to strictly exceed sum(amounts) + fee.

That removes a genuine circularity: whether a change output exists depends on
the fee, and the fee depends on the output count. Pinning the change output as
unconditional makes the action count — and therefore the fee — a pure function
of the inputs: max(spends, recipients + 1, 2). Note selection reserves
against the same recipients + 1 floor, so the reserved fee and the carved fee
cannot diverge. A caller spending exactly sum + fee is rejected with a
clear error rather than silently re-shaped into a differently-priced bundle.

Cost

Creation side: one extra Orchard action, min_fee(3) - min_fee(2)
= 31,425,600 credits = +0.000314256 DASH per invite
(0.001628512 → 0.001942768 DASH).

Claim side: unchanged. The claim spends two notes instead of one, but
max(2 spends, 1 change output, 2) = 2 actions either way — the padding action
it replaces was already being paid for.

Legacy one-note invites are intentionally unsupported

No transitional or back-compat path is included. One-note invites have never
existed on mainnet, so there is nothing to migrate. shielded_identity_id_is_reproducible
states the rule as a single predicate next to the id derivation it guards:
callers that must recognise an identity their earlier attempt created gate on
the note count — no chain lookup, decided before any proving work — and treat a
non-reproducible set as unrecoverable rather than computing an id that will
never match.

Layers

Layer Added
rs-dpp shielded_bundle_action_count, ShieldedTransferOutput, build_shielded_transfer_transition_multi, shielded_identity_id_is_reproducible
rs-platform-wallet operations::transfer_multi, PlatformWallet::shielded_transfer_multi_to
rs-platform-wallet-ffi platform_wallet_manager_shielded_transfer_multi
rs-unified-sdk-jni + kotlin-sdk shieldedTransferMulti

Invite link format, fundingCredits and the exit-denomination ladder are
unchanged — this PR changes how a value is laid out across notes, not the
value. The V13 denomination set constrains the exit amount, not individual
note values.

Tests

  • multi_output_transfer_fee_matches_on_wire_action_count — builds a real
    2-spend / 3-output bundle and pins
    value_balance == fee == min_fee(actions.len()) == min_fee(3), explicitly
    asserting it is not min_fee(2). Also asserts the two outputs paid to the
    same address become two distinct note commitments.
  • single_output_transfer_fee_matches_on_wire_action_count — pins the
    single-output builder against a real bundle so the shared helper cannot
    regress it.
  • shielded_bundle_action_count_is_max_spends_outputs_padded_to_two and
    ..._matches_a_real_bundle — pin the predictor, including the
    output-dominated shapes a spends-only predictor gets wrong.
  • test_two_sub_denomination_notes_are_both_selected /
    test_single_full_denomination_note_selects_alone — pin the selector
    behaviour the whole design rests on, for both shipped denominations.
  • test_select_notes_with_fee_reserves_multi_output_action_floor — the
    wallet reserves the 3-action fee, not the 2-action floor.
  • The existing padding tests now also assert
    shielded_identity_id_is_reproducible, tying the predicate to observed
    builder behaviour rather than leaving it a bare constant.

Results: 216 dpp shielded tests and 662 platform-wallet lib tests pass.
rustfmt clean; clippy --all-targets -D warnings introduces no new findings
(the pre-existing findings in recovery.rs, withdrawal.rs, persistence.rs
and core_wallet_types.rs are identical on the untouched base).

Follow-ups (deliberately not in this PR)

  • Swift parity for platform_wallet_manager_shielded_transfer_multi. The
    cbindgen header is generated at build time and nothing in the Swift SDK
    references the new symbol, so the Swift build is unaffected.
  • App-side adoption — switching invite funding to two outputs needs an SDK
    artifact carrying shieldedTransferMulti, so it lands with the next AAR.
  • Wiring the guard into the claim scan — the claim path lives in a separate
    in-flight PR; this PR supplies the predicate it should call.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for atomic shielded transfers to multiple recipients in a single transaction.
    • Send funds to up to 16 recipients, with optional memos applied to each transfer.
    • Added validation for recipient addresses, positive amounts, recipient limits, and available change.
    • Added support across the wallet and Kotlin SDK interfaces.
  • Reliability

    • Improved fee calculation, balance selection, and transaction handling for multi-recipient transfers.
    • Added safeguards for invalid requests and unexpected processing failures.

bfoss765 and others added 2 commits August 5, 2026 21:08
Adds a multi-output ShieldedTransfer so one transition can fund an address
with several notes, and fixes the fee predictor that made such a transfer
impossible to construct.

## The fee predictor (blocking bug)

`build_shielded_transfer_transition` sized its fee from `spends.len().max(2)`,
ignoring the output count. An Orchard action is a joined spend/output slot, so
the on-wire action count is `max(num_spends, num_outputs)` padded to
`MIN_ACTIONS = 2`. A ShieldedTransfer's `value_balance` IS its fee and
consensus pins it to `compute_minimum_shielded_fee(actions.len())` EXACTLY
(`validate_minimum_shielded_fee` rejects under- AND over-payment), so any
transfer publishing three or more outputs would carve `min_fee(2)` while
consensus demanded `min_fee(3)` and be rejected on chain.

The spends-only form happened to be correct while `num_outputs <= 2`, because
`max(n, 1).max(2) == max(n, 2).max(2)` — which is why the single-output builder
never hit it. Both builders now size the fee through a shared
`shielded_bundle_action_count`, which delegates to Orchard's own
`BundleType::num_actions` so the predictor cannot drift from the builder that
lays out the bundle.

## Why several outputs

Orchard pads any bundle to two actions, and a padding action's dummy nullifier
is randomly generated. An identity id derived from published nullifiers is
therefore only reproducible offline when at least two REAL notes are spent —
with one real note a retry builds a different dummy and a different id.

Funding an address with two sub-target notes instead of one full-target note
structurally forces a later spend to select BOTH: greedy largest-first
selection cannot stop on a note that does not cover the target. That keeps the
padding action, and its random nullifier, out of the bundle.

`shielded_identity_id_is_reproducible` states that rule as one predicate next
to the id derivation it guards, so callers that must recognise an identity
their earlier attempt created gate on the note count — no chain lookup, decided
before any proving work.

## Shape

The multi-output builder ALWAYS emits a change output and requires the spent
value to strictly exceed `sum(amounts) + fee`. That makes the output count —
and hence the action count and the fee — a pure function of the inputs
(`max(spends, recipients + 1, 2)`), with no circular dependency between "is
there change?" and "what is the fee?". Note selection reserves against the same
`recipients + 1` floor, so the reserved and carved fees cannot diverge.

Repeating the same address across outputs is allowed and is the point: Orchard
derives independent notes regardless.

## Layers

- rs-dpp: `shielded_bundle_action_count`, `ShieldedTransferOutput`,
  `build_shielded_transfer_transition_multi`, `shielded_identity_id_is_reproducible`
- rs-platform-wallet: `operations::transfer_multi`, `PlatformWallet::shielded_transfer_multi_to`
- rs-platform-wallet-ffi: `platform_wallet_manager_shielded_transfer_multi`
- rs-unified-sdk-jni + kotlin-sdk: `shieldedTransferMulti`

## Tests

- `multi_output_transfer_fee_matches_on_wire_action_count` builds a REAL
  2-spend/3-output bundle and pins `value_balance == fee ==
  min_fee(actions.len()) == min_fee(3)`, asserting it is NOT `min_fee(2)`.
- `single_output_transfer_fee_matches_on_wire_action_count` pins the
  single-output builder against a real bundle so the shared helper cannot
  regress it.
- `shielded_bundle_action_count_*` pin the predictor as `max(spends, outputs)`
  padded to 2, and against a real bundle's on-wire count.
- `test_two_sub_denomination_notes_are_both_selected` /
  `test_single_full_denomination_note_selects_alone` pin the selector
  behaviour the two-note layout depends on.
- The existing padding tests now also assert
  `shielded_identity_id_is_reproducible`.

Swift parity for the new entry point is a follow-up; the cbindgen header is
generated at build time and nothing in the Swift SDK references the new symbol,
so the Swift build is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ote selection, FFI panic guard, JNI allocation bound

Addresses the four findings on #4301 (2 blocking, 2 suggestions).

## BLOCKING — reject bundles over the consensus action limit before proving

`shielded_bundle_action_count` computed the on-wire action count but never
compared it with `platform_version.system_limits.max_shielded_transition_actions`
(16). `ShieldedTransferTransitionV0::validate_structure` rejects anything above
that limit, while `try_from_bundle` performs no structural validation — so the
FFI's 16 recipients (17 outputs once the unconditional change output is added,
therefore >= 17 actions), or a fragmented wallet's spend count, would build and
prove a bundle (~30 s of Halo 2) that consensus is guaranteed to reject.

The helper now takes `platform_version` and validates the computed count.
Because the count is `max(spends, outputs)` padded to 2, the single comparison
bounds BOTH sides. Both transfer builders route through it, so the rejection
happens before any spend is added to the Orchard builder.

## BLOCKING — reserve enough input to guarantee positive change

`select_notes_with_fee` accepted `total_input == amount + exact_fee`, but
`build_shielded_transfer_transition_multi` emits an unconditional change output
and rejects equality. With notes `[amount + fee, 1]`, largest-first selection
reserved the exact-coverage note alone and the build then failed even though
taking the remaining credit would have satisfied the builder.

Note selection now carries a `ChangeRequirement`. `StrictlyPositive` (the
multi-output transfer) folds one credit into the selection target and into the
sufficiency test on every convergence iteration, so the strict postcondition
holds against the RE-COMPUTED fee after an added note changes the action count.
The other three spends keep `Optional` — their builders accept zero change.
The returned fee stays the pure consensus fee the builder carves.

## SUGGESTION — catch panics before crossing the C ABI

A panic cannot unwind through `extern "C"`: it aborts the process before the
JNI layer's `support::guard` can turn it into a Java exception. `block_on_worker`
makes this reachable — it `.expect`s on the tokio `JoinError`, so a panicking
proving task re-panics inside the export. The multi-output transfer export's
body moved into a plain Rust function invoked under `catch_unwind`. A caught
panic maps to `ErrorShieldedSpendUnconfirmed`, whose contract is exactly the
conservative one required: the spend may have been broadcast, the reservation
stays, and the host must not auto-retry.

## SUGGESTION — enforce the recipient bound before allocating

The JNI adapter copied the whole Java recipient array and both amount buffers
before the native ceiling could reject the call. It now reads both array
LENGTHS first (header reads, no allocation), rejects counts above
`MAX_SHIELDED_TRANSFER_RECIPIENTS` (now public so the bridges share the
constant instead of duplicating the literal), and only then converts — so every
allocation is bounded by the ceiling, not by the caller.
`PlatformWalletManager.shieldedTransferMulti` mirrors the check before it
flattens its own buffers.

Tests: action-count boundary passes / one over fails fast from both the output
and spend sides (helper + builder level); the `[amount + fee, 1]` exact-fit case
now selects both notes, one credit short reports the extra credit in `required`,
and the strict floor survives fee re-convergence; the FFI panic guard maps a
panic to the unconfirmed contract and is transparent otherwise.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds atomic multi-recipient shielded transfers for up to 16 recipients. It adds Orchard bundle construction, action-count validation, strict-change note selection, wallet operations, FFI and JNI bridges, and Kotlin APIs with memo support.

Changes

Multi-output shielded transfer

Layer / File(s) Summary
DPP bundle construction and validation
packages/rs-dpp/src/shielded/builder/*, packages/rs-dpp/src/state_transition/...
The DPP layer exports multi-output transfer construction, validates Orchard action counts against platform limits, sizes fees from on-wire actions, requires positive change, and verifies identity-ID reproducibility behavior.
Note selection and wallet operation
packages/rs-platform-wallet/src/wallet/shielded/*
Wallet note selection now supports optional or strictly positive change. Multi-output operations reserve notes, build one transition, record activity, broadcast, and handle reservation outcomes.
Platform wallet and FFI execution
packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet-ffi/src/shielded_send.rs
The platform wallet parses multiple Orchard addresses. The FFI entry point validates inputs, resolves keys, proves transfers on a worker thread, and maps panics and operation results.
JNI and Kotlin API wiring
packages/rs-unified-sdk-jni/src/funding.rs, packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/{ffi, wallet}/*
JNI and Kotlin APIs accept flattened recipient addresses, matching amounts, and an optional memo. They enforce input sizes, positive amounts, valid accounts, and the 16-recipient limit.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlatformWalletManager
  participant FundingNative
  participant JNI
  participant PlatformWalletFFI
  participant PlatformWallet
  participant ShieldedOperations
  PlatformWalletManager->>FundingNative: Submit recipients, amounts, and memo
  FundingNative->>JNI: Invoke shieldedTransferMulti
  JNI->>PlatformWalletFFI: Pass validated native buffers
  PlatformWalletFFI->>PlatformWallet: Resolve wallet and account authority
  PlatformWallet->>ShieldedOperations: Build and broadcast atomic transfer
  ShieldedOperations-->>PlatformWalletManager: Return transfer result
Loading

Possibly related PRs

  • dashpay/platform#4301: Implements the same multi-output shielded transfer and output-aware fee handling changes.
  • dashpay/platform#4204: Changes several shared shielded-transfer API and FFI files, including the Kotlin and native funding interfaces.

Suggested reviewers: quantumexplorer, shumkov

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main changes: multi-output shielded transfers and an output-aware fee predictor for two-note invites.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shielded-two-note-invites

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

@thepastaclaw

thepastaclaw commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit 6e59784)
Canonical validated blockers: 1

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.66314% with 155 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.54%. Comparing base (b703f82) to head (6e59784).
⚠️ Report is 8 commits behind head on v4.2-dev.

Files with missing lines Patch % Lines
...s/rs-dpp/src/shielded/builder/shielded_transfer.rs 76.79% 100 Missing ⚠️
packages/rs-dpp/src/shielded/builder/mod.rs 65.45% 38 Missing ⚠️
...entity_create_from_shielded_pool_transition/mod.rs 18.75% 13 Missing ⚠️
...lded/builder/identity_create_from_shielded_pool.rs 60.00% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4312      +/-   ##
============================================
- Coverage     87.78%   87.54%   -0.25%     
============================================
  Files          2677     2704      +27     
  Lines        342371   346002    +3631     
============================================
+ Hits         300551   302896    +2345     
- Misses        41820    43106    +1286     
Components Coverage Δ
dpp 88.53% <72.66%> (-0.31%) ⬇️
drive 86.25% <ø> (ø)
drive-abci 89.66% <ø> (-0.01%) ⬇️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.88% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 48.02% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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.

Preliminary review — Codex only

Four carried-forward findings are fixed at the current head; there are no genuinely new current-PR findings in this revalidation. The prior blocker concerning the effective 20 KiB transition-size limit remains valid because seven-action bundles still reach expensive Halo 2 proving before guaranteed rejection. Source: reviewers codex/general=gpt-5.6-sol(completed); codex/security-auditor=gpt-5.6-sol(completed); codex/rust-quality=gpt-5.6-sol(completed); codex/ffi-engineer=gpt-5.6-sol(completed); verifier=codex/verifier=gpt-5.6-sol(completed); coordinator=openclaw-agent/cliproxy/gpt-5.6-sol(orchestration-only).

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — security-auditor (completed), gpt-5.6-sol — rust-quality (completed), gpt-5.6-sol — ffi-engineer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking

🤖 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-dpp/src/shielded/builder/mod.rs`:
- [BLOCKING] packages/rs-dpp/src/shielded/builder/mod.rs:151-160: Reject bundles over the effective transition-size limit before proving
  `shielded_bundle_action_count` only rejects action counts above `max_shielded_transition_actions`, currently 16, and does not account for the tighter versioned `max_state_transition_size` of 20,480 bytes. The platform-version constants document that six shielded actions serialize within this limit while seven require approximately 21.6 KiB. Both transfer builders call this helper before proceeding to `prove_and_sign_bundle`, so six recipients plus the multi-output builder's mandatory change output, or a wallet selecting seven spends, pass the gate and perform expensive Halo 2 proving even though DAPI's byte prefilter and Drive-ABCI's consensus decoder must reject the serialized transition. This is externally reachable because the Kotlin, JNI, and C boundaries admit up to 16 recipients. Enforce a platform-version-aware pre-proving ceiling derived from both the structural action limit and the serialized transition-size limit, test output- and spend-dominated seven-action shapes, and align the public recipient ceiling with the effective limit; under the current 20 KiB limit, at most five recipients fit beside mandatory change.

Comment on lines +151 to +160
let max_actions = platform_version
.system_limits
.max_shielded_transition_actions as usize;
if num_actions > max_actions {
return Err(ProtocolError::ShieldedBuildError(format!(
"a bundle of {num_spends} spends and {num_outputs} outputs publishes {num_actions} \
Orchard actions, exceeding the consensus limit of {max_actions} \
(max_shielded_transition_actions); consensus would reject the proved transition"
)));
}

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.

🔴 Blocking: Reject bundles over the effective transition-size limit before proving

shielded_bundle_action_count only rejects action counts above max_shielded_transition_actions, currently 16, and does not account for the tighter versioned max_state_transition_size of 20,480 bytes. The platform-version constants document that six shielded actions serialize within this limit while seven require approximately 21.6 KiB. Both transfer builders call this helper before proceeding to prove_and_sign_bundle, so six recipients plus the multi-output builder's mandatory change output, or a wallet selecting seven spends, pass the gate and perform expensive Halo 2 proving even though DAPI's byte prefilter and Drive-ABCI's consensus decoder must reject the serialized transition. This is externally reachable because the Kotlin, JNI, and C boundaries admit up to 16 recipients. Enforce a platform-version-aware pre-proving ceiling derived from both the structural action limit and the serialized transition-size limit, test output- and spend-dominated seven-action shapes, and align the public recipient ceiling with the effective limit; under the current 20 KiB limit, at most five recipients fit beside mandatory change.

source: ['codex']

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- Around line 375-405: Make the panic protection effective for iOS by
configuring dev-ios and release-ios with unwinding panics, or otherwise add a
non-aborting FFI boundary. Extend catch_spend_panic or equivalent guards to
every remaining block_on_worker export, including transfer, unshield, withdraw,
shield, identity creation, and asset-lock funding. Preserve each operation’s
result contract, using an appropriate identity-creation error code instead of
ErrorShieldedSpendUnconfirmed where required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: aa76d8d8-c226-48fa-8cd3-0feaca7c1c52

📥 Commits

Reviewing files that changed from the base of the PR and between b703f82 and 6e59784.

📒 Files selected for processing (11)
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs
  • packages/rs-dpp/src/shielded/builder/mod.rs
  • packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
  • packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-unified-sdk-jni/src/funding.rs

Comment on lines +375 to +405
/// Run a shielded-spend export body under [`std::panic::catch_unwind`], converting a panic into a
/// typed FFI error instead of letting it reach the `extern "C"` frame.
///
/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer
/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but
/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the
/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it
/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis,
/// note bookkeeping, the SDK) re-panics right here inside the export.
///
/// The panic is mapped to [`PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed`], NOT to
/// a definitive failure code: a panic can strike after the notes were reserved and even after the
/// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is
/// exactly the conservative one this needs — the host must not auto-retry, the reservation stays
/// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed.
fn catch_spend_panic(
operation: &str,
body: impl FnOnce() -> PlatformWalletFFIResult,
) -> PlatformWalletFFIResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
Ok(result) => result,
Err(payload) => PlatformWalletFFIResult::err(
PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed,
format!(
"{operation} panicked: {}. The spend may or may not have been broadcast — do \
NOT retry; the next shielded sync reconciles the outcome.",
panic_payload_message(payload.as_ref())
),
),
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find shielded-spend FFI exports and check their panic handling + panic strategy.
set -euo pipefail

echo "=== extern \"C\" exports in shielded_send.rs ==="
ast-grep run --pattern 'pub unsafe extern "C" fn $NAME($$$) -> $RET { $$$ }' --lang rust packages/rs-platform-wallet-ffi/src/shielded_send.rs 2>/dev/null | head -200

echo "=== block_on_worker call sites in the crate ==="
rg -nP --type=rust -C3 '\bblock_on_worker\s*\(' packages/rs-platform-wallet-ffi/src

echo "=== catch_spend_panic call sites ==="
rg -nP --type=rust -C2 '\bcatch_spend_panic\s*\(' packages/rs-platform-wallet-ffi/src

echo "=== panic strategy in workspace / crate manifests ==="
fd -t f 'Cargo.toml' -d 3 | xargs rg -nP -C3 'panic\s*=' || echo "no explicit panic strategy found"

Repository: dashpay/platform

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== shielded_send.rs exported functions ==='
python3 - <<'PY'
from pathlib import Path
import re
p = Path("packages/rs-platform-wallet-ffi/src/shielded_send.rs")
s = p.read_text()
pat = re.compile(r'pub\s+unsafe\s+extern\s+"C"\s+fn\s+([A-Za-z0-9_]+)')
for m in pat.finditer(s):
    start = m.start()
    line = s.count("\n", 0, start) + 1
    brace = s.find("{", m.end())
    body = s[brace:s.find("\n}", brace) + 2] if brace >= 0 else ""
    print(f"{line}: {m.group(1)}; direct_guard={bool(re.search(r'catch_spend_panic\\s*\\(', body))}; direct_block_on_worker={bool(re.search(r'\\bblock_on_worker\\s*\\(', body))}')
PY

echo '=== relevant call sites and helper definitions ==='
rg -n -C4 'catch_spend_panic|block_on_worker|shielded_transfer_multi_inner|identity.*create|create.*identity' "$file" packages/rs-platform-wallet-ffi/src/runtime.rs packages/rs-platform-wallet-ffi/src 2>/dev/null | rg -n 'shielded_send.rs|runtime.rs|block_on_worker|catch_spend_panic|shielded_transfer_multi_inner|identity.*create|create.*identity' | head -240

echo '=== panic strategy declarations ==='
fd -t f 'Cargo.toml' -d 5 | while read -r f; do
  rg -n -C3 'panic\s*=' "$f" || true
done
rg -n -C3 'panic\s*=' .github packages 2>/dev/null | head -160 || true

Repository: dashpay/platform

Length of output: 632


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file=packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== shielded_send.rs exported functions ==='
python3 - <<'PY'
from pathlib import Path
import re

p = Path("packages/rs-platform-wallet-ffi/src/shielded_send.rs")
s = p.read_text()
pat = re.compile(r'pub\s+unsafe\s+extern\s+"C"\s+fn\s+([A-Za-z0-9_]+)')
for m in pat.finditer(s):
    start = m.start()
    line = s.count("\n", 0, start) + 1
    brace = s.find("{", m.end())
    end = s.find("\n}", brace) if brace >= 0 else -1
    body = s[brace:end + 2] if brace >= 0 and end >= 0 else ""
    name = m.group(1)
    guarded = bool(re.search(r'catch_spend_panic\s*\(', body))
    worker = bool(re.search(r'\bblock_on_worker\s*\(', body))
    print(f"{line}: {name}; direct_guard={guarded}; direct_block_on_worker={worker}")
PY

echo '=== relevant call sites and helper definitions ==='
rg -n -C4 'catch_spend_panic|block_on_worker|shielded_transfer_multi_inner|identity.*create|create.*identity' \
  "$file" packages/rs-platform-wallet-ffi/src/runtime.rs packages/rs-platform-wallet-ffi/src 2>/dev/null \
  | rg 'shielded_send.rs|runtime.rs|block_on_worker|catch_spend_panic|shielded_transfer_multi_inner|identity.*create|create.*identity' \
  | head -240

echo '=== panic strategy declarations ==='
fd -t f 'Cargo.toml' -d 5 | while read -r f; do
  rg -n -C3 'panic\s*=' "$f" || true
done
rg -n -C3 'panic\s*=' .github packages 2>/dev/null | head -160 || true

Repository: dashpay/platform

Length of output: 26633


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== block_on_worker implementation ==='
sed -n '45,70p' packages/rs-platform-wallet-ffi/src/runtime.rs

echo '=== shielded operation helpers and mappings ==='
sed -n '760,835p' packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== unshield and withdraw ==='
sed -n '601,778p' packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== identity create ==='
sed -n '889,1038p' packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== shield ==='
sed -n '1060,1160p' packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== fund, resume, and seed exports ==='
sed -n '1162,1535p' packages/rs-platform-wallet-ffi/src/shielded_send.rs

echo '=== panic strategy declarations ==='
find . -name Cargo.toml -print0 | while IFS= read -r -d '' f; do
  matches=$(rg -n 'panic\s*=' "$f" || true)
  if [ -n "$matches" ]; then
    printf '%s\n%s\n' "--- $f" "$matches"
  fi
done

Repository: dashpay/platform

Length of output: 38757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== root Cargo profile and workspace context ==='
sed -n '1,100p' Cargo.toml

echo '=== FFI crate manifest and workspace membership ==='
fd -t f 'Cargo.toml' | while read -r f; do
  if rg -q 'rs-platform-wallet-ffi|platform-wallet-ffi' "$f"; then
    echo "--- $f"
    sed -n '1,140p' "$f"
  fi
done

echo '=== package-specific panic/profile overrides ==='
rg -n -C3 '^\[profile|panic\s*=|platform-wallet-ffi|rs-platform-wallet-ffi' --glob 'Cargo.toml' .

Repository: dashpay/platform

Length of output: 20199


Make the panic guard effective in iOS builds and extend it to worker-backed exports.

dev-ios and release-ios set panic = "abort", so catch_unwind cannot prevent process termination. Use panic = "unwind" or another non-aborting FFI boundary. Then guard the remaining block_on_worker exports, including transfer, unshield, withdraw, shield, identity creation, and asset-lock funding. Preserve operation-specific result contracts; identity creation cannot always return ErrorShieldedSpendUnconfirmed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/rs-platform-wallet-ffi/src/shielded_send.rs` around lines 375 - 405,
Make the panic protection effective for iOS by configuring dev-ios and
release-ios with unwinding panics, or otherwise add a non-aborting FFI boundary.
Extend catch_spend_panic or equivalent guards to every remaining block_on_worker
export, including transfer, unshield, withdraw, shield, identity creation, and
asset-lock funding. Preserve each operation’s result contract, using an
appropriate identity-creation error code instead of
ErrorShieldedSpendUnconfirmed where required.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes to hold this PR while we settle the invitation architecture as a package — this is a sequencing block, not an implementation critique. The mechanism itself is sound: two sub-target halves structurally force a two-spend claim, the identity id becomes a pure function of the spent note set (reproducible from seed + invite secret alone, surviving device loss), and the output-aware fee predictor is a genuine prerequisite. The problem is that this PR commits us to an on-chain funding-shape convention that is effectively permanent, and we have an open design question about exactly that shape.

1. The funding transaction carries a shape fingerprint

Single-transaction funding is unavoidably a 3-action bundle: two recipient halves + change. Change cannot be avoided in one transaction because consensus pins value_balance to the metered fee exactly — over-payment is rejected (amount_is_pure_fee), so leftover input value must return as a change note, forcing the third output/action.

The claim side is perfectly indistinguishable (2 actions, like every shielded spend — dummy and real nullifiers are indistinguishable by design). But on the funding side, multi-output transfers are rare today, so 3-action transfers would initially correlate strongly with "an invitation was just funded." An observer cannot link a funding to its claim (outputs are shielded, nullifiers reveal nothing), but can estimate invitation volume and timing network-wide. The anonymity set grows as batch payments adopt multi-output — but at launch, the correlation is real.

2. There is a change-free variant we should decide on BEFORE the layout ships

Pre-split funding: (1) the inviter self-sends a note of exactly D + fee₂ (an ordinary 1-recipient + change, 2-action transfer — indistinguishable from any payment); (2) that exact note is spent into the two halves with no change — 1 spend, 2 outputs, 2 actions, also indistinguishable, and both halves still land atomically. The two transactions are unlinkable on-chain. Cost: one extra fee, one extra broadcast, and reserving the exact note between steps.

This erases the fingerprint entirely with zero cryptographic novelty. The open decision: is pre-split the default invite funding flow, an opt-in "private funding" mode, or skipped? Deciding after launch is the worst option — invites funded under different layouts form permanently distinguishable cohorts, which is itself a privacy cost.

3. Alternatives considered and rejected (for the record)

We evaluated deriving the padding dummy nullifier deterministically (PRF keyed on the one-time secret + real nullifier set) so single-note invites would have reproducible ids. Rejected on risk grounds despite being client-side-only: (a) scope-bleed hazards — the deterministic seed must never reach signature nonces / value-commitment trapdoors / proof blinding, a silent-failure invariant every future builder refactor must preserve; (b) library-version drift — RNG-seeded determinism rides on orchard's internal draw order, so a dependency bump silently changes derived ids across app versions; (c) indistinguishability becomes conditional on PRF soundness and exact sampling distributions instead of unconditional; (d) phantom-nullifier wedging — a mis-scoped PRF input domain can permanently block a claim whose deterministic dummy already sits in the global nullifier set. The two-note approach achieves the same determinism from note structure (public-side, loud failure modes) rather than randomness manufacture (silent failure modes), which is the right risk shape. This PR remains the preferred direction — after the funding-shape decision.

4. Smaller items to fold into the redo/decision

  • Rollout policy for the long tail of already-funded single-note invites (they stay claimable; the claim path's >= 2 branch handles both, but wallet UX and docs need the story).
  • If pre-split is adopted: the intermediate exact note needs reservation so ordinary spends can't consume it between steps, and the partial-state (step 1 landed, step 2 pending) needs explicit handling.
  • A privacy note in the invite docs covering the funding-shape analysis above, whichever layout we choose.

What unblocks this

A short written decision on the funding layout (single-tx 3-action vs pre-split default vs pre-split opt-in), then this PR lands aligned with it — likely with small additions rather than rework. Holding both this and #4313 together so the funding shape, claim path, and recovery semantics ship as one coherent design.

@QuantumExplorer QuantumExplorer added the temp hold On temporary hold while higher priority items are dealt with. label Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

temp hold On temporary hold while higher priority items are dealt with.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants