Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
738dd7c
feat(shielded): multi-output transfers + output-aware fee predictor
bfoss765 Aug 5, 2026
6e59784
fix(shielded): review-gate round — action-limit gate, strict-change n…
bfoss765 Aug 5, 2026
d3ecd62
fix(dpp): enforce the transition-size-derived action ceiling in the s…
bfoss765 Aug 12, 2026
0281480
fix(wallet-ffi): panic-guard every shielded block_on_worker export; c…
bfoss765 Aug 12, 2026
1e46088
fix(dpp): price transition-specific envelopes into the pre-proving ac…
bfoss765 Aug 12, 2026
a5a7ee3
fix(platform-wallet): don't attribute a restored multi-recipient tran…
bfoss765 Aug 19, 2026
e9373ce
merge: bring v4.2-dev into feat/shielded-two-note-invites
bfoss765 Aug 19, 2026
409a0f6
fix(platform-wallet): classify wallet-owned recipients consistently i…
bfoss765 Aug 19, 2026
eea6c35
fix(dpp): price only the asset-lock proof delta above the baseline en…
bfoss765 Aug 19, 2026
35d6408
fix(dpp): reject zero-valued recipient outputs at the Rust builder bo…
bfoss765 Aug 19, 2026
9ea7bd1
test(platform-wallet-ffi): do not replace the process-global panic hook
bfoss765 Aug 19, 2026
99caf12
chore(dpp): align serialized_envelope_bytes with PlatformSerialize's …
bfoss765 Aug 19, 2026
40d725a
test(platform-wallet): bind live/restored parity tests to the product…
bfoss765 Aug 20, 2026
200e694
fix(platform-wallet): checked addition for the strict-change selectio…
bfoss765 Aug 21, 2026
43ed450
fix(platform-wallet): release note reservations when proving panics b…
bfoss765 Aug 21, 2026
41c16fd
fix(unified-sdk-jni): abort a shielded spend when the memo string can…
bfoss765 Aug 21, 2026
6245ad8
Merge origin/v4.2-dev into feat/shielded-two-note-invites
bfoss765 Aug 24, 2026
39d5c46
fix(platform-wallet): release note reservations on pre-broadcast pani…
bfoss765 Aug 24, 2026
f3c0dfc
test(platform-wallet): derive the input-selection regression seed fro…
bfoss765 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,26 @@ internal object FundingNative {
memoText: String?,
)

/**
* Multi-output shielded → shielded transfer, Type 16 (bridges
* `platform_wallet_manager_shielded_transfer_multi`).
*
* [recipientsRaw43] holds `amounts.size` raw 43-byte Orchard addresses
* laid out back to back (length must be `43 * amounts.size`), and
* [amounts] the matching credit values. Each pair becomes its own note;
* repeating the same address funds it with several independent notes.
* [memoText] is attached to every recipient note.
*/
external fun shieldedTransferMulti(
managerHandle: Long,
walletId: ByteArray,
resolverHandle: Long,
account: Int,
recipientsRaw43: ByteArray,
amounts: LongArray,
memoText: String?,
)

