Skip to content
Merged
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
91 changes: 87 additions & 4 deletions packages/rs-dpp/src/shielded/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ pub use unshield::build_unshield_transition;
use grovedb_commitment_tree::{
Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags,
FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey,
Scope, SpendAuthorizingKey,
Scope, SpendAuthorizingKey, SpendingKey,
};
use rand::rngs::OsRng;
use rand::RngCore;

use crate::address_funds::OrchardAddress;
use crate::shielded::{compute_platform_sighash, SerializedAction};
Expand Down Expand Up @@ -143,22 +144,55 @@ pub fn serialize_authorized_bundle(bundle: &Bundle<Authorized, i64, DashMemo>) -
// Internal helpers
// ---------------------------------------------------------------------------

/// Generates a fresh random Orchard payment address with no recoverable
/// spending authority retained by anyone.
///
/// Draws 32 random bytes for an Orchard `SpendingKey` (retrying on the
/// rare invalid draw — `SpendingKey::from_bytes` returns a `CtOption`),
/// derives its `FullViewingKey`, and returns the External-scope address
/// at diversifier index 0. The spending key is dropped here, so the
/// resulting address is unspendable by this process — exactly what a
/// zero-value anonymity-set filler output wants.
fn random_orchard_payment_address() -> PaymentAddress {
let mut rng = OsRng;
loop {
let mut bytes = [0u8; 32];
rng.fill_bytes(&mut bytes);
if let Some(sk) = Option::<SpendingKey>::from(SpendingKey::from_bytes(bytes)) {
let fvk = FullViewingKey::from(&sk);
return fvk.address_at(0u32, Scope::External);
}
}
}

/// Builds an output-only Orchard bundle (no spends).
///
/// Used by Shield and ShieldFromAssetLock transitions where funds enter
/// the shielded pool from transparent sources.
///
/// `sender_ovk` encrypts the output's `out_ciphertext` (Zcash
/// `sender_ovk` encrypts the real output's `out_ciphertext` (Zcash
/// outgoing-transaction-history convention): with `Some`, the sender can
/// later recover the note (recipient, value, memo) from chain data via
/// `try_recover_outgoing_note` under that OVK. With `None`, a random
/// outgoing cipher key is used and the sent note is unrecoverable by
/// anyone. Orchard's padding outputs always use `None`.
///
/// `dummy_outputs` adds that many extra **zero-value** outputs after the
/// real one, each to a fresh random Orchard address with `sender_ovk =
/// None` and an empty memo. They are unrecoverable by anyone (no party
/// holds the spending key) — they exist purely as anonymity-set filler
/// so a single transition can grow the on-chain note count. With
/// `dummy_outputs == 0` the bundle is byte-class identical to the
/// historical single-output form (Orchard still pads to its 2-action
/// minimum). The on-wire action count is
/// `max(1 + dummy_outputs, 2)` and the `value_balance` is unchanged
/// (the dummies contribute zero value).
pub(crate) fn build_output_only_bundle<P: OrchardProver>(
recipient: &OrchardAddress,
amount: u64,
memo: [u8; 36],
sender_ovk: Option<OutgoingViewingKey>,
dummy_outputs: usize,
prover: &P,
) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError> {
let payment_address = PaymentAddress::from(recipient);
Expand All @@ -180,6 +214,17 @@ pub(crate) fn build_output_only_bundle<P: OrchardProver>(
)
.map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;

// Anonymity-set filler: zero-value outputs to fresh random addresses,
// each with `None` OVK and an empty memo (unrecoverable by anyone).
for _ in 0..dummy_outputs {
let filler_address = random_orchard_payment_address();
builder
.add_output(None, filler_address, NoteValue::from_raw(0), [0u8; 36])
.map_err(|e| {
ProtocolError::ShieldedBuildError(format!("failed to add dummy output: {:?}", e))
})?;
}

prove_and_sign_bundle(builder, prover, &[], &[])
}

Expand Down Expand Up @@ -409,7 +454,7 @@ mod mod_tests {
#[test]
fn output_only_bundle_flags_and_value_balance() {
let recipient = test_orchard_address();
let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, &TestProver)
let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, 0, &TestProver)
.expect("bundle should build");

// Spends are disabled for Shield / ShieldFromAssetLock bundles.
Expand All @@ -423,6 +468,43 @@ mod mod_tests {
);
}

// ------------------------------------------------------------------
// `build_output_only_bundle` dummy-output padding — the on-wire
// action count is `max(1 + dummy_outputs, 2)` (Orchard pads an
// output-only bundle to its 2-action minimum) and the dummies are
// zero-value, so the bundle's `value_balance` still equals exactly
// the real recipient amount. This is the invariant the pool-seeding
// flow relies on: one transition publishes up to 6 actions (the most
// that fits the 20 KiB transition-size limit), all but one carrying
// no value. The cases stop at 5 dummies — the seeding maximum — to
// keep this real-proving test inside the CI shielded-step budget
// (proof cost grows with the action count).
// ------------------------------------------------------------------

