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
107 changes: 98 additions & 9 deletions packages/rs-dpp/src/shielded/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
//! &fvk.address_at(0, Scope::External).to_raw_address_bytes(),
//! );
//!
//! // Build a shield transition
//! // Build a shield transition; pass the sender's OVK so the wallet can
//! // later recover its own send from chain data (None = unrecoverable)
//! let pk = ProvingKey::build();
//! let st = build_shield_transition(
//! &recipient, shield_amount, inputs, fee_strategy,
//! &signer, 0, &pk, [0u8; 36], platform_version,
//! &signer, 0, &pk, [0u8; 36], Some(fvk.to_ovk(Scope::External)), platform_version,
//! )?;
//! ```

Expand All @@ -48,7 +49,8 @@ pub use unshield::build_unshield_transition;

use grovedb_commitment_tree::{
Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags,
FullViewingKey, MerklePath, Note, NoteValue, PaymentAddress, ProvingKey, SpendAuthorizingKey,
FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey,
Scope, SpendAuthorizingKey,
};
use rand::rngs::OsRng;

Expand Down Expand Up @@ -145,10 +147,18 @@ pub fn serialize_authorized_bundle(bundle: &Bundle<Authorized, i64, DashMemo>) -
///
/// Used by Shield and ShieldFromAssetLock transitions where funds enter
/// the shielded pool from transparent sources.
///
/// `sender_ovk` encrypts the 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`.
pub(crate) fn build_output_only_bundle<P: OrchardProver>(
recipient: &OrchardAddress,
amount: u64,
memo: [u8; 36],
sender_ovk: Option<OutgoingViewingKey>,
prover: &P,
) -> Result<Bundle<Authorized, i64, DashMemo>, ProtocolError> {
let payment_address = PaymentAddress::from(recipient);
Expand All @@ -162,16 +172,25 @@ pub(crate) fn build_output_only_bundle<P: OrchardProver>(
);

builder
.add_output(None, payment_address, NoteValue::from_raw(amount), memo)
.add_output(
sender_ovk,
payment_address,
NoteValue::from_raw(amount),
memo,
)
.map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to add output: {:?}", e)))?;

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

/// Builds a spend+output Orchard bundle.
///
/// Used by ShieldedTransfer, Unshield, and ShieldedWithdrawal where funds
/// are spent from existing notes.
/// Used by Unshield, ShieldedWithdrawal, and IdentityCreateFromShieldedPool
/// where funds are spent from existing notes. The single shielded output is
/// the spender's change note; its `out_ciphertext` is encrypted under the
/// spender's own External-scope OVK (derived from `fvk`) so the wallet can
/// recover the note — including its structured memo, which the compact IVK
/// scan path never sees — from chain data alone.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_spend_bundle<P: OrchardProver>(
spends: Vec<SpendableNote>,
Expand All @@ -198,7 +217,7 @@ pub(crate) fn build_spend_bundle<P: OrchardProver>(

builder
.add_output(
None,
Some(fvk.to_ovk(Scope::External)),
payment_address,
NoteValue::from_raw(output_amount),
memo,
Expand Down Expand Up @@ -328,7 +347,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], &TestProver)
let bundle = build_output_only_bundle(&recipient, 10_000, [0u8; 36], None, &TestProver)
.expect("bundle should build");

// Spends are disabled for Shield / ShieldFromAssetLock bundles.
Expand All @@ -350,7 +369,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], &TestProver)
let bundle = build_output_only_bundle(&recipient, 7_777, [3u8; 36], None, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);

Expand All @@ -374,6 +393,76 @@ mod mod_tests {
}
}

// ------------------------------------------------------------------
// OVK outgoing-history round trip: an output built with the sender's
// OVK must recover (note, recipient, memo) under that same OVK — the
// Zcash convention that lets a wallet reconstruct its send history
// from chain data alone — and must stay opaque to any other OVK.
// ------------------------------------------------------------------

