feat(platform-wallet): multi-output shielded transfers + output-aware fee predictor (two-note invites) - #4312
feat(platform-wallet): multi-output shielded transfers + output-aware fee predictor (two-note invites)#4312bfoss765 wants to merge 2 commits into
Conversation
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.
📝 WalkthroughWalkthroughThe 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. ChangesMulti-output shielded transfer
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
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
⛔ Blockers found — Sonnet deferred (commit 6e59784) |
Codecov Report❌ Patch coverage is 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
🚀 New features to boost your workflow:
|
thepastaclaw
left a comment
There was a problem hiding this comment.
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.
| 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" | ||
| ))); | ||
| } |
There was a problem hiding this comment.
🔴 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']
✅ Action performedReview finished.
|
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/FundingNative.ktpackages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.ktpackages/rs-dpp/src/shielded/builder/identity_create_from_shielded_pool.rspackages/rs-dpp/src/shielded/builder/mod.rspackages/rs-dpp/src/shielded/builder/shielded_transfer.rspackages/rs-dpp/src/state_transition/state_transitions/shielded/identity_create_from_shielded_pool_transition/mod.rspackages/rs-platform-wallet-ffi/src/shielded_send.rspackages/rs-platform-wallet/src/wallet/platform_wallet.rspackages/rs-platform-wallet/src/wallet/shielded/note_selection.rspackages/rs-platform-wallet/src/wallet/shielded/operations.rspackages/rs-unified-sdk-jni/src/funding.rs
| /// 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()) | ||
| ), | ||
| ), | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 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 || trueRepository: 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 || trueRepository: 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
doneRepository: 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
left a comment
There was a problem hiding this comment.
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
>= 2branch 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.
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::DEFAULTpads the single-spend bundleup to
MIN_ACTIONS = 2, and the padding action's dummy nullifier is randomlygenerated (
orchardbuilder.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 —
Dsplit asfloor(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 coversDon 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() >= 2when deciding whether an expected identity id canbe 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_transitionsized its fee fromspends.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 toMIN_ACTIONS = 2. AShieldedTransfer'svalue_balanceis its fee, and consensus pins it tocompute_minimum_shielded_fee(actions.len())exactly —validate_minimum_shielded_feerejects under-payment and over-payment forthis 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 demandedmin_fee(3), and the transfer would be rejected on chain. So the two-notelayout 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 outputside 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_actionsrather thanre-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 reservesagainst the same
recipients + 1floor, so the reserved fee and the carved feecannot diverge. A caller spending exactly
sum + feeis rejected with aclear 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 actionit 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_reproduciblestates 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
rs-dppshielded_bundle_action_count,ShieldedTransferOutput,build_shielded_transfer_transition_multi,shielded_identity_id_is_reproduciblers-platform-walletoperations::transfer_multi,PlatformWallet::shielded_transfer_multi_tors-platform-wallet-ffiplatform_wallet_manager_shielded_transfer_multirs-unified-sdk-jni+kotlin-sdkshieldedTransferMultiInvite link format,
fundingCreditsand the exit-denomination ladder areunchanged — 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 real2-spend / 3-output bundle and pins
value_balance == fee == min_fee(actions.len()) == min_fee(3), explicitlyasserting it is not
min_fee(2). Also asserts the two outputs paid to thesame address become two distinct note commitments.
single_output_transfer_fee_matches_on_wire_action_count— pins thesingle-output builder against a real bundle so the shared helper cannot
regress it.
shielded_bundle_action_count_is_max_spends_outputs_padded_to_twoand..._matches_a_real_bundle— pin the predictor, including theoutput-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 selectorbehaviour the whole design rests on, for both shipped denominations.
test_select_notes_with_fee_reserves_multi_output_action_floor— thewallet reserves the 3-action fee, not the 2-action floor.
shielded_identity_id_is_reproducible, tying the predicate to observedbuilder behaviour rather than leaving it a bare constant.
Results: 216
dppshielded tests and 662platform-walletlib tests pass.rustfmtclean;clippy --all-targets -D warningsintroduces no new findings(the pre-existing findings in
recovery.rs,withdrawal.rs,persistence.rsand
core_wallet_types.rsare identical on the untouched base).Follow-ups (deliberately not in this PR)
platform_wallet_manager_shielded_transfer_multi. Thecbindgen header is generated at build time and nothing in the Swift SDK
references the new symbol, so the Swift build is unaffected.
artifact carrying
shieldedTransferMulti, so it lands with the next AAR.in-flight PR; this PR supplies the predicate it should call.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Reliability