/**
* Shielded → Platform unshield, Type 17 (bridges
* `platform_wallet_manager_shielded_unshield`). [toPlatformAddress] is a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,68 @@ class PlatformWalletManager(
}
}

/**
* Multi-output shielded → shielded transfer (Type 16). Spends notes from
* [account] on [walletId] and creates ONE note per entry of [outputs] in
* a single atomic transition.
*
* Repeating the same address across entries is allowed and is the point
* of this call: it funds one address with several independent notes, so
* a later spend of that address spends several REAL notes rather than
* one real note plus an Orchard padding dummy (whose nullifier is
* randomly generated and therefore not reproducible offline).
*
* The transition always emits a change note, so the spendable balance
* must strictly exceed the summed amounts plus the fee. The fee grows
* with the output count: the bundle publishes
* `max(spentNotes, outputs.size + 1, 2)` Orchard actions.
*
* @param walletId the 32-byte wallet id.
* @param outputs (raw 43-byte Orchard address, credits) pairs; must be
* non-empty, hold at most 16 entries (the native ceiling), and every
* amount must be positive.
* @param account the ZIP-32 shielded account to spend from (usually 0).
* @param memo optional UTF-8 memo attached to EVERY recipient note
* (null / empty = no memo; at most 32 UTF-8 bytes).
*/
suspend fun shieldedTransferMulti(
walletId: ByteArray,
outputs: List<Pair<ByteArray, Long>>,
account: Int = 0,
memo: String? = null,
): Unit = teardownGate.op {
require(outputs.isNotEmpty()) { "outputs must not be empty" }
// Mirror the native ceiling BEFORE flattening: the arrays built below are sized by
// `outputs.size`, and the native layer would reject an oversized call anyway — after
// this side had already allocated for it.
require(outputs.size <= MAX_SHIELDED_TRANSFER_RECIPIENTS) {
"outputs must hold at most $MAX_SHIELDED_TRANSFER_RECIPIENTS entries, got ${outputs.size}"
}
require(account >= 0) { "account must be non-negative, got $account" }
outputs.forEachIndexed { index, (recipientRaw43, amount) ->
require(recipientRaw43.size == 43) {
"outputs[$index] address must be exactly 43 bytes, got ${recipientRaw43.size}"
}
require(amount > 0) { "outputs[$index] amount must be positive, got $amount" }
}
val recipientsRaw43 = ByteArray(outputs.size * 43)
outputs.forEachIndexed { index, (recipientRaw43, _) ->
recipientRaw43.copyInto(recipientsRaw43, index * 43)
}
val amounts = LongArray(outputs.size) { outputs[it].second }
mapNativeErrors {
FundingNative.shieldedTransferMulti(
managerHandle,
walletId,
mnemonicResolver.nativeHandle,
account,
recipientsRaw43,
amounts,
memo?.takeIf { it.isNotEmpty() },
)
}
}

/**
* Shielded → Platform unshield (Type 17) — port of Swift's
* `PlatformWalletManager.shieldedUnshield(walletId:account:toPlatformAddress:amount:)`
Expand Down Expand Up @@ -2222,6 +2284,15 @@ class PlatformWalletManager(
/** SPV progress poll cadence — matches Swift's 1 Hz `startProgressPolling`. */
const val POLL_INTERVAL_MS = 1_000L

/**
* Recipient ceiling of [shieldedTransferMulti] — mirrors
* `MAX_SHIELDED_TRANSFER_RECIPIENTS` in
* `packages/rs-platform-wallet-ffi/src/shielded_send.rs`, which the JNI adapter enforces
* from the array lengths before allocating. Checked here too so an oversized call is
* refused before this side flattens caller-sized buffers.
*/
const val MAX_SHIELDED_TRANSFER_RECIPIENTS = 16