#[test]
fn output_built_with_sender_ovk_recovers_under_that_ovk_only() {
use grovedb_commitment_tree::{try_output_recovery_with_ovk, OrchardDomain, Scope};

let sk = SpendingKey::from_bytes([42u8; 32]).expect("valid spending key bytes");
let sender_ovk = FullViewingKey::from(&sk).to_ovk(Scope::External);

let recipient = test_orchard_address();
let amount = 31_337u64;
let mut memo = [0u8; 36];
memo[..9].copy_from_slice(b"ovk-round");

let bundle = build_output_only_bundle(
&recipient,
amount,
memo,
Some(sender_ovk.clone()),
&TestProver,
)
.expect("bundle should build");

let recover_all = |ovk: &grovedb_commitment_tree::OutgoingViewingKey| {
bundle
.actions()
.iter()
.filter_map(|action| {
let domain = OrchardDomain::<DashMemo>::for_action(action);
try_output_recovery_with_ovk(
&domain,
ovk,
action,
action.cv_net(),
&action.encrypted_note().out_ciphertext,
)
})
.collect::<Vec<_>>()
};

let recovered = recover_all(&sender_ovk);
assert_eq!(
recovered.len(),
1,
"exactly the real recipient output must recover; padding stays opaque"
);
let (note, recovered_addr, recovered_memo) = &recovered[0];
assert_eq!(note.value().inner(), amount, "recovered value mismatch");
assert_eq!(
recovered_addr.to_raw_address_bytes(),
recipient.inner().to_raw_address_bytes(),
"recovered recipient mismatch"
);
assert_eq!(*recovered_memo, memo, "recovered memo mismatch");

// A different wallet's OVK opens nothing — no false positives in
// anyone else's send history.
let other_sk = SpendingKey::from_bytes([7u8; 32]).expect("valid spending key bytes");
let other_ovk = FullViewingKey::from(&other_sk).to_ovk(Scope::External);
assert!(
recover_all(&other_ovk).is_empty(),
"a foreign OVK must not recover the output"
);
}

// ------------------------------------------------------------------
// `From<&OrchardAddress> for PaymentAddress` delegates to `inner()`.
// ------------------------------------------------------------------
Expand Down
13 changes: 12 additions & 1 deletion packages/rs-dpp/src/shielded/builder/shield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver
/// - `user_fee_increase` - Fee multiplier (0 = 100% base fee)
/// - `prover` - Orchard prover (holds the Halo 2 proving key; cache with `OnceLock` — ~30s to build)
/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the
/// recipient output's `out_ciphertext` is encrypted under it so the sender can later
/// recover the sent note (recipient, value, memo) from chain data via OVK recovery —
/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing
/// cipher key is used and the sent note is unrecoverable by anyone.
/// - `platform_version` - Protocol version
#[allow(clippy::too_many_arguments)]
pub async fn build_shield_transition<S: Signer<PlatformAddress>, P: OrchardProver>(
Expand All @@ -38,6 +43,7 @@ pub async fn build_shield_transition<S: Signer<PlatformAddress>, P: OrchardProve
user_fee_increase: UserFeeIncrease,
prover: &P,
memo: [u8; 36],
sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
if fee_strategy.is_empty() {
Expand All @@ -46,7 +52,7 @@ pub async fn build_shield_transition<S: Signer<PlatformAddress>, P: OrchardProve
));
}

let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?;
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
let sb = serialize_authorized_bundle(&bundle);

