Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 on lines +151 to +160

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

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

source: ['codex']


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