/** De-offset `PlatformWalletFFIResultCode::ErrorInvalidParameter`. */
const val PWFFI_INVALID_PARAMETER = 2
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,13 @@ mod tests {
identity_id_from_nullifiers(&[real_nullifier]),
"the padding action's dummy nullifier must participate in the id derivation"
);
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with one real
// spend the published set contains fresh randomness, so the id cannot be re-derived
// offline (a retry would build a different dummy and thus a different id).
assert!(
!crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(1),
"a single-spend bundle is padded, so its id must be reported as NOT reproducible"
);
assert!(
result.predicted_fee < DENOMINATION,
"predicted fee must leave the new identity a positive balance"
Expand Down Expand Up @@ -497,5 +504,12 @@ mod tests {
identity_id_from_nullifiers(&[nf_a, nf_b]),
"with no padding, the published set is exactly the real spends' nullifiers"
);
// …which is precisely what `shielded_identity_id_is_reproducible` reports: with two real
// spends no padding is added, so the id is a pure function of the spent notes and a retry
// re-derives the SAME id. This is the property two-note funding buys.
assert!(
crate::state_transition::identity_create_from_shielded_pool_transition::shielded_identity_id_is_reproducible(2),
"a two-spend bundle needs no padding, so its id must be reported as reproducible"
);
}
}
160 changes: 159 additions & 1 deletion packages/rs-dpp/src/shielded/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ pub use identity_create_from_shielded_pool::{
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition;
#[cfg(feature = "core_key_wallet")]
pub use shield_from_asset_lock::build_shield_from_asset_lock_transition_with_signer;
pub use shielded_transfer::build_shielded_transfer_transition;
pub use shielded_transfer::{
build_shielded_transfer_transition, build_shielded_transfer_transition_multi,
ShieldedTransferOutput,
};
pub use shielded_withdrawal::build_shielded_withdrawal_transition;
pub use unshield::build_unshield_transition;

Expand All @@ -52,6 +55,7 @@ use grovedb_commitment_tree::{
FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey,
Scope, SpendAuthorizingKey, SpendingKey,
};
use platform_version::version::PlatformVersion;
use rand::rngs::OsRng;
use rand::RngCore;

Expand Down Expand Up @@ -103,6 +107,61 @@ impl From<&OrchardAddress> for PaymentAddress {
}
}

/// The number of Orchard actions a `BundleType::DEFAULT` bundle built from `num_spends` spends
/// and `num_outputs` outputs will publish **on the wire**, validated against the consensus
/// action ceiling.
///
/// Every shielded fee predictor MUST size its fee with this function, because consensus prices
/// the fee off the on-wire `actions.len()` (see
/// `StateTransitionShieldedMinimumFeeValidationV0::validate_minimum_shielded_fee`, which reads
/// `v0.actions.len()`), and an Orchard action is a *joined* spend/output slot: the action count
/// is `max(num_spends, num_outputs)`, then padded up to Orchard's `MIN_ACTIONS = 2`.
///
/// The output side matters. A predictor that looks only at the spend count is correct **only**
/// while `num_outputs <= 2`, because `max(n, 1).max(2) == max(n, 2).max(2)`. As soon as a
/// transition publishes three or more outputs (a multi-recipient transfer plus change), a
/// spends-only predictor under-counts and carves a fee below the one consensus computes — fatal
/// for `ShieldedTransfer`, whose `value_balance` must equal the minimum fee **exactly**.
///
/// This 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.
///
/// # The consensus ceiling
///
/// Every shielded transition's `validate_structure` rejects a bundle whose `actions.len()`
/// exceeds `platform_version.system_limits.max_shielded_transition_actions` (via
/// `validate_actions_count`), but the `try_from_bundle` constructors do NOT run structural
/// validation — so without this gate an over-sized bundle is built, proved (~30 s of Halo 2),
/// and only then rejected on chain. Because the action count is `max(spends, outputs)`, bounding
/// it here bounds BOTH sides: a fragmented wallet spending too many notes and a caller asking
/// for too many outputs are rejected by the same comparison, before any proving work starts.
pub fn shielded_bundle_action_count(
num_spends: usize,
num_outputs: usize,
platform_version: &PlatformVersion,
) -> Result<usize, ProtocolError> {
let num_actions = BundleType::DEFAULT
.num_actions(num_spends, num_outputs)
.map_err(|e| {
ProtocolError::ShieldedBuildError(format!(
"invalid Orchard bundle shape ({num_spends} spends, {num_outputs} outputs): {e}"
))
})?;

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"
)));
}
Comment thread
bfoss765 marked this conversation as resolved.

Ok(num_actions)
}

