Skip to content

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

Open
bfoss765 wants to merge 7 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 7 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 atomic shielded transfers to multiple recipients in one transaction.
    • Send funds to up to 5 recipients, with optional memos applied to each transfer.
    • Added support across wallet and Kotlin SDK interfaces.
  • Bug Fixes

    • Improved validation for addresses, amounts, recipient limits, balances, and change.
    • Improved fee and transaction-size calculations to prevent oversized transactions.
  • Reliability

    • Added safeguards against unexpected processing failures.
    • Improved multi-recipient activity details, including aggregated amounts and consistent recipient information.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 15c737c2-a3c4-49b6-a27c-ed2b43ad1274

📥 Commits

Reviewing files that changed from the base of the PR and between 6495991 and e9373ce.

📒 Files selected for processing (17)
  • Cargo.toml
  • 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/shield_from_asset_lock.rs
  • packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
  • packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs
  • packages/rs-dpp/src/shielded/builder/unshield.rs
  • packages/rs-dpp/src/shielded/mod.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/activity.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
🚧 Files skipped from review as they are similar to previous changes (15)
  • packages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rs
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.kt
  • packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs
  • packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt
  • packages/rs-dpp/src/shielded/builder/unshield.rs
  • packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs
  • packages/rs-platform-wallet/src/wallet/platform_wallet.rs
  • Cargo.toml
  • packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs
  • packages/rs-unified-sdk-jni/src/funding.rs
  • packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-dpp/src/shielded/mod.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs

Included review availability: Your plan provides up to 3 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change adds atomic shielded transfers for up to five recipients, applies serialized-size action limits before proving, adds change-aware note selection, aggregates multi-output activity metadata, and maps panics to typed FFI errors.

Changes

Multi-output shielded transfer