#[test]
fn dummy_output_padding_action_count_and_value_balance() {
let recipient = test_orchard_address();
let amount = 10_000u64;

// (dummy_outputs, expected on-wire action count).
for (dummies, expected_actions) in [(0usize, 2usize), (1, 2), (5, 6)] {
let bundle =
build_output_only_bundle(&recipient, amount, [0u8; 36], None, dummies, &TestProver)
.expect("bundle should build");
assert_eq!(
bundle.actions().len(),
expected_actions,
"dummy_outputs={dummies} should serialize to {expected_actions} actions"
);
// Dummies are zero-value: net value entering the pool is unchanged.
assert_eq!(
*bundle.value_balance(),
-(amount as i64),
"value_balance must equal the real amount regardless of dummy_outputs ({dummies})"
);
}
}

// ------------------------------------------------------------------
// `serialize_authorized_bundle` — verify the mapping from a fully
// authorized bundle into the raw state-transition fields.
Expand All @@ -431,7 +513,7 @@ mod mod_tests {
#[test]
fn serialize_authorized_bundle_preserves_fields() {
let recipient = test_orchard_address();
let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, &TestProver)
let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, 0, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);

Expand Down Expand Up @@ -479,6 +561,7 @@ mod mod_tests {
amount,
memo,
Some(sender_ovk.clone()),
0,
&TestProver,
)
.expect("bundle should build");
Expand Down
4 changes: 3 additions & 1 deletion packages/rs-dpp/src/shielded/builder/shield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ pub async fn build_shield_transition<S: Signer<PlatformAddress>, P: OrchardProve
));
}

let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
// Shield (Type 15) never pads with anonymity-set fillers — only the
// Type 18 ShieldFromAssetLock pool-seeding path does (`dummy_outputs`).
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, 0, prover)?;
let sb = serialize_authorized_bundle(&bundle);