ShieldTransition::try_from_bundle_with_signer(
Expand Down Expand Up @@ -115,6 +121,7 @@ mod tests {
0,
&TestProver,
[0u8; 36],
None,
platform_version,
)
.await;
Expand Down Expand Up @@ -148,6 +155,7 @@ mod tests {
0,
&TestProver,
[0u8; 36],
None,
platform_version,
)
.await;
Expand Down Expand Up @@ -186,6 +194,7 @@ mod tests {
0,
&TestProver,
[0u8; 36],
None,
platform_version,
)
.await;
Expand Down Expand Up @@ -217,6 +226,7 @@ mod tests {
42, // non-zero fee increase
&TestProver,
[9u8; 36],
None,
platform_version,
)
.await;
Expand Down Expand Up @@ -252,6 +262,7 @@ mod tests {
0,
&TestProver,
memo,
None,
platform_version,
)
.await;
Expand Down
22 changes: 17 additions & 5 deletions packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ use super::{build_output_only_bundle, serialize_authorized_bundle, OrchardProver
/// - `asset_lock_private_key` - Private key for the asset lock (signs the transition)
/// - `prover` - Orchard prover (holds the Halo 2 proving key)
/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the
/// recipient output's `out_ciphertext` is encrypted under it so the sender can later
/// recover the sent note (recipient, value, memo) from chain data via OVK recovery —
/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing
/// cipher key is used and the sent note is unrecoverable by anyone.
/// - `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`
Expand All @@ -32,10 +37,11 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
asset_lock_private_key: &[u8],
prover: &P,
memo: [u8; 36],
sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
surplus_output: Option<PlatformAddress>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError> {
let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?;
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
let sb = serialize_authorized_bundle(&bundle);

// For output-only bundles, Orchard value_balance is negative (value flowing in).
Expand Down Expand Up @@ -77,6 +83,11 @@ pub fn build_shield_from_asset_lock_transition<P: OrchardProver>(
/// - `asset_lock_signer` - External signer that produces the outer ECDSA signature
/// - `prover` - Orchard prover (holds the Halo 2 proving key)
/// - `memo` - 36-byte structured memo for the recipient (4-byte type tag + 32-byte payload)
/// - `sender_ovk` - The sender's outgoing viewing key (External scope). With `Some`, the
/// recipient output's `out_ciphertext` is encrypted under it so the sender can later
/// recover the sent note (recipient, value, memo) from chain data via OVK recovery —
/// the Zcash outgoing-transaction-history convention. With `None`, a random outgoing
/// cipher key is used and the sent note is unrecoverable by anyone.
/// - `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`
Expand All @@ -91,14 +102,15 @@ pub async fn build_shield_from_asset_lock_transition_with_signer<P, AS>(
asset_lock_signer: &AS,
prover: &P,
memo: [u8; 36],
sender_ovk: Option<grovedb_commitment_tree::OutgoingViewingKey>,
surplus_output: Option<PlatformAddress>,
platform_version: &PlatformVersion,
) -> Result<StateTransition, ProtocolError>
where
P: OrchardProver,
AS: ::key_wallet::signer::Signer,
{
let bundle = build_output_only_bundle(recipient, shield_amount, memo, prover)?;
let bundle = build_output_only_bundle(recipient, shield_amount, memo, sender_ovk, prover)?;
let sb = serialize_authorized_bundle(&bundle);

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

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

Expand Down Expand Up @@ -169,7 +181,7 @@ 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], &TestProver)
let bundle = build_output_only_bundle(&recipient, 50_000u64, [0u8; 36], None, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);
assert_eq!(
Expand Down Expand Up @@ -211,7 +223,7 @@ 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], &TestProver)
let bundle = build_output_only_bundle(&recipient, amount, [0u8; 36], None, &TestProver)
.expect("bundle should build");
let sb = serialize_authorized_bundle(&bundle);
assert_eq!(
Expand Down
17 changes: 14 additions & 3 deletions packages/rs-dpp/src/shielded/builder/shielded_transfer.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use grovedb_commitment_tree::{
Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress,
Anchor, Builder, BundleType, DashMemo, FullViewingKey, NoteValue, PaymentAddress, Scope,
SpendAuthorizingKey,
};

Expand All @@ -20,6 +20,10 @@ use super::{prove_and_sign_bundle, serialize_authorized_bundle, OrchardProver, S
/// fee is deducted from the spent notes. Any remaining change is returned to
/// the `change_address`.
///
/// Both real outputs are encrypted with the sender's External-scope OVK
/// (derived from `fvk`), so the sender can recover its own send history
/// (recipient, value, memo) from chain data via OVK recovery.
///
/// # Parameters
/// - `spends` - Notes to spend with their Merkle paths
/// - `recipient` - Orchard address to receive the transferred note
Expand Down Expand Up @@ -82,10 +86,17 @@ pub fn build_shielded_transfer_transition<P: OrchardProver>(
})?;
}

// Both real outputs carry an `out_ciphertext` encrypted under the sender's
// External-scope OVK (the Zcash outgoing-transaction-history convention),
// so the sender can recover its own send history — recipient, value, memo —
// from chain data alone. Without it, the outgoing cipher key is random and
// the sent note is unrecoverable by anyone, including the sender.
let sender_ovk = fvk.to_ovk(Scope::External);

// Primary output to recipient
builder
.add_output(
None,
Some(sender_ovk.clone()),
recipient_payment,
NoteValue::from_raw(transfer_amount),
memo,
Expand All @@ -97,7 +108,7 @@ pub fn build_shielded_transfer_transition<P: OrchardProver>(
let change_payment = PaymentAddress::from(change_address);
builder
.add_output(
None,
Some(sender_ovk),
change_payment,
NoteValue::from_raw(change_amount),
[0u8; 36],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen
///
/// Spends existing notes and withdraws value to a core chain script output.
/// The shielded fee is deducted from the spent notes. Any remaining value is
/// returned to the shielded `change_address`.
/// returned to the shielded `change_address`; the change note is encrypted
/// with the sender's External-scope OVK (derived from `fvk`) so the wallet
/// can recover it — including the structured memo — via OVK recovery.
///
/// # Parameters
/// - `spends` - Notes to spend with their Merkle paths
Expand Down
4 changes: 3 additions & 1 deletion packages/rs-dpp/src/shielded/builder/unshield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use super::{build_spend_bundle, serialize_authorized_bundle, OrchardProver, Spen
///
/// Spends existing notes and sends part of the value to a transparent platform
/// address. The shielded fee is deducted from the spent notes. Any remaining
/// value is returned to the shielded `change_address`.
/// value is returned to the shielded `change_address`; the change note is
/// encrypted with the sender's External-scope OVK (derived from `fvk`) so the
/// wallet can recover it — including the structured memo — via OVK recovery.
///
/// # Parameters
/// - `spends` - Notes to spend with their Merkle paths
Expand Down
Loading
Loading