/// Serializes an authorized Orchard bundle into the raw fields used by
/// state transition constructors.
pub fn serialize_authorized_bundle(bundle: &Bundle<Authorized, i64, DashMemo>) -> SerializedBundle {
Expand Down Expand Up @@ -781,4 +840,103 @@ mod mod_tests {
other => panic!("expected the closure's error to propagate, got {:?}", other),
}
}

// ------------------------------------------------------------------
// `shielded_bundle_action_count` — the shared fee-sizing predictor.
// ------------------------------------------------------------------

/// The predictor must be `max(num_spends, num_outputs)` padded to Orchard's 2-action
/// minimum — for the OUTPUT side as well as the spend side. The `num_outputs >= 3` rows are
/// the ones a spends-only predictor gets wrong.
#[test]
fn shielded_bundle_action_count_is_max_spends_outputs_padded_to_two() {
let platform_version = PlatformVersion::latest();
for (spends, outputs, expected) in [
(0usize, 1usize, 2usize),
(1, 1, 2),
(1, 2, 2),
(2, 2, 2),
// Output-dominated shapes: the spend count no longer determines the fee.
(1, 3, 3),
(2, 3, 3),
(1, 4, 4),
(5, 3, 5),
(3, 7, 7),
] {
let actual = shielded_bundle_action_count(spends, outputs, platform_version)
.expect("DEFAULT bundles accept any spend/output mix");
assert_eq!(
actual, expected,
"action count for {spends} spends / {outputs} outputs"
);
}
}

/// A real bundle's on-wire `actions.len()` — the number consensus prices the fee off — must
/// equal what the predictor said. Exercised through the output-only builder because it is
/// the cheapest real bundle to construct at several output counts.
#[test]
fn shielded_bundle_action_count_matches_a_real_bundle() {
let platform_version = PlatformVersion::latest();
let recipient = test_orchard_address();
// (dummy_outputs, total outputs = 1 real + dummies)
for dummies in [0usize, 1, 4] {
let num_outputs = 1 + dummies;
let bundle =
build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, dummies, &TestProver)
.expect("bundle should build");
let predicted = shielded_bundle_action_count(0, num_outputs, platform_version)
.expect("valid bundle shape");
assert_eq!(
bundle.actions().len(),
predicted,
"predicted action count must match the real bundle's on-wire count for \
{num_outputs} outputs"
);
}
}

/// The predictor is also the CONSENSUS gate: `validate_actions_count` rejects
/// `actions.len() > max_shielded_transition_actions`, but `try_from_bundle` runs no
/// structural validation — so a bundle over the ceiling would be proved (~30 s of Halo 2)
/// and only then rejected on chain. The boundary itself must still pass.
#[test]
fn shielded_bundle_action_count_accepts_the_consensus_boundary() {
let platform_version = PlatformVersion::latest();
let max = platform_version
.system_limits
.max_shielded_transition_actions as usize;

// Exactly at the ceiling, from each side.
assert_eq!(
shielded_bundle_action_count(1, max, platform_version)
.expect("the output-side boundary must be accepted"),
max
);
assert_eq!(
shielded_bundle_action_count(max, 1, platform_version)
.expect("the spend-side boundary must be accepted"),
max
);
}

/// One action over the ceiling must fail fast — from the OUTPUT side (the 16-recipient FFI
/// call, which becomes 17 outputs once the unconditional change output is added) and from
/// the SPEND side (a fragmented wallet selecting too many notes).
#[test]
fn shielded_bundle_action_count_rejects_over_the_consensus_limit() {
let platform_version = PlatformVersion::latest();
let max = platform_version
.system_limits
.max_shielded_transition_actions as usize;

for (spends, outputs) in [(1usize, max + 1), (max + 1, 1), (max + 1, max + 1)] {
let err = shielded_bundle_action_count(spends, outputs, platform_version)
.expect_err("a bundle over the consensus action limit must be rejected");
assert!(
err.to_string().contains("exceeding the consensus limit"),
"unexpected error for {spends} spends / {outputs} outputs: {err}"
);
}
}
}
Loading
Loading