ShieldTransition::try_from_bundle_with_signer(
Expand Down
40 changes: 33 additions & 7 deletions packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver
/// - `surplus_output` - Optional platform address that receives the asset-lock surplus
/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee
/// pools, capped at `shielded_implicit_fee_cap`
/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after
/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0`
/// reproduces the historical single-output bundle exactly. The on-wire action count becomes
/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow.
/// - `platform_version` - Protocol version
#[allow(clippy::too_many_arguments)]
pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
Expand All @@ -39,9 +43,17 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
memo: [u8; 36],
sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
surplus_output: Option<PlatformAddress>,
dummy_outputs: usize,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
let bundle = build_output_only_bundle(
recipient,
shield_amount,
memo,
sender_ovk,
dummy_outputs,
prover,
)?;
let sb = serialize_authorized_bundle(&bundle);

// For output-only bundles, Orchard value_balance is negative (value flowing in).
Expand Down Expand Up @@ -91,6 +103,10 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
/// - `surplus_output` - Optional platform address that receives the asset-lock surplus
/// (`asset_lock_value − shield_amount − fee`); when `None`, the surplus is added to the fee
/// pools, capped at `shielded_implicit_fee_cap`
/// - `dummy_outputs` - Number of extra zero-value anonymity-set filler outputs to append after
/// the real recipient output (unrecoverable random addresses, `None` OVK, empty memo). `0`
/// reproduces the historical single-output bundle exactly. The on-wire action count becomes
/// `max(1 + dummy_outputs, 2)`, which consensus prices the fee from — see the pool-seeding flow.
/// - `platform_version` - Protocol version
#[cfg(feature = "core_key_wallet")]
#[allow(clippy::too_many_arguments)]
Expand All @@ -104,13 +120,21 @@ pub async fn build_shield_from_asset_lock_transition_with_signer<P, AS>(
memo: [u8; 36],
sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
surplus_output: Option<PlatformAddress>,
dummy_outputs: usize,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError>
where
P: OrchardProver,
AS: ::key_wallet::signer::Signer,
{
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
let bundle = build_output_only_bundle(
recipient,
shield_amount,
memo,
sender_ovk,
dummy_outputs,
prover,
)?;
let sb = serialize_authorized_bundle(&bundle);

// For output-only bundles, Orchard value_balance is negative (value flowing in).
Expand Down Expand Up @@ -153,7 +177,7 @@ mod tests {
let recipient = test_orchard_address();
let amount = 50_000u64;

let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver)
let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &TestProver)
.expect("bundle should build successfully");
let sb = serialize_authorized_bundle(&bundle);

Expand Down Expand Up @@ -181,8 +205,9 @@ mod tests {
#[test]
fn test_output_only_bundle_serializes_to_min_actions() {
let recipient = test_orchard_address();
let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, &TestProver)
.expect("bundle should build");
let bundle =
build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, 0, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);
assert_eq!(
sb.actions.len(),
Expand Down Expand Up @@ -223,8 +248,9 @@ mod tests {
// negative value_balance equal in magnitude to the requested amount.
for amount in [1u64, 100, 1_000_000, u32::MAX as u64] {
let recipient = test_orchard_address();
let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver)
.expect("bundle should build");
let bundle =
build_output_only_bundle(&recipient, amount, [0u8; 36], None, 0, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);
assert_eq!(
sb.value_balance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() {
[0u8; 36],
None, // sender_ovk
None, // surplus_output
0, // dummy_outputs
PlatformVersion::latest(),
)
.await
Expand All @@ -259,3 +260,59 @@ async fn build_shield_from_asset_lock_transition_with_signer_end_to_end() {
"output-only bundle must produce at least one Orchard action",
);
}

#[tokio::test]
async fn seed_pool_batch_fits_max_state_transition_size() {
// The pool-seeding batch size (MAX_ACTIONS_PER_BATCH = 6 in
// rs-platform-wallet's seed_pool.rs) is bounded by the 20 KiB
// transaction-size limit, not the 16-action consensus cap: the Halo 2
// proof grows ~2,681 bytes per action (measured: 2 actions → 8,294 B,
// 6 → 19,018 B, 7 → 21,699 B — the last rejected by tenderdash's
// `mempool.max-tx-bytes = 20480` as "Tx too large"). Pin the largest
// seeding batch under `system_limits.max_state_transition_size` so a
// proof- or action-encoding size change that breaks seeding fails here
// instead of on a devnet.
use crate::serialization::PlatformSerializable;
use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver};

let platform_version = PlatformVersion::latest();
let recipient = test_orchard_address();
let signer = FixedKeySigner::new([7u8; 32]);
let path = DerivationPath::default();

let st = build_shield_from_asset_lock_transition_with_signer(
&recipient,
50_000u64,
make_chain_asset_lock_proof(),
&path,
&signer,
&TestProver,
[0u8; 36],
None, // sender_ovk
None, // surplus_output
5, // dummy_outputs -> 6 on-wire actions, the seeding batch max
platform_version,
)
.await
.expect("builder should succeed");

let bytes = st.serialize_to_bytes().expect("serialize");
let max = platform_version.system_limits.max_state_transition_size as usize;
assert!(
bytes.len() <= max,
"a 6-action seeding batch must fit max_state_transition_size: {} > {}",
bytes.len(),
max,
);

let v0 = extract_v0(st);
assert_eq!(
v0.actions.len(),
6,
"1 real + 5 dummy outputs must serialize to 6 Orchard actions",
);
assert_eq!(
v0.value_balance, 50_000,
"dummy outputs are zero-value: value_balance must equal the real amount",
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits {
min_withdrawal_amount: 190_000,
max_contract_group_size: 256,
max_token_redemption_cycles: 128,
// 16 actions x 408 bytes + ~5,305 bytes overhead = ~11,833 bytes (within 20 KiB max_state_transition_size)
// NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on
// top of the 408 B serialized action), so a transition's on-wire size is
// ~2,681 B per action + ~2,930 B fixed (measured: 2 actions → 8,294 B,
// 6 → 19,018 B). The effective per-transition action bound under the
// 20 KiB `max_state_transition_size` is therefore 6, NOT this cap — 16
// only becomes reachable if the size limit is raised. Pinned by dpp's
// `seed_pool_batch_fits_max_state_transition_size` signing test.
max_shielded_transition_actions: 16,
};
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits {
min_withdrawal_amount: 1_000_000, //1000 duffs (raised from 190 in v12)
max_contract_group_size: 256,
max_token_redemption_cycles: 128,
// 16 actions x 408 bytes + ~5,305 bytes overhead = ~11,833 bytes (within 20 KiB max_state_transition_size)
// NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on
// top of the 408 B serialized action), so a transition's on-wire size is
// ~2,681 B per action + ~2,930 B fixed (measured: 2 actions → 8,294 B,
// 6 → 19,018 B). The effective per-transition action bound under the
// 20 KiB `max_state_transition_size` is therefore 6, NOT this cap — 16
// only becomes reachable if the size limit is raised. Pinned by dpp's
// `seed_pool_batch_fits_max_state_transition_size` signing test.
max_shielded_transition_actions: 16,
};
Loading
Loading