Layer / File(s) Summary
Shielded transition sizing and builder validation
packages/rs-dpp/src/shielded/*, packages/rs-dpp/src/state_transition/...
DPP derives effective action ceilings from structural and serialized-size limits. Shielded builders validate these limits before proving.
Change-aware note selection and transfer execution
packages/rs-platform-wallet/src/wallet/shielded/*
Wallet note selection supports optional or strictly positive change. Multi-output transfers reserve notes, build one atomic transition, broadcast it, record aggregate activity, and manage reservations.
Platform wallet and FFI execution
packages/rs-platform-wallet/src/wallet/platform_wallet.rs, packages/rs-platform-wallet-ffi/src/shielded_send.rs, Cargo.toml
The platform wallet parses multiple Orchard addresses. FFI adds multi-output execution, a five-recipient limit, and typed panic handling. The release profile documents panic-abort behavior.
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 validate flattened 43-byte recipient addresses, matching positive amounts, account values, recipient limits, and optional memo input.

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

Merge Risk: 🔵 Low · up to e9373

The PR adds multi-output shielded transfer support, but the Kotlin recipient limit duplicates a consensus-derived value without a test tying the two together, so a future limit change could cause SDK and validation behavior to diverge. The PR is mergeable with explicit owner follow-up to derive or pin this limit.

Sequence Diagram(s)

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

Possibly related issues

  • dashpay/platform#4250: Adds shared effective action-count and serialized-size validation before shielded note selection and proving.

Possibly related PRs

Suggested reviewers: quantumexplorer, lklimek, llbartekll, shumkov, zocolini

🚥 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 two main changes: multi-output shielded transfers and output-aware fee prediction.
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

✅ Final review complete — no blockers (commit e9373ce)

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.80352% with 292 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.65%. Comparing base (6495991) to head (e9373ce).

Files with missing lines Patch % Lines
...s/rs-dpp/src/shielded/builder/shielded_transfer.rs 77.73% 104 Missing ⚠️
packages/rs-dpp/src/shielded/builder/mod.rs 59.65% 71 Missing ⚠️
...lded/builder/identity_create_from_shielded_pool.rs 44.73% 42 Missing ⚠️
...dpp/src/shielded/builder/shield_from_asset_lock.rs 68.25% 40 Missing ⚠️
...entity_create_from_shielded_pool_transition/mod.rs 18.75% 13 Missing ⚠️
packages/rs-dpp/src/shielded/mod.rs 90.69% 8 Missing ⚠️
...rs-dpp/src/shielded/builder/shielded_withdrawal.rs 30.00% 7 Missing ⚠️
packages/rs-dpp/src/shielded/builder/unshield.rs 30.00% 7 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##           v4.2-dev    #4312      +/-   ##
============================================
- Coverage     84.74%   84.65%   -0.09%     
============================================
  Files          2711     2711              
  Lines        357138   358314    +1176     
============================================
+ Hits         302668   303348     +680     
- Misses        54470    54966     +496     
Components Coverage Δ
dpp 84.74% <69.80%> (-0.58%) ⬇️
drive 83.93% <ø> (+0.13%) ⬆️
drive-abci 86.64% <ø> (-0.02%) ⬇️
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.92% <ø> (ø)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 38.96% <ø> (ø)
🚀 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 thread packages/rs-dpp/src/shielded/builder/mod.rs
@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 thread packages/rs-platform-wallet-ffi/src/shielded_send.rs Outdated

@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
…hielded pre-proving gate

shielded_bundle_action_count only enforced the structural
max_shielded_transition_actions cap (16) and ignored the versioned
max_state_transition_size (20 KiB). A shielded transition's on-wire size
grows ~2,681 B per action on a ~2.9 KiB envelope (measured: 2 actions ->
8,294 B, 6 -> 19,018 B, 7 -> 21,699 B), so 7..16-action bundles passed
the gate, burned ~30 s of Halo 2 proving per bundle, and were only then
rejected by DAPI's byte prefilter / Tenderdash mempool.max-tx-bytes /
the Drive-ABCI consensus decoder. Reachable from the FFI/JNI/Kotlin
boundaries, which admitted up to 16 recipients.

- shielded/mod.rs: add the measured wire-cost constants
  (SHIELDED_ACTION_WIRE_BYTES = 408, SHIELDED_PROOF_WIRE_BYTES_PER_ACTION
  = 2,273, SHIELDED_TRANSITION_WIRE_OVERHEAD_BYTES = 2,932),
  estimated_shielded_transition_wire_bytes(), and
  max_shielded_actions_per_transition() - the effective ceiling derived
  from BOTH versioned limits (min of the structural cap and the largest
  action count whose estimated size fits max_state_transition_size).
  6 at current constants; derived, never hardcoded, so raising
  max_state_transition_size widens the gate automatically. Pin tests tie
  the linear model to the measured transitions and the derivation to the
  value the system_limits doc comments state.
- builder/mod.rs: shielded_bundle_action_count now also rejects bundles
  over the effective ceiling, with a size-derived message naming the
  estimated byte count (structural-cap check unchanged and still first).
- builder routing: shielded_withdrawal / unshield /
  identity_create_from_shielded_pool swap their ungated
  spends.len().max(2) for the gated predictor (numerically identical for
  valid shapes); both shield_from_asset_lock builders gate
  1 + dummy_outputs (checked add) before building the bundle. Every
  shielded builder now fails fast instead of proving a doomed bundle.
- tests: output-dominated (1,7), spend-dominated (7,1) and (7,7) shapes
  rejected pre-proving; (1,6)/(6,1)/(6,6) accepted at the boundary; the
  multi-output transfer gains a 6-recipient (7-output) pre-proving
  rejection test and its boundary-accept test moves from the structural
  cap to the effective ceiling.

Validation: cargo test -p dpp --features
shielded-client,core_key_wallet,state-transition-signing --lib ->
3931 passed, 0 failed, 6 ignored (includes the 6-action
seed_pool_batch_fits_max_state_transition_size signing test through the
new gate).
…lamp the multi-transfer recipient ceiling to the effective action limit

Boundary alignment for the size-derived action ceiling:
- MAX_SHIELDED_TRANSFER_RECIPIENTS drops 16 -> 5: the effective
  per-transition Orchard action ceiling (6, bound by the 20 KiB
  max_state_transition_size) minus the unconditional change output.
  Recipient counts 6..16 could never execute on chain - they only burned
  ~30 s of Halo 2 proving before the byte prefilter rejected the
  transition. A test pins the constant to dpp's
  max_shielded_actions_per_transition() derivation so a versioned-limit
  change fails loudly. The JNI adapter enforces the Rust constant
  symbolically (no change needed); the Kotlin mirror in
  PlatformWalletManager.kt is updated in lockstep.

Panic guards:
- catch_spend_panic generalizes to catch_panic_to_code(operation, code,
  guidance, body). Every remaining block_on_worker export in
  shielded_send.rs now runs its body under the guard via the established
  *_inner extraction pattern (previously only transfer_multi was
  guarded):
  - transfer, unshield, withdraw, shield ->
    ErrorShieldedSpendUnconfirmed (the ambiguous, do-NOT-retry spend
    contract; shield included to match map_spend_result's mapping, with
    the address-nonce check making a later manual retry self-healing).
  - identity_create_from_pool -> generic ErrorUnknown: the export's
    ErrorShieldedBroadcastUnconfirmed ABI contract requires writing
    out_identity_id, which a panic cannot supply;
    ErrorShieldedSpendUnconfirmed is documented as scoped to
    unshield/transfer/withdrawal; every other code promises a definitive
    outcome; and no dedicated panic code exists in the registry-tracked
    enum (allocating one risks the cross-branch numeric collisions the
    codes-28-30 comment warns about). The message carries do-not-resubmit
    / hold-the-slot guidance.
  - fund_from_asset_lock, resume_fund_from_asset_lock, seed_pool_notes ->
    ErrorWalletOperation (the single error code those exports already
    surface), with tracked-lock/resume guidance in the message.

iOS panic=abort - evaluated, NOT flipped:
- dev-ios/release-ios keep panic = "abort", so on iOS a panic still
  aborts before any guard runs; the guards are effective on Android
  (panic=unwind per the profile comments) and host/test builds. Reasons
  against flipping now: (1) the iOS profiles exist explicitly as size
  tuning for the staticlib ('otherwise ships huge'), and panic=abort
  removes unwind tables and landing pads under fat LTO - a size lever;
  (2) the Android profile comment ('Unlike iOS, panic stays unwind')
  shows abort-on-iOS is a deliberate decision, not an accident; (3) the
  size regression of flipping cannot be measured in this environment (no
  iOS target build). The workspace Cargo.toml release-ios comment now
  documents the guard interplay and that a flip requires a measured size
  delta; until then this is a known, documented iOS limitation.

Validation: cargo test -p platform-wallet-ffi --features shielded --lib
-> 235 passed, 0 failed; cargo check -p rs-unified-sdk-jni -> clean;
platform-wallet note_selection/seed_pool tests -> 24 passed, 0 failed.

@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 — Codex/Sol only (Phase 2 disabled)

The prior blocker is fixed: seven-action bundles are now rejected before proving, and the public multi-transfer ceiling is aligned to five recipients plus mandatory change. Two in-scope suggestions remain: cold-restored activity misattributes aggregate multi-recipient payments, and the shared size ceiling still assumes a fixed transition envelope despite variable asset-lock proofs and identity keys.
Source: reviewers codex/general=gpt-5.6-sol, codex/security-auditor=gpt-5.6-sol, codex/rust-quality=gpt-5.6-sol, codex/ffi-engineer=gpt-5.6-sol; final verifier codex/verifier=gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

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/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/activity.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/activity.rs:501-522: Do not attribute a multi-recipient transfer to its first recipient
  Cold restoration now encounters transfers containing several distinct recipient outputs, but this branch sums all external outputs into one activity amount and records only the first output's recipient as the counterparty. A transfer sending 10 credits to A and 20 to B is consequently restored as a 30-credit payment to whichever recipient appears first after Orchard action ordering. The live `transfer_multi` path already avoids this by recording a counterparty only when every recipient is identical. Apply the same rule during scan derivation and add a restore-path test with two distinct recipients that expects the aggregate amount and no single counterparty.

In `packages/rs-dpp/src/shielded/mod.rs`:
- [SUGGESTION] packages/rs-dpp/src/shielded/mod.rs:101-114: Account for transition-specific envelope sizes in the action ceiling
  `max_shielded_actions_per_transition` derives one ceiling by subtracting a fixed 2,932-byte envelope measured from `ShieldFromAssetLock` transitions carrying a small chain proof, but the helper is shared by transition types whose non-Orchard fields have variable serialized sizes. In particular, an instant asset-lock proof embeds both a transaction and an `InstantLock`; both contain input vectors, while DPP permits asset-lock transactions with up to 100 inputs. Six actions leave only 1,462 bytes below the current 20,480-byte limit, so a valid multi-input instant proof can exhaust that slack while still passing this gate and performing Halo 2 proving before the byte prefilter rejects the completed transition. Identity creation similarly carries up to six variable public keys. Make the pre-proving check transition-specific by including the known non-proof fields in its size budget, and cover maximum transfer, identity-key, chain-proof, and realistic multi-input instant-proof envelopes with serialized-size boundary tests.

Comment thread packages/rs-dpp/src/shielded/mod.rs
…tion ceiling

The size-derived ceiling assumed the fixed 2,932-byte envelope measured
on a chain-proof ShieldFromAssetLock, but two transition families carry
variable non-Orchard fields that can consume the ~1.4 KiB slack it
leaves under max_state_transition_size: an instant asset-lock proof
embeds its funding transaction and InstantLock (both hold input
vectors; DPP admits up to 100 inputs), and identity creation carries up
to six variable public keys — so a valid multi-input instant proof
could pass the gate, burn the ~30 s Halo 2 proof, and only then be
rejected by DAPI's byte prefilter (#4312 review finding e90e9cf15f52).

- max_shielded_actions_for_envelope / estimated_..._with_envelope: the
  ceiling and the estimator now take the transition's extra envelope
  bytes; the baseline forms delegate with 0.
- shielded_bundle_action_count grows an extra_envelope_bytes parameter;
  the rejection message names the envelope contribution.
- ShieldFromAssetLock measures its serialized asset-lock proof (chain
  proofs cost a few dozen bytes and keep the baseline ceiling; the
  slight double-count of the baseline's own measured chain proof is
  deliberate conservatism). Identity-create measures its serialized key
  set plus a 97-byte per-key allowance for the PoP signatures that are
  still empty at gate time (BLS 96 B + length prefix). Transfer,
  unshield, and withdrawal have fixed-size envelopes and pass 0.
- serialized_envelope_bytes measures with the same
  standard().with_big_endian() bincode config the wire serialization
  uses.

Boundary tests: exact-byte ceiling tightening (slack keeps, slack+1
displaces an action, u64::MAX degrades to 0 without wrapping);
chain-proof envelope keeps the baseline; 20- and 100-input instant
proofs tighten the ceiling and fail the gate pre-proving; a six-key
identity create still clears the padded 2-action claim shape. Also
rustfmts the d3ecd62 test/model hunks that were failing the
workspace fmt gate in CI. dpp suite: 3935 passed.

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

Copy link
Copy Markdown
Collaborator Author

Status for a resolution pass — head `1e46088d7`. Both open review threads are addressed with inline replies citing the fixing commit:

  • `rs-dpp/src/shielded/builder/mod.rs:185` (size-limit gate) — fixed in `d3ecd62f9d`.
  • `rs-dpp/src/shielded/mod.rs:161` (transition-specific proof envelopes) — new this round, fixed in `1e46088d73` (envelope-aware action ceiling + boundary tests; dpp lib 3935 passing).

CI red earlier was a cargo fmt --check --all diff, fixed in the same push. Ready to resolve where you concur — thanks.

@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 — Codex/Sol only (Phase 2 disabled)

The transition-specific envelope fix is valid: asset-lock proofs and identity key sets are now included in the pre-proving size budget, resolving the prior action-ceiling finding. One in-scope suggestion remains because the new multi-recipient API exposes the restoration path's existing first-recipient attribution behavior.
Source: reviewer backend gpt-5.6-sol; final verifier backend gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 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/activity.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/shielded/activity.rs:501-522: Do not attribute a multi-recipient transfer to its first recipient
  Cold restoration sums every external output in the cluster into `amount`, but it always copies `counterparty` from `external.first()`. The new multi-output API makes it possible for one transfer to pay distinct recipients, so a 10-credit payment to A and a 20-credit payment to B is restored as one 30-credit payment attributed only to the first recipient in Orchard action order. This disagrees with the live `transfer_multi` recorder, which sets a counterparty only when all recipient addresses are equal. Apply the same all-recipients-equal rule here and add a restoration-path test with two distinct recipients that expects the aggregate amount and no counterparty.

…sfer to one recipient

Cold restoration summed every external output in a cluster into `amount`
but copied `counterparty` from `external.first()`. Now that one Type-16
transition can pay distinct recipients, a 10-credit payment to A plus a
20-credit payment to B restored as a single 30-credit `Sent` attributed
solely to A — and since Orchard shuffles outputs before pairing them into
actions, which recipient won was not even stable across scans.

The live `transfer_multi` recorder already had the right rule: name a
counterparty only when every recipient address is equal (the
fund-one-address-with-N-notes shape), otherwise none. The two paths now
share that rule as one function, `activity::unanimous_bytes`, rather than
two copies that have to agree: the live path feeds it the raw 43-byte
address encodings of its `(address, amount)` outputs, and the restore
path feeds it the same encodings recovered from the OVK-decrypted
outgoing notes. Same input, same function, same verdict.

Splitting the restored row per recipient is not an option: an entry's id
is `sha256(sorted visible output cmxs)` over the WHOLE cluster, so subset
rows could never dedupe against the live row and a rescan would
double-count the transfer. The aggregate row is correct — only its
attribution was wrong.

`memo` gets the same treatment for the same reason. `transfer_multi`
attaches one memo to every recipient note, so this is a no-op for every
currently reachable transfer, but the builder's `ShieldedTransferOutput`
carries a per-output memo, and presenting one output's memo as the whole
transfer's is the same misattribution class.

Tests (restore path): two distinct recipients derive one 30-credit row
with no counterparty, matching what the live rule returns for the same
outputs; the derived row is invariant under output order (the shuffle
hazard that made `first()` unstable); N outputs to a single address still
keep that address and its unanimous memo, so the fix doesn't over-correct
into always dropping attribution; a memo the outputs disagree on is
dropped; plus the helper's own truth table.

Addresses the remaining suggestion on #4312 (activity.rs:501-522).

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

🧹 Nitpick comments (5)
packages/rs-dpp/src/shielded/mod.rs (1)

396-420: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the slack arithmetic against a structural-bound ceiling.

slack subtracts baseline as u64 * per_action from max_size with plain u64 arithmetic. This is only safe while the size limit is the binding one. If max_state_transition_size is raised so that max_shielded_transition_actions binds instead, baseline * per_action can exceed max_size - OVERHEAD and the subtraction panics in debug builds. The assertion at Line 417 (baseline - 1) also assumes the size-bound case.

The sibling tests shielded_bundle_action_count_rejects_over_the_size_derived_ceiling and multi_output_transfer_rejects_output_count_over_the_size_ceiling both start with an explicit effective < structural guard. Add the same guard here so a limits bump fails with a clear message instead of an arithmetic panic.

♻️ Proposed guard
         let platform_version = PlatformVersion::latest();
         let baseline = max_shielded_actions_per_transition(platform_version);
+        let structural = platform_version
+            .system_limits
+            .max_shielded_transition_actions as usize;
+        assert!(
+            baseline < structural,
+            "this test requires the size limit to be the binding one (baseline {baseline} < \
+             structural {structural}); if the size limit was raised, rework this test"
+        );
         let per_action = SHIELDED_ACTION_WIRE_BYTES + SHIELDED_PROOF_WIRE_BYTES_PER_ACTION;
🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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-dpp/src/shielded/mod.rs` around lines 396 - 420, Guard the slack
calculation in envelope_bytes_tighten_the_action_ceiling_at_the_exact_boundary
by asserting that the size-derived baseline is below the structural
max_shielded_transition_actions ceiling, with a clear failure message. Keep the
existing boundary assertions unchanged once that precondition is established.
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt (1)

2288-2300: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Source the recipient ceiling from Rust instead of duplicating the literal.

MAX_SHIELDED_TRANSFER_RECIPIENTS = 5 is a consensus-derived protocol constant held as a Kotlin literal. The Rust constant in packages/rs-platform-wallet-ffi/src/shielded_send.rs is pinned to the DPP derivation by max_recipients_matches_the_effective_action_ceiling. Nothing pins this Kotlin copy. If max_state_transition_size moves, the Rust test fails and the Rust constant is updated, while this literal silently stays behind and rejects valid calls until someone notices the KDoc note.

Expose the ceiling through the existing FundingNative JNI surface (a small accessor returning the Rust constant) and initialize this value from it, so the two cannot drift.

The Kotlin SDK coding guidelines state: "Do not implement derivation-path construction, policy-loop orchestration, mnemonic/seed processing across JNI, protocol constants, or JNI functions that merely stitch together existing Rust calls; implement these in Rust instead." As per coding guidelines.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`
around lines 2288 - 2300, Replace the duplicated literal used by
MAX_SHIELDED_TRANSFER_RECIPIENTS with a FundingNative JNI accessor that returns
the Rust shielded-send recipient ceiling, and initialize the Kotlin value from
that accessor. Add the small native accessor to the existing FundingNative
surface, reusing the Rust constant, while preserving the current validation and
public Kotlin symbol.

Source: Coding guidelines

packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs (1)

172-197: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Build the in-creation key list once and reuse it for the measurement.

Lines 172-179 clone every IdentityPublicKeyInCreation into a temporary Vec to measure the envelope, and Lines 196-197 build the identical Vec again as in_creation_keys. The two expressions must stay in sync: if one ever changes (ordering, filtering), the measured envelope stops describing the key set the transition actually carries.

Hoist the list above the measurement and measure the same value that is bound into the sighash.

♻️ Proposed refactor
+    // The in-creation key list is bound, together with the id and the denomination, into the
+    // Orchard sighash. Build it once so the pre-proving size gate measures exactly the key set
+    // the transition carries.
+    let in_creation_keys: Vec<IdentityPublicKeyInCreation> =
+        public_keys.iter().map(|(_, c)| c.clone()).collect();
     let key_set_envelope_bytes = serialized_envelope_bytes(
-        &public_keys
-            .iter()
-            .map(|(_, c)| c.clone())
-            .collect::<Vec<IdentityPublicKeyInCreation>>(),
+        &in_creation_keys,
         "the identity key set",
     )?
-    .saturating_add(public_keys.len() as u64 * PER_KEY_SIGNATURE_ALLOWANCE_BYTES);
+    .saturating_add(in_creation_keys.len() as u64 * PER_KEY_SIGNATURE_ALLOWANCE_BYTES);

Then remove the duplicate construction at Lines 196-197.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs`
around lines 172 - 197, Construct the in_creation_keys vector once before
key-set envelope measurement, then pass that same vector to
serialized_envelope_bytes and retain it for the transition sighash binding.
Remove the later duplicate public_keys mapping while preserving the existing
ordering and contents.
packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs (1)

76-88: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Keep extra_envelope_bytes at 0 and clarify the comment. Structural validation accepts only canonical P2PKH (25-byte) or P2SH (23-byte) scripts. Replace “fixed-size” with “validated as canonical P2PKH or P2SH.”

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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-dpp/src/shielded/builder/shielded_withdrawal.rs` around lines 76
- 88, Keep the extra_envelope_bytes argument passed to
shielded_bundle_action_count at 0, and update its adjacent comment to state that
structural validation accepts only canonical P2PKH or P2SH scripts instead of
describing the fields as fixed-size.
packages/rs-dpp/src/shielded/builder/mod.rs (1)

220-237: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Align serialized_envelope_bytes with PlatformSerialize. Add .with_no_limit() because the platform serializer uses standard().with_big_endian().with_no_limit() for unversioned shielded transitions. All shielded_bundle_action_count call sites use the current four-argument signature.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. 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-dpp/src/shielded/builder/mod.rs` around lines 220 - 237, Update
serialized_envelope_bytes to configure bincode with standard(), big-endian
encoding, and no limit, matching PlatformSerialize for unversioned shielded
transitions. Preserve its existing error mapping and four-argument
shielded_bundle_action_count call sites.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Nitpick comments:
In
`@packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt`:
- Around line 2288-2300: Replace the duplicated literal used by
MAX_SHIELDED_TRANSFER_RECIPIENTS with a FundingNative JNI accessor that returns
the Rust shielded-send recipient ceiling, and initialize the Kotlin value from
that accessor. Add the small native accessor to the existing FundingNative
surface, reusing the Rust constant, while preserving the current validation and
public Kotlin symbol.

In `@packages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rs`:
- Around line 172-197: Construct the in_creation_keys vector once before key-set
envelope measurement, then pass that same vector to serialized_envelope_bytes
and retain it for the transition sighash binding. Remove the later duplicate
public_keys mapping while preserving the existing ordering and contents.

In `@packages/rs-dpp/src/shielded/builder/mod.rs`:
- Around line 220-237: Update serialized_envelope_bytes to configure bincode
with standard(), big-endian encoding, and no limit, matching PlatformSerialize
for unversioned shielded transitions. Preserve its existing error mapping and
four-argument shielded_bundle_action_count call sites.

In `@packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs`:
- Around line 76-88: Keep the extra_envelope_bytes argument passed to
shielded_bundle_action_count at 0, and update its adjacent comment to state that
structural validation accepts only canonical P2PKH or P2SH scripts instead of
describing the fields as fixed-size.

In `@packages/rs-dpp/src/shielded/mod.rs`:
- Around line 396-420: Guard the slack calculation in
envelope_bytes_tighten_the_action_ceiling_at_the_exact_boundary by asserting
that the size-derived baseline is below the structural
max_shielded_transition_actions ceiling, with a clear failure message. Keep the
existing boundary assertions unchanged once that precondition is established.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45525365-2cf5-4bc2-8196-553acbb685ca

📥 Commits

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

📒 Files selected for processing (12)
  • Cargo.toml
  • 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/shield_from_asset_lock.rs
  • packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
  • packages/rs-dpp/src/shielded/builder/shielded_withdrawal.rs
  • packages/rs-dpp/src/shielded/builder/unshield.rs
  • packages/rs-dpp/src/shielded/mod.rs
  • packages/rs-platform-wallet-ffi/src/shielded_send.rs
  • packages/rs-platform-wallet/src/wallet/shielded/activity.rs
  • packages/rs-platform-wallet/src/wallet/shielded/operations.rs

Included review availability: Your plan provides up to 3 included reviews per hour; 1 remains after this review.

Base moved while this PR was in review: #4342 ("honest scan-derived
shielded history") rewrote `wallet/shielded/activity.rs`, which this
branch had not previously touched. Merging it in so the PR is mergeable
and, more importantly, so the multi-recipient attribution fix actually
survives the merge.

One conflict, in the scan deriver's SENT arm, resolved by taking both
sides:

- ours: `counterparty` / `memo` via the shared `unanimous_bytes` rule
  (a5a7ee3), so a restored multi-recipient transfer is not attributed
  to whichever output happened to come first;
- theirs: `block_height: None` (plus `created_at_ms: 0` and the new
  `min_note_position` field, which auto-merged), #4342's deliberate
  change to stop claiming a mined height the scan cannot know.

Worth recording: #4342's rewrite carries the SAME first-recipient bug
the review flagged here, so a clean auto-merge would have silently taken
base's version and the finding would have come back. Base's side of the
conflict no longer even compiles — `send` is unbound after the fix
switched the arm to `if !external.is_empty()`.

`operations.rs` (the live `transfer_multi` half of the shared rule)
auto-merged with no conflict.

Tests: `cargo test -p platform-wallet --features shielded` — 848 + 9
passed, 0 failed. The activity module is 27/27, covering this branch's
four multi-recipient restoration tests and #4342's two new ones together.
fmt clean.
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@bfoss765

Copy link
Copy Markdown
Collaborator Author

Fixed in a5a7ee3 (base merge landed as e9373ce).

The restore path's SENT arm summed every external output into amount but took counterparty from external.first(), so a multi-recipient transfer restored as one aggregate row attributed to a single recipient — and since Orchard shuffles outputs before pairing them into actions, which recipient won wasn't even stable across scans.

Rather than copy the live rule, I extracted it: activity::unanimous_bytes is now called by both transfer_multi and derive_activity_from_scan_data, over the same canonical 43-byte raw address encoding. The live path feeds it its (address, amount) outputs; the restore path feeds it the same encodings recovered from the OVK-decrypted outgoing notes. Same input, same function, so the two verdicts agree by construction instead of by two copies of the rule staying in sync. This also tightened the live path slightly — it used to compare PaymentAddress values, and now compares the bytes the restore path actually sees.

I kept the aggregate single row rather than splitting per recipient: an entry's id is sha256(sorted visible output cmxs) over the whole cluster, so per-recipient rows could never dedupe against the live row and a rescan would double-count. The aggregate was right; only its attribution was wrong.

memo gets the same treatment. It's a no-op for anything reachable today (transfer_multi attaches one memo to every recipient note), but ShieldedTransferOutput carries a per-output memo and presenting one output's memo as the whole transfer's is the same misattribution.

Tests on the restoration path: two distinct recipients derive one 30-credit row with no counterparty, asserted equal to what the shared rule returns for the live path's inputs; the row is invariant under output order; N outputs to a single address still keep that address and its unanimous memo, so the fix doesn't over-correct into always dropping attribution; a memo the outputs disagree on is dropped; plus the helper's truth table.

One note on the base merge: this branch had never touched activity.rs, and v4.2-dev's #4342 rewrote that file carrying the same first-recipient bug (its side of the conflict also no longer compiled — send is unbound there). Without this fix an auto-merge would have silently taken base's version and the finding would have reappeared in the merged result. The resolution routes base's rewrite through the shared rule; merged tree is green (848 + 9, including #4342's two new determinism tests), fmt clean, and the PR is mergeable again.

@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 — Codex/Sol only (Phase 2 disabled)

The prior multi-recipient restoration-attribution finding is fixed at the exact head. Four non-blocking issues remain: live activity diverges from restoration for mixed self/external outputs, the asset-lock size gate double-counts its baseline proof, the Rust builder accepts zero-valued recipients, and two tests mutate the global panic hook without synchronization.
Source: reviewers codex/general=gpt-5.6-sol, codex/security-auditor=gpt-5.6-sol, codex/rust-quality=gpt-5.6-sol, codex/ffi-engineer=gpt-5.6-sol; final verifier codex/verifier=gpt-5.6-sol. Orchestration-only: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated zero-blocker Codex/Sol precheck evidence was promoted to final because Phase 2 (Sonnet/Opus) is temporarily disabled. This is Codex/Sol-only final validation, not Codex + Sonnet/Opus coverage.

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/Opus: not run (Phase 2 disabled — temporary Codex/Sol-only final)
  • Secondary pass: disabled (temporary_phase2_sonnet_disable)

🟡 4 suggestion(s)

🤖 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:1170-1188: Classify wallet-owned recipients consistently in live activity
  The live recorder derives `amount` and `counterparty` from every requested output, while cold restoration uses the account IVK to remove wallet-owned outputs before aggregating external payments. The public multi-transfer API accepts arbitrary valid Orchard addresses, including the account's own diversified addresses. A transfer of 10 credits to an external address and 20 to an own address is therefore recorded live as a 30-credit send with no counterparty, but restores as a 10-credit send to the external address. An all-self output set similarly changes from `Sent` live to a shielded spend or self-transfer after restoration. Partition the live outputs with `views.incoming_viewing_key.diversifier_index`, matching the coordinator's restoration classification, and derive the activity kind, amount, and counterparty from the external subset.

In `packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs`:
- [SUGGESTION] packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs:66-68: Price only the asset-lock proof delta above the baseline envelope
  `SHIELDED_TRANSITION_WIRE_OVERHEAD_BYTES` was calibrated from complete `ShieldFromAssetLock` transitions that already carried a chain asset-lock proof, but this call adds the entire serialized proof as extra envelope bytes. The estimator consequently models `baseline + full proof`, although the supplied proof replaces the chain proof represented by the baseline. This is conservative but can reject valid transitions at an action boundary: five actions leave 4,143 bytes for extra envelope under the current constants, while the real transition can fit a proof larger by the encoded baseline proof size. Calibrate a proof-free fixed overhead or subtract the encoded baseline proof before passing the transition-specific delta, then test the gate against actual serialized boundary transitions rather than only against the conservative model.

In `packages/rs-dpp/src/shielded/builder/shielded_transfer.rs`:
- [SUGGESTION] packages/rs-dpp/src/shielded/builder/shielded_transfer.rs:223-227: Reject zero-valued recipient outputs at the Rust builder boundary
  The new C, JNI, and Kotlin boundaries require every recipient amount to be positive, but the public DPP builder only rejects an empty output list. A direct Rust caller can therefore build a zero-valued recipient note, making the API's amount invariant depend on the entry point. It also undermines the motivating two-note layout: `[0, D]` allows greedy claim selection to stop after the full-value note, restoring the random padding nullifier the layout is intended to avoid. Enforce positivity in this lowest public builder and add a builder-level rejection test.

In `packages/rs-platform-wallet-ffi/src/shielded_send.rs`:
- [SUGGESTION] packages/rs-platform-wallet-ffi/src/shielded_send.rs:2313-2379: Do not replace the process-global panic hook in parallel tests
  Both panic-guard tests call `take_hook`, install a temporary hook, and restore the captured hook without synchronization. Rust tests run concurrently and panic hooks are process-global. If these tests interleave, one can capture the other's temporary hook and restore it last, leaving panic diagnostics suppressed for the rest of the process; either test can also hide diagnostics from an unrelated concurrent panic. The test harness captures the default hook's output, so invoke the catch helpers directly without changing the global hook.

Comment on lines +1170 to +1188
let recipients: Vec<Vec<u8>> = outputs
.iter()
.map(|(addr, _)| addr.to_raw_address_bytes().to_vec())
.collect();
let counterparty = unanimous_bytes(recipients.iter().map(|r| r.as_slice()));

pending_entry = record_pending_activity(
store,
persister,
wallet_id,
id,
&views,
LiveEntryParams {
kind: ShieldedActivityKind::Sent,
direction: ShieldedDirection::Out,
amount: total_amount,
fee: Some(fee_used),
counterparty,
memo: non_zero_memo(&memo),

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: Classify wallet-owned recipients consistently in live activity

The live recorder derives amount and counterparty from every requested output, while cold restoration uses the account IVK to remove wallet-owned outputs before aggregating external payments. The public multi-transfer API accepts arbitrary valid Orchard addresses, including the account's own diversified addresses. A transfer of 10 credits to an external address and 20 to an own address is therefore recorded live as a 30-credit send with no counterparty, but restores as a 10-credit send to the external address. An all-self output set similarly changes from Sent live to a shielded spend or self-transfer after restoration. Partition the live outputs with views.incoming_viewing_key.diversifier_index, matching the coordinator's restoration classification, and derive the activity kind, amount, and counterparty from the external subset.

source: ['codex']

Comment on lines +66 to +68
let proof_envelope_bytes =
serialized_envelope_bytes(&asset_lock_proof, "the asset-lock proof")?;
shielded_bundle_action_count(0, num_outputs, proof_envelope_bytes, platform_version)?;

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: Price only the asset-lock proof delta above the baseline envelope

SHIELDED_TRANSITION_WIRE_OVERHEAD_BYTES was calibrated from complete ShieldFromAssetLock transitions that already carried a chain asset-lock proof, but this call adds the entire serialized proof as extra envelope bytes. The estimator consequently models baseline + full proof, although the supplied proof replaces the chain proof represented by the baseline. This is conservative but can reject valid transitions at an action boundary: five actions leave 4,143 bytes for extra envelope under the current constants, while the real transition can fit a proof larger by the encoded baseline proof size. Calibrate a proof-free fixed overhead or subtract the encoded baseline proof before passing the transition-specific delta, then test the gate against actual serialized boundary transitions rather than only against the conservative model.

source: ['codex']

Comment on lines +223 to +227
if outputs.is_empty() {
return Err(ProtocolError::ShieldedBuildError(
"a multi-output shielded transfer needs at least one recipient output".to_string(),
));
}

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: Reject zero-valued recipient outputs at the Rust builder boundary

The new C, JNI, and Kotlin boundaries require every recipient amount to be positive, but the public DPP builder only rejects an empty output list. A direct Rust caller can therefore build a zero-valued recipient note, making the API's amount invariant depend on the entry point. It also undermines the motivating two-note layout: [0, D] allows greedy claim selection to stop after the full-value note, restoring the random padding nullifier the layout is intended to avoid. Enforce positivity in this lowest public builder and add a builder-level rejection test.

Suggested change
if outputs.is_empty() {
return Err(ProtocolError::ShieldedBuildError(
"a multi-output shielded transfer needs at least one recipient output".to_string(),
));
}
if outputs.is_empty() {
return Err(ProtocolError::ShieldedBuildError(
"a multi-output shielded transfer needs at least one recipient output".to_string(),
));
}
if let Some((index, _)) = outputs
.iter()
.enumerate()
.find(|(_, output)| output.amount == 0)
{
return Err(ProtocolError::ShieldedBuildError(format!(
"multi-output shielded transfer amount at index {index} must be positive"
)));
}

source: ['codex']

Comment on lines +2313 to +2379
let previous = std::panic::take_hook();
// Silence the default hook's backtrace spew for this deliberate panic.
std::panic::set_hook(Box::new(|_| {}));
let result = catch_spend_panic("shielded multi-output transfer", || {
panic!("tokio worker panicked");
});
std::panic::set_hook(previous);

assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed,
"a panic must map to the ambiguous, do-not-retry code"
);
let message = message_of(&result);
assert!(
message.contains("shielded multi-output transfer panicked")
&& message.contains("tokio worker panicked"),
"the panic payload must survive into the FFI message: {message}"
);
assert!(
message.contains("do NOT retry"),
"the message must carry the do-not-retry guidance: {message}"
);
}

/// The public recipient ceiling must equal the EFFECTIVE per-transition action ceiling
/// (derived in dpp from BOTH `max_shielded_transition_actions` and
/// `max_state_transition_size`) minus the unconditional change output. If a versioned limit
/// moves, this fails and the constant — plus its Kotlin mirror in
/// `PlatformWalletManager.kt` — must be raised in lockstep.
#[test]
fn max_recipients_matches_the_effective_action_ceiling() {
let effective = dpp::shielded::max_shielded_actions_per_transition(
dpp::version::PlatformVersion::latest(),
);
assert_eq!(
MAX_SHIELDED_TRANSFER_RECIPIENTS + 1,
effective,
"recipients + the unconditional change output must equal the effective action \
ceiling; update MAX_SHIELDED_TRANSFER_RECIPIENTS (and the Kotlin mirror) in \
lockstep with the versioned limits"
);
}

/// The generalized guard must carry the per-operation code and guidance: identity creation
/// maps a panic to the generic `ErrorUnknown` (its richer codes all promise things a panic
/// cannot deliver — see the export's call site), and the asset-lock funding exports map it
/// to their single existing error code, `ErrorWalletOperation`.
#[test]
fn catch_panic_to_code_carries_the_per_operation_contract() {
let previous = std::panic::take_hook();
// Silence the default hook's backtrace spew for these deliberate panics.
std::panic::set_hook(Box::new(|_| {}));

let identity = catch_panic_to_code(
"shielded identity-create-from-pool",
PlatformWalletFFIResultCode::ErrorUnknown,
IDENTITY_CREATE_PANIC_GUIDANCE,
|| panic!("proving task panicked"),
);
let funding = catch_panic_to_code(
"shielded fund-from-asset-lock",
PlatformWalletFFIResultCode::ErrorWalletOperation,
ASSET_LOCK_FUNDING_PANIC_GUIDANCE,
|| panic!("proving task panicked"),
);
std::panic::set_hook(previous);

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: Do not replace the process-global panic hook in parallel tests

Both panic-guard tests call take_hook, install a temporary hook, and restore the captured hook without synchronization. Rust tests run concurrently and panic hooks are process-global. If these tests interleave, one can capture the other's temporary hook and restore it last, leaving panic diagnostics suppressed for the rest of the process; either test can also hide diagnostics from an unrelated concurrent panic. The test harness captures the default hook's output, so invoke the catch helpers directly without changing the global hook.

source: ['codex']

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