Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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,61 @@ 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 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" }
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
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"
);
}
}
88 changes: 87 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 Down Expand Up @@ -103,6 +106,36 @@ 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**.
///
/// 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.
pub fn shielded_bundle_action_count(
num_spends: usize,
num_outputs: usize,
) -> Result<usize, ProtocolError> {
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}"
))
})
}

/// 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 +814,57 @@ 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() {
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)
.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 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).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"
);
}
}
}
Loading
Loading