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
9 changes: 9 additions & 0 deletions packages/wasm-utxo/docs/zcash-v6-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ Ironwood: nActionsIronwood(varint) actions[820×n] flags(1) valueBalance(8)
// whole block present only if nActionsIronwood > 0
```

The `flags` byte: bit 0 `enableSpends`, bit 1 `enableOutputs`, bit 2
`enableCrossAddress` (Ironwood-only; reserved-and-zero in the Orchard slot). A bundle
built here sets spends/outputs exactly when it carries real spends/outputs — a
shielding build is `0x06` (its one spend per output is the fabricated value-0 dummy),
a deshield is `0x05` (its unpaired output halves are value-0 dummies), and a
shielded transfer is `0x07`. The mined `unshield` and `shield*` goldens predate this and
carry `0x07` (all three enables, as the reference wallet sets). Both forms are valid:
a disabled side only adds a value-0 constraint.

Each Ironwood action is 820 bytes: `cv(32) nullifier(32) rk(32) cmx(32) epk(32)
encCiphertext(580) outCiphertext(80)`. This codec supports empty Sapling/Orchard
slots plus a populated Ironwood bundle; non-empty Sapling/Orchard are rejected with a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ fn build_sign_combine_produces_valid_v6_tx() {
#[test]
fn combine_ironwood_proof_locally_produces_a_verifying_proof() {
use nonempty::NonEmpty;
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion};
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion, Flags};
use orchard::circuit::{OrchardCircuitVersion, VerifyingKey};
use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext};
use orchard::primitives::redpallas::{Binding, Signature, SpendAuth, VerificationKey};
Expand Down Expand Up @@ -297,7 +297,7 @@ fn combine_ironwood_proof_locally_produces_a_verifying_proof() {
);
let orchard_bundle = OrchardBundle::<Authorized, i64>::try_from_parts(
actions,
BundleVersion::ironwood_v3().default_flags(),
Flags::from_byte(bundle.flags, BundleVersion::ironwood_v3()).expect("valid flags"),
bundle.value_balance,
Option::from(Anchor::from_bytes(bundle.anchor)).expect("valid anchor"),
authorization,
Expand Down Expand Up @@ -2236,7 +2236,7 @@ fn combine_rejects_a_proof_sized_for_a_different_action_count() {
#[test]
fn multi_spend_multi_output_proves_and_verifies_locally() {
use nonempty::NonEmpty;
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion};
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion, Flags};
use orchard::circuit::{OrchardCircuitVersion, VerifyingKey};
use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext};
use orchard::primitives::redpallas::{Binding, Signature, SpendAuth, VerificationKey};
Expand Down Expand Up @@ -2292,7 +2292,7 @@ fn multi_spend_multi_output_proves_and_verifies_locally() {
);
let orchard_bundle = OrchardBundle::<Authorized, i64>::try_from_parts(
actions,
BundleVersion::ironwood_v3().default_flags(),
Flags::from_byte(bundle.flags, BundleVersion::ironwood_v3()).expect("valid flags"),
bundle.value_balance,
Option::from(orchard::tree::Anchor::from_bytes(bundle.anchor)).expect("valid anchor"),
authorization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ fn prove_and_verify_locally(
n_actions: usize,
) {
use nonempty::NonEmpty;
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion};
use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion, Flags};
use orchard::circuit::{OrchardCircuitVersion, VerifyingKey};
use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext};
use orchard::primitives::redpallas::{Binding, Signature};
Expand Down Expand Up @@ -773,7 +773,7 @@ fn prove_and_verify_locally(
);
let orchard_bundle = OrchardBundle::<Authorized, i64>::try_from_parts(
actions,
BundleVersion::ironwood_v3().default_flags(),
Flags::from_byte(bundle.flags, BundleVersion::ironwood_v3()).expect("valid flags"),
bundle.value_balance,
Option::from(orchard::tree::Anchor::from_bytes(bundle.anchor)).expect("valid anchor"),
authorization,
Expand Down Expand Up @@ -1666,7 +1666,9 @@ fn generate_ts_spend_fixtures() {
transparent_output,
);
let path = dir.join(format!("ironwood_spend_{name}.json"));
std::fs::write(&path, serde_json::to_string_pretty(&fixture).unwrap()).unwrap();
let mut json = serde_json::to_string_pretty(&fixture).unwrap();
json.push('\n');
std::fs::write(&path, json).unwrap();
println!("wrote {}", path.display());
}
}
Expand Down
46 changes: 37 additions & 9 deletions packages/wasm-utxo/src/zcash/ironwood_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
use rand::{CryptoRng, RngCore};

use orchard::builder::{Builder, BundleType};
use orchard::bundle::BundleVersion;
use orchard::bundle::{BundleVersion, Flags};
#[cfg(any(test, feature = "orchard-proving"))]
use orchard::keys::Scope;
use orchard::keys::{FullViewingKey, OutgoingViewingKey};
Expand Down Expand Up @@ -326,8 +326,11 @@ pub enum IronwoodVerifyCheck {
/// A real spend carries its randomizer `alpha` on the wire: with `rk` it reveals the
/// wallet's `ak` (`ak = rk − [alpha]·G`).
RandomizerPresent,
/// The bundle is not an Ironwood V3 bundle with the default flags.
/// The bundle is not an Ironwood V3 bundle.
BundleVersion,
/// The bundle's flags do not match its contents (spends enabled iff a real spend is
/// present, outputs iff a real output is, cross-address always enabled).
Flags,
/// A value-0 (dummy) output is paired with a dummy spend, so the value-based
/// accounting cannot distinguish it from a real output.
ZeroValueOutput,
Expand Down Expand Up @@ -371,6 +374,7 @@ impl core::fmt::Display for IronwoodVerifyCheck {
Self::ViewingKeyPresent => "viewing key present",
Self::RandomizerPresent => "randomizer present",
Self::BundleVersion => "bundle version",
Self::Flags => "flags",
Self::ZeroValueOutput => "zero-value output",
Self::SpendSignature => "spend signature",
Self::MissingPlaintext => "missing plaintext",
Expand Down Expand Up @@ -666,6 +670,27 @@ impl IronwoodActionMap {
}
}

/// The Ironwood bundle flags for a build with the given real sides: spends are enabled iff the
/// bundle carries real spends, outputs iff it carries real outputs, and cross-address transfers
/// are always enabled (the least-restrictive value the Ironwood V3 version permits). Fabricated
/// value-0 halves — the dummy spends on shielding builds, the dummy outputs on deshields —
/// satisfy the disabled bits: the action circuit only forbids non-zero value on a disabled side.
pub(crate) fn ironwood_flags(has_spends: bool, has_outputs: bool) -> Flags {
// Flag-byte layout (Zcash §7.1): bit 0 enables spends, bit 1 outputs, and bit 2
// (`enableCrossAddress`) is set on every Ironwood V3 bundle.
let byte = 0b100 | u8::from(has_spends) | (u8::from(has_outputs) << 1);
Flags::from_byte(byte, BundleVersion::ironwood_v3())
.expect("the Ironwood V3 flag byte is always representable")
}

/// The wire byte of [`ironwood_flags`].
pub(crate) fn ironwood_flag_byte(has_spends: bool, has_outputs: bool) -> u8 {
let bundle_version = BundleVersion::ironwood_v3();
ironwood_flags(has_spends, has_outputs)
.to_byte(bundle_version)
.expect("the Ironwood V3 flag byte is always representable")
}

/// Constructor: build an Ironwood bundle as an orchard PCZT from real spends and/or requested
/// outputs — the general Constructor for the shielding (outputs only), unshielding (spends only),
/// and shielded-transfer flows.
Expand Down Expand Up @@ -714,7 +739,7 @@ pub fn construct_spend_pczt_multi<R: RngCore + CryptoRng>(
let mut builder = Builder::new(
BundleType::UNPADDED,
bundle_version,
bundle_version.default_flags(),
ironwood_flags(!spends.is_empty(), !outputs.is_empty()),
anchor_root,
)
.map_err(|e| IronwoodBuildError::Builder(e.to_string()))?;
Expand Down Expand Up @@ -846,6 +871,8 @@ fn validate_constructed_out_ciphertexts<R: RngCore + CryptoRng>(
/// Constructor: build a transparent → Ironwood shielding bundle (no real spends) as an orchard
/// PCZT, for one or more outputs. Each output rides an orchard-`Builder`-fabricated dummy spend
/// (`BundleType::UNPADDED`, so exactly one action per output); no key is needed.
/// The bundle's flags disable spends: the fabricated dummy spends are declared dummies, not
/// real spends.
///
/// Also the dummy-spend fabricator the keyless spend constructor
/// ([`crate::zcash::ironwood_construct::construct_spend_pczt_keyless`]) reuses for surplus
Expand All @@ -864,7 +891,7 @@ pub fn construct_shield_pczt_multi<R: RngCore + CryptoRng>(
let mut builder = Builder::new(
BundleType::UNPADDED,
bundle_version,
bundle_version.default_flags(),
ironwood_flags(false, true),
anchor_root,
)
.map_err(|e| IronwoodBuildError::Builder(e.to_string()))?;
Expand Down Expand Up @@ -1822,8 +1849,9 @@ mod tests {
.unwrap();
let data = pczt_action_data(&pczt).unwrap();
assert_eq!(data.actions.len(), 1, "UNPADDED single output ⇒ one action");
// ironwood_v3 default flags = spends | outputs | cross-address.
assert_eq!(data.flags, 0x07);
// A shielding bundle disables spends (its only spend is the fabricated dummy):
// outputs | cross-address.
assert_eq!(data.flags, 0x06);
// A pure output bundle (dummy spend has value 0): value_balance = 0 - amount.
assert_eq!(data.value_balance, -(amount as i64));
}
Expand Down Expand Up @@ -2391,7 +2419,7 @@ mod tests {
assert_eq!(bundle.actions.len(), 1);
assert_eq!(bundle.spend_auth_sigs.len(), 1);
assert_eq!(bundle.proof.len(), Proof::expected_proof_size(1));
assert_eq!(bundle.flags, 0x07);
assert_eq!(bundle.flags, 0x06);

// The action data survived unchanged from build → combine (digests are stable).
assert_eq!(
Expand Down Expand Up @@ -2525,7 +2553,7 @@ mod tests {

let orchard_bundle = OrchardBundle::<EffectsOnly, i64>::from_parts(
actions,
BundleVersion::ironwood_v3().default_flags(),
Flags::from_byte(bundle.flags, BundleVersion::ironwood_v3()).expect("valid flags"),
bundle.value_balance,
Option::from(Anchor::from_bytes(bundle.anchor)).expect("valid anchor"),
EffectsOnly,
Expand Down Expand Up @@ -3275,7 +3303,7 @@ mod tests {
verify_spend_pczt(&bundle).unwrap();

// Shielding bundle (dummy spend): every check is skipped, the cross-address restriction
// is a no-op under the default flags.
// is a no-op with cross-address enabled.
let shield =
construct_shield_pczt(&test_recipient(), 1_000, None, &anchor, &[0u8; 512], OsRng)
.unwrap();
Expand Down
20 changes: 8 additions & 12 deletions packages/wasm-utxo/src/zcash/ironwood_construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ struct ActionOrigin {
/// and/or requested outputs — the deshield and shielded→shielded Constructor for a build
/// service that holds no viewing keys.
///
/// The bundle has `max(spends.len(), outputs.len())` actions (the Ironwood V3 default flags
/// enable cross-address transfers, so a spend and an output share an action): where one side
/// The bundle has `max(spends.len(), outputs.len())` actions (cross-address transfers are
/// enabled in the bundle's flags, so a spend and an output share an action): where one side
/// runs out, the missing half is a protocol dummy — a value-0 output to a fresh random address
/// for surplus spends, an orchard-`Builder`-fabricated dummy spend for surplus outputs. At
/// least one real spend is required: output-only bundles are *shielding* builds
Expand Down Expand Up @@ -245,7 +245,7 @@ pub fn construct_spend_pczt_keyless<R: RngCore + CryptoRng>(
(output_total - spend_total, true)
};

let bundle = assemble_bundle(wires, value_sum, anchor)?;
let bundle = assemble_bundle(wires, value_sum, anchor, !outputs.is_empty())?;

// Self-check: the Constructor's own output must pass every key-free
// check the client will later run on it — note commitments, value commitments, the
Expand Down Expand Up @@ -570,16 +570,12 @@ fn assemble_bundle(
actions: Vec<ActionWire>,
value_sum: (u64, bool),
anchor: &AnchorBytes,
has_outputs: bool,
) -> Result<PcztBundle, IronwoodBuildError> {
let bundle_version = BundleVersion::ironwood_v3();
let flags = bundle_version
.default_flags()
.to_byte(bundle_version)
.ok_or_else(|| {
IronwoodBuildError::Builder(
"the Ironwood V3 default flags have no byte encoding".to_string(),
)
})?;
// The keyless constructor always carries real spends; outputs are enabled iff any were
// requested. The fabricated halves on the surplus side (value-0 dummies) satisfy the
// disabled bit.
let flags = super::ironwood_build::ironwood_flag_byte(true, has_outputs);
let wire = super::ironwood_pczt::BundleWire {
flags,
value_pool: 1, // Ironwood
Expand Down
26 changes: 15 additions & 11 deletions packages/wasm-utxo/src/zcash/ironwood_pczt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,17 @@ pub(crate) fn with_output_rseed_for_test(
})
}

/// Test-only: replace the bundle's flags byte, producing a PCZT whose declared enables disagree
/// with its actions. Exists to exercise the key-free verifier's flag-consistency rejection
/// ([`IronwoodVerifyCheck::Flags`]).
#[cfg(test)]
pub(crate) fn with_flags_for_test(bytes: &[u8], flags: u8) -> Result<Vec<u8>, IronwoodPcztError> {
with_patched_wire(bytes, |wire| {
wire.flags = flags;
Ok(())
})
}

/// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form.
pub fn deserialize_pczt(bytes: &[u8]) -> Result<PcztBundle, IronwoodPcztError> {
let (&version, body) = bytes.split_first().ok_or(IronwoodPcztError::Empty)?;
Expand Down Expand Up @@ -765,18 +776,18 @@ mod tests {
use rand::rngs::OsRng;

use crate::zcash::ironwood_build::test_field_bytes as field_bytes;
use crate::zcash::ironwood_build::{ironwood_flag_byte, ironwood_flags};

/// Construct a shielding PCZT (one Ironwood output, dummy spend) via the orchard Constructor.
fn sample_pczt() -> PcztBundle {
let sk = Option::<SpendingKey>::from(SpendingKey::from_bytes([7u8; 32])).unwrap();
let fvk = FullViewingKey::from(&sk);
let recipient = fvk.address_at(0u32, Scope::External);
let bundle_version = BundleVersion::ironwood_v3();
let flags = bundle_version.default_flags();
let mut builder = Builder::new(
BundleType::UNPADDED,
bundle_version,
flags,
ironwood_flags(false, true),
Anchor::empty_tree(),
)
.unwrap();
Expand All @@ -801,14 +812,7 @@ mod tests {
// directions, so also assert against sample_pczt()'s known values.
let wire: BundleWire = postcard::from_bytes(&bytes[1..]).unwrap();
assert_eq!(wire.anchor, Anchor::empty_tree().to_bytes());
let bundle_version = BundleVersion::ironwood_v3();
assert_eq!(
wire.flags,
bundle_version
.default_flags()
.to_byte(bundle_version)
.unwrap()
);
assert_eq!(wire.flags, ironwood_flag_byte(false, true));
assert_eq!(wire.actions.len(), 1);
assert_eq!(wire.value_sum_magnitude, 100_000_000);
assert!(wire.value_sum_negative);
Expand Down Expand Up @@ -946,7 +950,7 @@ mod tests {
let mut builder = Builder::new(
BundleType::UNPADDED,
bundle_version,
bundle_version.default_flags(),
ironwood_flags(false, true),
Anchor::empty_tree(),
)
.unwrap();
Expand Down
Loading
Loading