From a9b5403cc6bf26b31ff957e1b7368b1216cc30e5 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Sun, 28 Jun 2026 15:38:53 -0600 Subject: [PATCH 1/5] Distinguish value pool, protocol version, and bundle version Replace `BundlePoolRestrictions` with `BundleVersion`, the `(ValuePool, ProtocolVersion)` of an Orchard bundle, built from the new top-level `ValuePool` and `ProtocolVersion` types via safe-by-construction `const fn` constructors (`orchard_insecure_v0`, `orchard_v1`, `orchard_v2`, `ironwood_v2`). Each `Bundle` now carries its `BundleVersion` as non-serialized context, so a bundle is encodable and committable by construction: - Construction (`Bundle::from_parts` / `try_from_parts`, the builder, and PCZT parsing/extraction) takes a `BundleVersion` and validates that the flags are representable under it, rejecting inconsistent combinations with the new `BundleError::UnrepresentableFlags`. `from_parts` is now fallible. - `commitment` / `authorizing_commitment` and the `decrypt_*` / `recover_*` helpers no longer take a version argument; they read it from the bundle. `Bundle::flag_byte` exposes the now-infallible flag-byte encoding and `Bundle::bundle_version` the carried version. - `CommitmentError::UnrepresentableFlags` is removed (flags are validated at construction); only `InvalidTransactionVersion` remains. Proof-size enforcement is derived from the bundle version rather than a separate `ProofSizeEnforcement` argument: it is enforced for every version except the historical pre-NU6.2 Orchard pool (`orchard_insecure_v0`), whose already-committed transactions may carry non-canonical proofs. `ProofSizeEnforcement` is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 183 ++++---- benches/circuit.rs | 4 +- benches/note_decryption.rs | 4 +- src/builder.rs | 224 +++++----- src/bundle.rs | 830 +++++++++++++++++++++---------------- src/bundle/commitments.rs | 54 ++- src/circuit.rs | 10 +- src/lib.rs | 39 ++ src/note_encryption.rs | 24 +- src/pczt.rs | 101 +++-- src/pczt/parse.rs | 11 +- src/pczt/prover.rs | 8 +- src/pczt/tx_extractor.rs | 10 + src/pczt/verify.rs | 2 +- tests/builder.rs | 166 ++------ 15 files changed, 885 insertions(+), 785 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d593566..8d03479a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,34 @@ and this project adheres to Rust's notion of ## [Unreleased] -This release introduces `orchard::bundle::BundlePoolRestrictions`, a `(pool, era)` -selector now threaded through bundle construction, wire encoding, and commitments, -alongside the post-NU 6.3 Action circuit that enforces the cross-address restriction. -Existing callers keep the current behavior by selecting -`BundlePoolRestrictions::OrchardNu6_2Only` (and `OrchardCircuitVersion::FixedPostNu6_2` -when building proving/verifying keys). +This release introduces `orchard::bundle::BundleVersion`, the `(value pool, protocol +version)` of an Orchard bundle, built from the new `orchard::ValuePool` and +`orchard::ProtocolVersion` types. Each `Bundle` now carries its `BundleVersion` as +non-serialized context, so a bundle can be serialized and committed to without separately +supplying a — possibly mismatching — version, and is encodable and committable by +construction. The post-NU 6.3 Action circuit enforces the cross-address restriction. +Existing callers keep the current behavior by constructing bundles with +`BundleVersion::orchard_v1()` (and `OrchardCircuitVersion::FixedPostNu6_2` when building +proving/verifying keys). ### Added -- NU6.3 and Ironwood bundle-selection APIs: - - `orchard::bundle::BundlePoolRestrictions`, the `(pool, era)` selector for an - Orchard bundle. It determines the note plaintext version - (`BundlePoolRestrictions::note_version`), the circuit version - (`BundlePoolRestrictions::circuit_version`, when the `circuit` feature is - enabled), the flag-byte interpretation (pre-NU6.3 rules, where bit 2 is - reserved and cross-address transfers are implicitly enabled, vs NU6.3 rules, - where bit 2 is `enableCrossAddress`), and whether consensus mandates the - cross-address restriction (the builder then chooses the value within that - constraint). Variants: `OrchardPreNu6_2`, `OrchardNu6_2Only`, - `OrchardNu6_3Onward`, and `IronwoodNu6_3Onward` (which shares the post-NU6.3 - circuit and uses V3 note plaintexts). +- NU6.3 and Ironwood bundle-version APIs: + - `orchard::ValuePool`, the value pool an Orchard bundle belongs to (`Orchard` or + `Ironwood`), and `orchard::ProtocolVersion`, the Orchard protocol version + (`InsecureV0`, the historical pre-NU6.2 protocol that uses the unsound circuit; `V1`, + NU6.2; `V2`, NU6.3, which also instantiates the Ironwood pool). + - `orchard::bundle::BundleVersion`, the `(value pool, protocol version)` of an Orchard + bundle. Its `const fn` constructors `orchard_insecure_v0`, `orchard_v1`, `orchard_v2`, + and `ironwood_v2` make only the valid combinations representable. It determines the + note plaintext version (`BundleVersion::note_version`), the circuit version + (`BundleVersion::circuit_version`, when the `circuit` feature is enabled), the + flag-byte interpretation (pre-NU6.3 rules, where bit 2 is reserved and cross-address + transfers are implicitly enabled, vs NU6.3 rules, where bit 2 is `enableCrossAddress`), + and whether consensus mandates the cross-address restriction (the builder then chooses + the value within that constraint). `BundleVersion::value_pool` and + `BundleVersion::protocol_version` return the bundle's `ValuePool` and + `ProtocolVersion`; the Ironwood pool (`ironwood_v2`) shares the post-NU6.3 circuit and + uses V3 note plaintexts. - `orchard::bundle::TxVersion`, the transaction version (`V5` or `V6`) a bundle's commitments are computed for. At NU6.3 an Orchard bundle may be encoded in a v5 or a v6 transaction; the two use different commitment @@ -46,11 +54,12 @@ when building proving/verifying keys). introspection for whether a circuit version (or a key's circuit version) constrains the `disableCrossAddress` public input. - `orchard::circuit::VerifyingKey::circuit_version` -- `orchard::bundle::CommitmentError`, with its `UnrepresentableFlags` and - `InvalidTransactionVersion` variants, returned by bundle commitment APIs when - a bundle's flags cannot be represented under the requested - `BundlePoolRestrictions`, or when `IronwoodNu6_3Onward` is requested for a v5 - transaction. +- `orchard::bundle::CommitmentError`, with its `InvalidTransactionVersion` variant, + returned by the bundle commitment APIs when an Ironwood bundle's commitment is requested + for a `TxVersion::V5` transaction. +- `orchard::Bundle::bundle_version`, returning the `BundleVersion` the bundle is encoded + under, and `orchard::Bundle::flag_byte`, the infallible byte encoding of the bundle's + flags under that version. - `orchard::bundle::BatchError` (requires the `circuit` feature), with its `RestrictionUnsupportedByKey` variant, returned by `orchard::bundle::BatchValidator::add_bundle` when a restricted bundle is added @@ -81,6 +90,9 @@ when building proving/verifying keys). - PCZT note-version and cross-address APIs: - `orchard::pczt::{Spend, Output}::note_version`, the generated getters for the note plaintext version of a parsed spend or output. + - `orchard::pczt::Bundle::bundle_version`, the generated getter for the bundle's + `BundleVersion`, and `orchard::pczt::Bundle::flag_byte`, the infallible byte encoding of + its flags under that version. - `orchard::pczt::Bundle::verify_cross_address_restriction`, so that Signers can check the cross-address restriction's same-expanded-receiver structural property before signing. It is a no-op for bundles that permit cross-address @@ -92,21 +104,21 @@ when building proving/verifying keys). underlying `orchard::pczt::VerifyError`. ### Changed -- Bundle construction now requires explicit pool restrictions: +- Bundle construction now requires an explicit `BundleVersion`: - `orchard::builder::Builder::new` now takes - `(BundlePoolRestrictions, BundleType, Anchor)`; the builder derives the circuit - version from the pool restrictions rather than from an explicit + `(BundleVersion, BundleType, Anchor)`; the builder derives the circuit + version from the bundle version rather than from an explicit `OrchardCircuitVersion`. - - `orchard::builder::bundle` now takes a `BundlePoolRestrictions` in place of + - `orchard::builder::bundle` now takes a `BundleVersion` in place of the circuit-version argument, and takes the wallet-controlled change outputs as a separate `changes: Vec` argument (plain `outputs` and `changes` are distinct). It rejects supplied `OutputInfo`/`ChangeInfo` values - whose note version does not match the `BundlePoolRestrictions`, returning + whose note version does not match the `BundleVersion`, returning `BuildError::InvalidNoteVersion`. - `orchard::builder::BundleType::Transactional` no longer embeds a full `Flags`; it now carries `{ spends_enabled, outputs_enabled, bundle_required }`. - `orchard::builder::BundleType::{num_actions, flags}` now take a - `BundlePoolRestrictions`. For bundles that disable cross-address transfers, + `BundleVersion`. For bundles that disable cross-address transfers, `num_actions` counts `num_spends + num_outputs` requested actions (a requested spend and a requested output never share an action) rather than the maximum of the two, and `BundleMetadata` maps them to distinct actions; wallets @@ -114,10 +126,10 @@ when building proving/verifying keys). - `orchard::builder::OutputInfo::{new, dummy}` now take an `orchard::NoteVersion`, so callers choose between V2 Orchard notes and V3 Ironwood notes; builder-created outputs use the note version associated with the selected - `BundlePoolRestrictions`. + `BundleVersion`. - `orchard::builder::BundleMetadata::output_action_index` now indexes the plain outputs first, followed by the wallet-controlled change outputs. -- For `BundlePoolRestrictions::OrchardNu6_3Onward`, the builder constructs +- For `BundleVersion::orchard_v2()`, the builder constructs withdrawal/change bundles that disable cross-address transfers: every action's output is addressed to the expanded receiver of the note it spends. The fabricated zero-value output paired with each real spend carries a randomized, @@ -129,52 +141,60 @@ when building proving/verifying keys). recipient not owned by the full viewing key (`OutputError::RecipientNotOwned`) and requires spends to be enabled (`OutputError::SpendsDisabled`). The builder chooses the cross-address bit as a prover-side default — the least-restrictive - value consensus permits: enabled, except under `OrchardNu6_3Onward`, where - consensus mandates the restriction. `BundlePoolRestrictions` exposes only that - consensus constraint; the default lives in builder logic. The `Flags` codec - still represents NU6.3 `enableCrossAddress = 0` flag sets, so a future builder - could expose the choice where consensus leaves it free (e.g. Ironwood); this - branch does not. Coinbase bundles follow the same pool restrictions as - non-coinbase bundles: post-NU6.3 Orchard coinbase transactions cannot contain - Orchard actions, so post-NU6.3 coinbase bundle construction in this crate is - only useful for `IronwoodNu6_3Onward`. + value consensus permits: enabled, except for the Orchard pool under + `BundleVersion::orchard_v2()`, where consensus mandates the restriction. + `BundleVersion` exposes only that consensus constraint; the default lives in builder + logic. The `Flags` codec still represents NU6.3 `enableCrossAddress = 0` flag sets, so a + future builder could expose the choice where consensus leaves it free (e.g. Ironwood); + this branch does not. Coinbase bundles follow the same constraints as non-coinbase + bundles: post-NU6.3 Orchard coinbase transactions cannot contain Orchard actions, so + post-NU6.3 coinbase bundle construction in this crate is only useful for + `BundleVersion::ironwood_v2()`. - `orchard::bundle::Flags::{to_byte, from_byte}` now take a - `BundlePoolRestrictions`. Bit 2 (`enableCrossAddress`) is only representable for + `BundleVersion`. Bit 2 (`enableCrossAddress`) is only representable for the Ironwood pool post-NU6.3; it is rejected for pre-NU6.3 (where bit 2 is reserved) and for Orchard post-NU6.3 (where consensus mandates the cross-address restriction). `to_byte` now returns `Option`, yielding `None` when the flag - set is not representable under the given pool restrictions. A byte with bit 2 + set is not representable under the given bundle version. A byte with bit 2 clear is interpreted differently per epoch: an unrestricted bundle before NU6.3, - a restricted bundle under NU6.3. -- Bundle decryption, recovery, and commitment APIs now require pool or - transaction-version context: + a restricted bundle under NU6.3. A `Bundle` exposes the infallible + `Bundle::flag_byte` for its own flag encoding. +- A bundle's `BundleVersion` is now carried by the bundle itself rather than passed to its + decryption, recovery, and commitment methods: - `orchard::Bundle::{decrypt_outputs_with_keys, decrypt_output_with_key}` and - `orchard::Bundle::{recover_outputs_with_ovks, recover_output_with_ovk}` now - take a `BundlePoolRestrictions` and enforce its note plaintext version after - decryption; selecting `IronwoodNu6_3Onward` lets these helpers discover V3 - Ironwood notes. + `orchard::Bundle::{recover_outputs_with_ovks, recover_output_with_ovk}` no longer take a + version argument; they use the bundle's own `BundleVersion` (so an Ironwood bundle's + helpers discover its V3 notes). - `orchard::Bundle::commitment` and - `orchard::Bundle::::authorizing_commitment` now take a - `BundlePoolRestrictions` (which selects the flag-byte encoding) and a - `TxVersion` (which selects the commitment personalization strings and the - anchor placement). The ZIP-244 digest — and therefore the transaction ID and - sighash — now depends on both: under a NU6.3 protocol an unrestricted bundle's - flag byte sets bit 2, and `TxVersion::V6` uses the v6 personalization strings - and commits the anchor in the authorizing commitment instead of the effects - commitment. Callers computing transaction IDs or sighashes must pass the - restrictions and version matching the transaction; these APIs check only that - the combination is representable, not that it is consensus-valid for the - transaction version. `Bundle::commitment` now returns - `Result` and - `Bundle::::authorizing_commitment` returns - `Result`, with - `Err(CommitmentError::InvalidTransactionVersion)` for an Ironwood bundle in a - v5 transaction and `Err(CommitmentError::UnrepresentableFlags)` (from - `commitment` only) for flags the restrictions cannot represent. - - `orchard::bundle::commitments::{hash_bundle_txid_empty, hash_bundle_auth_empty}` - now take a `BundlePoolRestrictions` and a `TxVersion`, and return - `Result`, rejecting an Ironwood pool in a v5 - transaction with `CommitmentError::InvalidTransactionVersion`. + `orchard::Bundle::::authorizing_commitment` no longer take a + `BundleVersion` (the bundle supplies it); they still take a `TxVersion`, which selects + the commitment personalization strings and the anchor placement. The ZIP-244 digest — + and therefore the transaction ID and sighash — depends on both the bundle's version and + the transaction version: under a NU6.3 protocol an unrestricted bundle's flag byte sets + bit 2, and `TxVersion::V6` uses the v6 personalization strings and commits the anchor in + the authorizing commitment instead of the effects commitment. Callers must construct the + bundle with the version matching the transaction and pass the matching `TxVersion`; + these APIs check only that the combination is representable, not that it is + consensus-valid. Both now return only + `Err(CommitmentError::InvalidTransactionVersion)` (for an Ironwood bundle in a v5 + transaction); because flags are validated when the bundle is constructed, commitment can + no longer fail on unrepresentable flags. + - `orchard::bundle::commitments::{hash_bundle_txid_empty, hash_bundle_auth_empty}`, which + operate on an absent bundle (and so hash no flags), now take a `ValuePool` and a + `TxVersion`, and return `Result`, rejecting an Ironwood pool + in a v5 transaction with `CommitmentError::InvalidTransactionVersion`. +- Bundle construction now takes a `BundleVersion` and validates flags against it: + - `orchard::Bundle::::from_parts` and + `orchard::Bundle::::try_from_parts` now take a `BundleVersion`, and + reject a flag set that cannot be encoded under it with the new + `orchard::bundle::BundleError::UnrepresentableFlags` variant. `from_parts` is now + fallible (returns `Result<_, BundleError>`) for this reason, so a constructed `Bundle` + is always encodable and committable. + - `try_from_parts` no longer takes an `orchard::bundle::ProofSizeEnforcement`: the + canonical proof-size check (GHSA-2x4w-pxqw-58v9) is derived from the bundle version, + enforced for every version except the historical pre-NU6.2 Orchard pool + (`BundleVersion::orchard_insecure_v0`), whose already-committed transactions may carry + non-canonical proofs. - Circuit APIs now require explicit circuit versions: - `orchard::circuit::Circuit::from_action_context` now takes an `OrchardCircuitVersion` instead of implicitly selecting `FixedPostNu6_2`. @@ -210,7 +230,7 @@ when building proving/verifying keys). differ only in which note plaintext versions they accept during parsing and decryption. - PCZT parsing and role checks now carry pool and note-version context: - - `orchard::pczt::Bundle::parse` now takes a `BundlePoolRestrictions` and rejects + - `orchard::pczt::Bundle::parse` now takes a `BundleVersion` and rejects flags and output note versions that do not match it. - `orchard::pczt::{Spend, Output}::parse` now take the `orchard::NoteVersion` for the parsed spend or output. @@ -229,13 +249,13 @@ when building proving/verifying keys). `orchard::bundle::testing::{arb_action, arb_unauthorized_action}`, and `orchard::bundle::testing::{arb_action_n, arb_unauthorized_action_n}` now take an `orchard::NoteVersion`. - - `orchard::bundle::testing::{arb_bundle, arb_unauthorized_bundle}` now generate - a `BundlePoolRestrictions` (via the new - `orchard::bundle::testing::arb_bundle_pool_restriction` strategy) to select the - note version. + - `orchard::bundle::testing::{arb_bundle, arb_unauthorized_bundle}` now construct the + bundle with a generated `BundleVersion` (via the new + `orchard::bundle::testing::arb_bundle_version` strategy), choosing flags + consistent with that version. - `orchard::bundle::testing::arb_flags` is unchanged and only generates flag sets - with cross-address transfers enabled, representable under every pool - restriction other than Orchard post-NU6.3; use the new + with cross-address transfers enabled, representable under every bundle + version other than Orchard post-NU6.3; use the new `arb_flags_ironwood_post_nu6_3` for Ironwood post-NU6.3 flag sets that may disable cross-address transfers. - `unstable-voting-circuits`-only (not covered by the crate's semver guarantees): @@ -245,10 +265,13 @@ when building proving/verifying keys). (ZIP 2005, Ironwood) notes. ### Removed +- `orchard::bundle::ProofSizeEnforcement`; `Bundle::try_from_parts` now derives the + canonical proof-size check from the `BundleVersion` (enforced for every version except + `BundleVersion::orchard_insecure_v0`). - `orchard::builder::Builder::new_for_version`; use - `Builder::new(pool_restrictions, bundle_type, anchor)`. + `Builder::new(bundle_version, bundle_type, anchor)`. - `orchard::builder::bundle_for_version`; use `builder::bundle` with - `BundlePoolRestrictions` and a `Vec`. + `BundleVersion` and a `Vec`. - Zero-argument `orchard::circuit::{ProvingKey, VerifyingKey}::build`; pass an `OrchardCircuitVersion` explicitly. - `orchard::circuit::{ProvingKey, VerifyingKey}::build_for_version`; use @@ -472,7 +495,7 @@ when building proving/verifying keys). ### Changed - MSRV is now 1.70 -- Migrated to `nonempty 0.11`, `incrementalmerkletree 0.8`, `shardtree 0.6`, +- Migrated to `nonempty 0.11`, `incrementalmerkletree 0.8`, `shardtree 0.6`, `zcash_spec 0.2`, `zip32 0.2` - `orchard::builder::Builder::add_output` now takes a `[u8; 512]` for its `memo` argument instead of an optional value. diff --git a/benches/circuit.rs b/benches/circuit.rs index a32d9d936..3772c88eb 100644 --- a/benches/circuit.rs +++ b/benches/circuit.rs @@ -8,7 +8,7 @@ use pprof::criterion::{Output, PProfProfiler}; use orchard::{ builder::{Builder, BundleType}, - bundle::BundlePoolRestrictions, + bundle::BundleVersion, circuit::{OrchardCircuitVersion, ProvingKey, VerifyingKey}, keys::{FullViewingKey, Scope, SpendingKey}, value::NoteValue, @@ -27,7 +27,7 @@ fn criterion_benchmark(c: &mut Criterion) { let create_bundle = |num_recipients| { let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_2Only, + BundleVersion::orchard_v1(), BundleType::DEFAULT, Anchor::from_bytes([0; 32]).unwrap(), ); diff --git a/benches/note_decryption.rs b/benches/note_decryption.rs index 49610ced7..0ac59ccd6 100644 --- a/benches/note_decryption.rs +++ b/benches/note_decryption.rs @@ -1,7 +1,7 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; use orchard::{ builder::{Builder, BundleType}, - bundle::BundlePoolRestrictions, + bundle::BundleVersion, circuit::{OrchardCircuitVersion, ProvingKey}, keys::{FullViewingKey, PreparedIncomingViewingKey, Scope, SpendingKey}, note_encryption::{CompactAction, OrchardDomain}, @@ -46,7 +46,7 @@ fn bench_note_decryption(c: &mut Criterion) { let bundle = { let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_2Only, + BundleVersion::orchard_v1(), BundleType::DEFAULT, Anchor::from_bytes([0; 32]).unwrap(), ); diff --git a/src/builder.rs b/src/builder.rs index 9bf4ae10d..64da4de3a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -12,7 +12,7 @@ use zcash_note_encryption::ENC_CIPHERTEXT_SIZE; use crate::{ address::Address, - bundle::{Authorization, Authorized, Bundle, BundlePoolRestrictions, Flags}, + bundle::{Authorization, Authorized, Bundle, BundleVersion, Flags}, keys::{ FullViewingKey, OutgoingViewingKey, Scope, SpendAuthorizingKey, SpendValidatingKey, SpendingKey, @@ -52,9 +52,9 @@ pub enum BundleType { bundle_required: bool, }, /// A coinbase bundle disables nonzero-valued Orchard spends, and is built with - /// `Flags::from_parts(false, true, default_cross_address_enabled(pool_restrictions))`: + /// `Flags::from_parts(false, true, bundle_version.permits_cross_address_transfers())`: /// spends disabled, outputs enabled, and cross-address transfers enabled if - /// possible for this bundle's pool restrictions. No padding is performed. + /// possible for this bundle's version. No padding is performed. /// /// Since coinbase transactions have `enableSpends = 0`, every spend must be a /// dummy. Coinbase transactions are not otherwise any different wrt cross-address @@ -102,7 +102,7 @@ impl BundleType { &self, num_spends: usize, num_outputs: usize, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, ) -> Result { match self { BundleType::Transactional { @@ -110,7 +110,7 @@ impl BundleType { outputs_enabled, bundle_required, } => { - let cross_address_enabled = default_cross_address_enabled(pool_restrictions); + let cross_address_enabled = bundle_version.permits_cross_address_transfers(); // When cross-address transfers are disabled, every action's output is // addressed to the note it spends. For this implementation, a requested // spend and a requested output never share an action: each is paired with @@ -146,8 +146,13 @@ impl BundleType { } /// Returns the set of flags that will be used for bundle construction under - /// `pool_restrictions`. - pub fn flags(&self, pool_restrictions: BundlePoolRestrictions) -> Flags { + /// `bundle_version`. + /// + /// As a prover-side default, the builder enables cross-address transfers whenever the version + /// permits them (the least restrictive policy consensus allows). This is the bundle's + /// `cross_address_enabled` flag, not bit 2 of the wire `flags` byte: in v5 transactions bit 2 + /// is reserved and always 0 regardless of epoch. + pub fn flags(&self, bundle_version: BundleVersion) -> Flags { match self { BundleType::Transactional { spends_enabled, @@ -156,32 +161,17 @@ impl BundleType { } => Flags::from_parts( *spends_enabled, *outputs_enabled, - default_cross_address_enabled(pool_restrictions), + bundle_version.permits_cross_address_transfers(), ), BundleType::Coinbase => Flags::from_parts( false, true, - default_cross_address_enabled(pool_restrictions), + bundle_version.permits_cross_address_transfers(), ), } } } -/// Decide, for a bundle targeting the consensus rules implied by `pool_restrictions`, -/// whether to enable cross-address transfers according to the least restrictive policy -/// mandated by those rules (see [`BundlePoolRestrictions::requires_cross_address_restriction`]). -/// -/// It is builder policy to enable cross-address transfers whenever `pool_restrictions` -/// implies it is possible; a future builder could expose the choice in that case. -/// -/// Note: whether cross-address transfers are enabled should not be confused -/// with the value of bit 2 of the bundle's `flags` field. In v5 transactions, bit 2 of -/// `flagsOrchard` is still reserved and is always 0; it is not the `enableCrossAddress` -/// flag in that case, regardless of epoch. -fn default_cross_address_enabled(pool_restrictions: BundlePoolRestrictions) -> bool { - !pool_restrictions.requires_cross_address_restriction() -} - /// An error type for the kinds of errors that can occur during bundle construction. #[derive(Debug)] #[non_exhaustive] @@ -211,7 +201,7 @@ pub enum BuildError { /// output is not a wallet-controlled change output. CrossAddressDisabled, /// A supplied output or change output has a note version that is - /// inconsistent with the bundle pool restrictions. + /// inconsistent with the bundle version. InvalidNoteVersion, } @@ -247,7 +237,7 @@ impl fmt::Display for BuildError { ), InvalidNoteVersion => f.write_str( "A supplied output or change output has a note version that does not match \ - the bundle pool restrictions.", + the bundle version.", ), } } @@ -775,43 +765,39 @@ pub struct Builder { spends: Vec, outputs: Vec, changes: Vec, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, bundle_type: BundleType, anchor: Anchor, } impl Builder { - /// Constructs a new empty builder for an Orchard bundle following `pool_restrictions`. + /// Constructs a new empty builder for an Orchard bundle following `bundle_version`. /// - /// `pool_restrictions` is the information needed to determine restrictions on bundles + /// `bundle_version` is the information needed to determine restrictions on bundles /// created by this builder: its shielded pool (Orchard or Ironwood) and the epoch range /// (pre-NU6.2, NU6.2 only, NU6.3 onward) that it targets. It influences the circuit version, /// the flag-byte format, and the cross-address policy, and is threaded into building, - /// committing, and parsing. See [`BundlePoolRestrictions`]. - pub fn new( - pool_restrictions: BundlePoolRestrictions, - bundle_type: BundleType, - anchor: Anchor, - ) -> Self { + /// committing, and parsing. See [`BundleVersion`]. + pub fn new(bundle_version: BundleVersion, bundle_type: BundleType, anchor: Anchor) -> Self { Builder { spends: vec![], outputs: vec![], changes: vec![], - pool_restrictions, + bundle_version, bundle_type, anchor, } } - /// Returns the note version associated with this builder's pool restrictions. + /// Returns the note version associated with this builder's bundle version. fn note_version(&self) -> NoteVersion { - self.pool_restrictions.note_version() + self.bundle_version.note_version() } /// Adds a note to be spent in this transaction. /// /// - `note` is a spendable note, obtained by trial-decrypting an [`Action`] - /// under the bundle's pool restrictions. + /// under the bundle's version. /// - `merkle_path` can be obtained using the [`incrementalmerkletree`] crate /// instantiated with [`MerkleHashOrchard`]. /// @@ -832,7 +818,7 @@ impl Builder { note: Note, merkle_path: MerklePath, ) -> Result<(), SpendError> { - let flags = self.bundle_type.flags(self.pool_restrictions); + let flags = self.bundle_type.flags(self.bundle_version); if !flags.spends_enabled() { return Err(SpendError::SpendsDisabled); } @@ -861,7 +847,7 @@ impl Builder { value: NoteValue, memo: [u8; 512], ) -> Result<(), OutputError> { - let flags = self.bundle_type.flags(self.pool_restrictions); + let flags = self.bundle_type.flags(self.bundle_version); if !flags.outputs_enabled() { return Err(OutputError::OutputsDisabled); } @@ -911,7 +897,7 @@ impl Builder { value: NoteValue, memo: [u8; 512], ) -> Result<(), OutputError> { - let flags = self.bundle_type.flags(self.pool_restrictions); + let flags = self.bundle_type.flags(self.bundle_version); if !flags.outputs_enabled() { return Err(OutputError::OutputsDisabled); } @@ -979,12 +965,12 @@ impl Builder { } /// Builds a bundle containing the given spent notes and outputs, under this builder's - /// [`BundlePoolRestrictions`]. + /// [`BundleVersion`]. /// /// The returned bundle will have no proof or signatures; these can be applied with /// [`Bundle::create_proof`] and [`Bundle::apply_signatures`] respectively. The proof must be /// created with a [`ProvingKey`] for the circuit version consistent with the builder's - /// pool restrictions. + /// bundle version. #[cfg(feature = "circuit")] pub fn build>( self, @@ -992,7 +978,7 @@ impl Builder { ) -> Result, BundleMetadata)>, BuildError> { bundle( rng, - self.pool_restrictions, + self.bundle_version, self.anchor, self.bundle_type, self.spends, @@ -1009,7 +995,7 @@ impl Builder { ) -> Result<(crate::pczt::Bundle, BundleMetadata), BuildError> { build_bundle( rng, - self.pool_restrictions, + self.bundle_version, self.anchor, self.bundle_type, self.spends, @@ -1026,6 +1012,7 @@ impl Builder { crate::pczt::Bundle { actions, flags, + bundle_version: self.bundle_version, value_sum, anchor: self.anchor, zkproof: None, @@ -1039,7 +1026,7 @@ impl Builder { } /// Builds a bundle containing the given spent notes, outputs, and wallet-controlled change -/// outputs, under the given [`BundlePoolRestrictions`] (which selects the Action circuit +/// outputs, under the given [`BundleVersion`] (which selects the Action circuit /// version, the flag-byte format, and the cross-address policy). /// /// In a bundle that disables cross-address transfers, `outputs` must be empty (every output @@ -1047,17 +1034,16 @@ impl Builder { #[cfg(feature = "circuit")] pub fn bundle>( rng: impl RngCore, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, anchor: Anchor, bundle_type: BundleType, spends: Vec, outputs: Vec, changes: Vec, ) -> Result, BundleMetadata)>, BuildError> { - let circuit_version = pool_restrictions.circuit_version(); build_bundle( rng, - pool_restrictions, + bundle_version, anchor, bundle_type, spends, @@ -1071,7 +1057,7 @@ pub fn bundle>( bundle_meta, rng, anchor, - circuit_version, + bundle_version, ) }, ) @@ -1085,8 +1071,9 @@ fn finish_unauthorized_bundle, R: RngCore>( bundle_meta: BundleMetadata, mut rng: R, anchor: Anchor, - circuit_version: OrchardCircuitVersion, + bundle_version: BundleVersion, ) -> Result, BundleMetadata)>, BuildError> { + let circuit_version = bundle_version.circuit_version(); let result_value_balance: V = i64::try_from(value_balance) .map_err(BuildError::ValueSum) .and_then(|i| { @@ -1126,6 +1113,7 @@ fn finish_unauthorized_bundle, R: RngCore>( }, sigs: Unauthorized { bsk }, }, + bundle_version, ), bundle_meta, ) @@ -1135,7 +1123,7 @@ fn finish_unauthorized_bundle, R: RngCore>( #[allow(clippy::too_many_arguments)] fn build_bundle( mut rng: R, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, anchor: Anchor, bundle_type: BundleType, spends: Vec, @@ -1143,8 +1131,8 @@ fn build_bundle( changes: Vec, finisher: impl FnOnce(Vec, Flags, ValueSum, BundleMetadata, R) -> Result, ) -> Result { - let flags = bundle_type.flags(pool_restrictions); - let note_version = pool_restrictions.note_version(); + let flags = bundle_type.flags(bundle_version); + let note_version = bundle_version.note_version(); let num_requested_spends = spends.len(); if !flags.spends_enabled() && num_requested_spends > 0 { @@ -1185,11 +1173,7 @@ fn build_bundle( } let num_actions = bundle_type - .num_actions( - num_requested_spends, - num_requested_outputs, - pool_restrictions, - ) + .num_actions(num_requested_spends, num_requested_outputs, bundle_version) .map_err(|_| BuildError::BundleTypeNotSatisfiable)?; let (pre_actions, bundle_meta) = if !flags.cross_address_enabled() { @@ -1686,7 +1670,7 @@ pub mod testing { use crate::{ address::testing::arb_address, - bundle::{Authorized, Bundle, BundlePoolRestrictions}, + bundle::{Authorized, Bundle, BundleVersion}, circuit::{OrchardCircuitVersion, ProvingKey}, keys::{testing::arb_spending_key, FullViewingKey, SpendAuthorizingKey, SpendingKey}, note::testing::arb_note, @@ -1718,8 +1702,8 @@ pub mod testing { /// Create a bundle from the set of arbitrary bundle inputs. fn into_bundle>(mut self) -> Bundle { let fvk = FullViewingKey::from(&self.sk); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_2Only; - let mut builder = Builder::new(pool_restrictions, BundleType::DEFAULT, self.anchor); + let bundle_version = BundleVersion::orchard_v1(); + let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, self.anchor); for (note, path) in self.notes.into_iter() { builder.add_spend(fvk.clone(), note, path).unwrap(); @@ -1824,7 +1808,7 @@ mod tests { }; use crate::{ builder::BundleType, - bundle::{Authorized, Bundle, BundlePoolRestrictions}, + bundle::{Authorized, Bundle, BundleVersion}, circuit::{OrchardCircuitVersion, ProvingKey}, constants::MERKLE_DEPTH_ORCHARD, keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, @@ -1870,29 +1854,29 @@ mod tests { // for every variant but that one. // Orchard pre-NU6.3: free, so the default is enabled. - for pool_restrictions in [ - BundlePoolRestrictions::OrchardPreNu6_2, - BundlePoolRestrictions::OrchardNu6_2Only, + for bundle_version in [ + BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_v1(), ] { - assert!(bundle_type.flags(pool_restrictions).cross_address_enabled()); + assert!(bundle_type.flags(bundle_version).cross_address_enabled()); } // Orchard post-NU6.3: consensus mandates the restriction, so the default is disabled. assert!(!bundle_type - .flags(BundlePoolRestrictions::OrchardNu6_3Onward) + .flags(BundleVersion::orchard_v2()) .cross_address_enabled()); // Ironwood post-NU6.3: free, so the default is enabled (currently built unrestricted). assert!(bundle_type - .flags(BundlePoolRestrictions::IronwoodNu6_3Onward) + .flags(BundleVersion::ironwood_v2()) .cross_address_enabled()); } - /// Creates a builder with the given `pool_restrictions` and `bundle_type` over the + /// Creates a builder with the given `bundle_version` and `bundle_type` over the /// empty-tree anchor, with a single 5000-zat output to a freshly derived external address. fn output_only_builder( rng: &mut impl RngCore, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, bundle_type: BundleType, ) -> Builder { let sk = SpendingKey::random(rng); @@ -1900,7 +1884,7 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - pool_restrictions, + bundle_version, bundle_type, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -1915,11 +1899,8 @@ mod tests { let pk = ProvingKey::build(OrchardCircuitVersion::FixedPostNu6_2); let mut rng = OsRng; - let builder = output_only_builder( - &mut rng, - BundlePoolRestrictions::OrchardNu6_2Only, - BundleType::DEFAULT, - ); + let builder = + output_only_builder(&mut rng, BundleVersion::orchard_v1(), BundleType::DEFAULT); let balance: i64 = builder.value_balance().unwrap(); assert_eq!(balance, -5000); @@ -1945,11 +1926,8 @@ mod tests { // has no Orchard bundle). So a post-NU6.3 coinbase bundle built by this crate must // be an Ironwood bundle. There the builder leaves cross-address enabled by default, // and therefore ordinary outputs build normally. - let builder = output_only_builder( - &mut rng, - BundlePoolRestrictions::IronwoodNu6_3Onward, - BundleType::Coinbase, - ); + let builder = + output_only_builder(&mut rng, BundleVersion::ironwood_v2(), BundleType::Coinbase); let (bundle, _) = builder .build::(&mut rng) @@ -1968,31 +1946,31 @@ mod tests { // cross-address restriction: they set `enableCrossAddress` exactly as a non-coinbase // transaction would for the same pool restrictions. for pr in [ - BundlePoolRestrictions::OrchardPreNu6_2, - BundlePoolRestrictions::OrchardNu6_2Only, - BundlePoolRestrictions::OrchardNu6_3Onward, - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_v1(), + BundleVersion::orchard_v2(), + BundleVersion::ironwood_v2(), ] { let flags = BundleType::Coinbase.flags(pr); assert!(!flags.spends_enabled()); assert!(flags.outputs_enabled()); assert_eq!( flags.cross_address_enabled(), - !pr.requires_cross_address_restriction() + pr.permits_cross_address_transfers() ); } // Orchard post-NU6.3 mandates the restriction, so the coinbase flag byte has bit 2 // clear; Ironwood leaves the builder default (set). assert_eq!( BundleType::Coinbase - .flags(BundlePoolRestrictions::OrchardNu6_3Onward) - .to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), + .flags(BundleVersion::orchard_v2()) + .to_byte(BundleVersion::orchard_v2()), Some(0b010) ); assert_eq!( BundleType::Coinbase - .flags(BundlePoolRestrictions::IronwoodNu6_3Onward) - .to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward), + .flags(BundleVersion::ironwood_v2()) + .to_byte(BundleVersion::ironwood_v2()), Some(0b110) ); } @@ -2006,15 +1984,15 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; + let bundle_version = BundleVersion::orchard_v2(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, NoteValue::from_raw(15_000), - pool_restrictions.note_version(), + bundle_version.note_version(), ); - let mut builder = Builder::new(pool_restrictions, restricted_bundle_type(false), anchor); + let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); assert_eq!( builder.add_output( None, @@ -2107,7 +2085,7 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), restricted_bundle_type(true), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -2144,12 +2122,12 @@ mod tests { let sk = SpendingKey::random(&mut rng); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; + let bundle_version = BundleVersion::orchard_v2(); assert!(matches!( bundle::( &mut rng, - pool_restrictions, + bundle_version, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), restricted_bundle_type(false), vec![], @@ -2157,7 +2135,7 @@ mod tests { None, recipient, NoteValue::from_raw(5_000), - pool_restrictions.note_version(), + bundle_version.note_version(), [0u8; 512], )], vec![], @@ -2170,13 +2148,13 @@ mod tests { None, recipient, NoteValue::from_raw(5_000), - pool_restrictions.note_version(), + bundle_version.note_version(), [0u8; 512], ) .unwrap(); let (bundle, bundle_meta) = bundle::( &mut rng, - pool_restrictions, + bundle_version, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), restricted_bundle_type(false), vec![], @@ -2201,9 +2179,9 @@ mod tests { outputs_enabled: true, bundle_required: false, }; - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; + let bundle_version = BundleVersion::orchard_v2(); // Under OrchardNu6_3Onward this is spends-disabled and cross-address-disabled. - let flags = bundle_type.flags(pool_restrictions); + let flags = bundle_type.flags(bundle_version); assert!(!flags.spends_enabled()); assert!(flags.outputs_enabled()); assert!(!flags.cross_address_enabled()); @@ -2213,7 +2191,7 @@ mod tests { None, recipient, NoteValue::from_raw(5_000), - pool_restrictions.note_version(), + bundle_version.note_version(), [0u8; 512], ) .unwrap(); @@ -2221,7 +2199,7 @@ mod tests { assert!(matches!( bundle::( &mut rng, - pool_restrictions, + bundle_version, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), bundle_type, vec![], @@ -2238,7 +2216,7 @@ mod tests { let sk = SpendingKey::random(&mut rng); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let pool_restrictions = BundlePoolRestrictions::IronwoodNu6_3Onward; + let bundle_version = BundleVersion::ironwood_v2(); let mismatched_note_version = NoteVersion::V2; let (note, merkle_path, anchor) = note_with_path( @@ -2247,7 +2225,7 @@ mod tests { NoteValue::from_raw(15_000), mismatched_note_version, ); - let mut builder = Builder::new(pool_restrictions, BundleType::DEFAULT, anchor); + let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, anchor); assert_eq!(builder.add_spend(fvk.clone(), note, merkle_path), Ok(())); let (note, merkle_path, anchor) = note_with_path( @@ -2259,7 +2237,7 @@ mod tests { let spend = SpendInfo::new(fvk.clone(), note, merkle_path).unwrap(); assert!(bundle::( &mut rng, - pool_restrictions, + bundle_version, anchor, BundleType::DEFAULT, vec![spend], @@ -2278,7 +2256,7 @@ mod tests { assert!(matches!( bundle::( &mut rng, - pool_restrictions, + bundle_version, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), BundleType::DEFAULT, vec![], @@ -2300,7 +2278,7 @@ mod tests { assert!(matches!( bundle::( &mut rng, - pool_restrictions, + bundle_version, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), BundleType::DEFAULT, vec![], @@ -2318,9 +2296,9 @@ mod tests { let owned = fvk.address_at(0u32, Scope::Internal); let foreign = FullViewingKey::from(&SpendingKey::random(&mut rng)).address_at(0u32, Scope::External); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_2Only; + let bundle_version = BundleVersion::orchard_v1(); let mut builder = Builder::new( - pool_restrictions, + bundle_version, BundleType::Transactional { spends_enabled: true, outputs_enabled: true, @@ -2364,7 +2342,7 @@ mod tests { // happen by voluntarily disabling `enableSpends` and/or `enableCrossAddress` when // consensus does not require it. let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), BundleType::Transactional { spends_enabled: false, outputs_enabled: true, @@ -2388,15 +2366,15 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; + let bundle_version = BundleVersion::orchard_v2(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, NoteValue::from_raw(15_000), - pool_restrictions.note_version(), + bundle_version.note_version(), ); - let mut builder = Builder::new(pool_restrictions, restricted_bundle_type(false), anchor); + let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); builder .add_change_output( @@ -2433,7 +2411,7 @@ mod tests { // A change-only bundle: the padding dummy spend is signed during `prepare`, so // a single `sign` call with the change key completes the actions. let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), restricted_bundle_type(false), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -2470,15 +2448,15 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; + let bundle_version = BundleVersion::orchard_v2(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, NoteValue::from_raw(15_000), - pool_restrictions.note_version(), + bundle_version.note_version(), ); - let mut builder = Builder::new(pool_restrictions, restricted_bundle_type(false), anchor); + let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); builder .add_change_output( @@ -2516,15 +2494,15 @@ mod tests { #[test] fn create_proof_supports_cross_address_disabled_only_for_post_nu6_3() { - // A cross-address-disabled bundle can only be built under `OrchardNu6_3Onward` - // (`BundlePoolRestrictions` owns the cross-address policy), which builds post-NU6.3 + // A cross-address-disabled bundle can only be built under `BundleVersion::orchard_v2()` + // (`BundleVersion` owns the cross-address policy), which builds post-NU6.3 // circuits. Proving therefore requires a matching post-NU6.3 key; a pre-NU6.3 key // is rejected as a circuit-version mismatch. The lower-level interlock that rejects // a restricted *instance* under an unsupporting key is covered by // `circuit::tests::restricted_statement_requires_supporting_key`. let build_restricted = |rng: &mut OsRng| { Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), restricted_bundle_type(true), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) diff --git a/src/bundle.rs b/src/bundle.rs index 93479ee82..29d9a5ba9 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -28,7 +28,7 @@ use crate::{ primitives::redpallas::{self, Binding, SpendAuth}, tree::Anchor, value::{ValueCommitTrapdoor, ValueCommitment, ValueSum}, - Proof, + Proof, ProtocolVersion, ValuePool, }; #[cfg(feature = "circuit")] @@ -51,93 +51,127 @@ impl Action { } } -/// The information needed to determine restrictions on the bundle: its shielded pool -/// (Orchard or Ironwood) and the epoch range (pre-NU6.2, NU6.2 only, NU6.3 onward) -/// that it targets. The Ironwood pool is only allowed for NU6.3 onward. +/// A shielded [`ValuePool`] (Orchard or Ironwood) together with the [`ProtocolVersion`] +/// under which a bundle in that pool is built. The Ironwood pool only exists from NU6.3 +/// onward, so it is only valid in combination with [`ProtocolVersion::V2`]. /// -/// This pins the `circuit_version` and the flag-byte format, and determines whether the -/// cross-address consensus restriction is enforced (`requires_cross_address_restriction`). +/// This pins the `circuit_version` and the flag-byte format, and determines whether +/// cross-address transfers are permitted (`permits_cross_address_transfers`). /// The `flagsOrchard` / `flagsIronwood` value emitted by the [`Builder`](crate::builder::Builder) /// depends on that constraint. The integration layer uses the pool and consensus branch ID -/// to select the `BundlePoolRestrictions` value, and threads it through bundle construction +/// to select the `BundleVersion` value, and threads it through bundle construction /// and wire encoding. /// /// This crate has no concept of consensus branches or activation heights, so it can't -/// derive the `BundlePoolRestrictions` itself. Note that bit 2 of the flags is *reserved* +/// derive the `BundleVersion` itself. Note that bit 2 of the flags is *reserved* /// (required to be clear) for v5, and encodes `enableCrossAddress` only for v6. The -/// correct choice of `BundlePoolRestrictions` is needed to get that right, as well as +/// correct choice of `BundleVersion` is needed to get that right, as well as /// affecting how requested spends and outputs map to actions. Using the wrong value /// may result in constructing a consensus-invalid transaction. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] -pub enum BundlePoolRestrictions { - /// The Orchard pool before NU6.2. - /// - /// Uses the insecure historical Orchard circuit and the pre-NU6.3 flag-byte format. - /// Cross-address transfers are permitted and notes use the V2 plaintext format. Used to - /// reconstruct the historical verifying key and to parse/verify historical bundles, not to - /// build new ones. - OrchardPreNu6_2, - /// The Orchard pool from NU6.2 until NU6.3. - /// - /// Uses the post-NU6.2 fixed Orchard circuit and the pre-NU6.3 flag-byte format. - /// Cross-address transfers are permitted and notes use the V2 plaintext format. - OrchardNu6_2Only, - /// The Orchard pool at NU6.3 and later. - /// - /// Uses the post-NU6.3 circuit and the NU6.3 flag-byte format. For transactional bundles - /// `enableCrossAddress = 0` is required by consensus, so cross-address transfers are - /// prohibited; Orchard actions are disallowed in coinbase. Notes use V2 plaintexts. - OrchardNu6_3Onward, - /// The Ironwood pool at NU6.3 and later. - /// - /// Uses the post-NU6.3 circuit (shared with [`Self::OrchardNu6_3Onward`]) and the v6 - /// transaction format, including its flag-byte encoding. Consensus permits either - /// `enableCrossAddress` value here, so this crate's builder currently constructs Ironwood - /// bundles with `enableCrossAddress = 1`. The v6 flags are able to represent - /// `enableCrossAddress = 0` if a future builder policy chooses to expose it. - IronwoodNu6_3Onward, +pub struct BundleVersion { + value_pool: ValuePool, + protocol_version: ProtocolVersion, } -impl BundlePoolRestrictions { +impl BundleVersion { + /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under + /// [`ProtocolVersion::InsecureV0`] (the Orchard pool prior to NU6.2). + pub const fn orchard_insecure_v0() -> Self { + Self { + value_pool: ValuePool::Orchard, + protocol_version: ProtocolVersion::InsecureV0, + } + } + + /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under + /// [`ProtocolVersion::V1`] (the Orchard pool from NU6.2 until NU6.3). + pub const fn orchard_v1() -> Self { + Self { + value_pool: ValuePool::Orchard, + protocol_version: ProtocolVersion::V1, + } + } + + /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under + /// [`ProtocolVersion::V2`] (the Orchard pool at NU6.3 and later). + pub const fn orchard_v2() -> Self { + Self { + value_pool: ValuePool::Orchard, + protocol_version: ProtocolVersion::V2, + } + } + + /// The [`BundleVersion`] for the [`ValuePool::Ironwood`] pool under + /// [`ProtocolVersion::V2`] (the Ironwood pool, introduced at NU6.3). + pub const fn ironwood_v2() -> Self { + Self { + value_pool: ValuePool::Ironwood, + protocol_version: ProtocolVersion::V2, + } + } + + /// Returns the [`ValuePool`] to which this bundle version applies. + pub fn value_pool(&self) -> ValuePool { + self.value_pool + } + + /// Returns the [`ProtocolVersion`] under which this bundle is built. + pub fn protocol_version(&self) -> ProtocolVersion { + self.protocol_version + } + /// The circuit version whose proving and verifying keys prove and verify actions consistent - /// with these pool restrictions. + /// with this bundle version. /// - /// This is many-to-one: `OrchardNu6_3Onward` and `IronwoodNu6_3Onward` share the post-NU6.3 - /// circuit, so build a key with `ProvingKey::build(pool_restrictions.circuit_version())` / - /// `VerifyingKey::build(pool_restrictions.circuit_version())`. + /// This is many-to-one: both the [`ValuePool::Orchard`] and [`ValuePool::Ironwood`] pools + /// under [`ProtocolVersion::V2`] share the post-NU6.3 circuit, so build a key with + /// `ProvingKey::build(bundle_version.circuit_version())` / + /// `VerifyingKey::build(bundle_version.circuit_version())`. #[cfg(feature = "circuit")] - pub fn circuit_version(self) -> OrchardCircuitVersion { - match self { - BundlePoolRestrictions::OrchardPreNu6_2 => OrchardCircuitVersion::InsecurePreNu6_2, - BundlePoolRestrictions::OrchardNu6_2Only => OrchardCircuitVersion::FixedPostNu6_2, - BundlePoolRestrictions::OrchardNu6_3Onward - | BundlePoolRestrictions::IronwoodNu6_3Onward => OrchardCircuitVersion::PostNu6_3, + pub fn circuit_version(&self) -> OrchardCircuitVersion { + match self.protocol_version { + ProtocolVersion::InsecureV0 => OrchardCircuitVersion::InsecurePreNu6_2, + ProtocolVersion::V1 => OrchardCircuitVersion::FixedPostNu6_2, + ProtocolVersion::V2 => OrchardCircuitVersion::PostNu6_3, } } - /// The [`NoteVersion`] associated with this bundle pool restriction. + /// The [`NoteVersion`] associated with this bundle version. /// /// Orchard pools use V2 note plaintexts, and Ironwood pools use V3 note /// plaintexts. - pub fn note_version(self) -> NoteVersion { - use BundlePoolRestrictions::*; - match self { - OrchardPreNu6_2 | OrchardNu6_2Only | OrchardNu6_3Onward => NoteVersion::V2, - BundlePoolRestrictions::IronwoodNu6_3Onward => NoteVersion::V3, + pub fn note_version(&self) -> NoteVersion { + match self.value_pool { + ValuePool::Orchard => NoteVersion::V2, + ValuePool::Ironwood => NoteVersion::V3, } } - /// Whether the consensus rules targetted by these pool restrictions *require* - /// bundles to disable cross-address transfers (by setting `enableCrossAddress = 0` - /// in the case of a v6 transaction). + /// Whether the consensus rules for this version *permit* cross-address transfers within an + /// action (`enableCrossAddress = 1` in a v6 transaction). + /// + /// Every version permits them except the [`ValuePool::Orchard`] pool under + /// [`ProtocolVersion::V2`], which mandates the cross-address restriction. This is not + /// necessarily the same cross-address-enabled decision the + /// [`Builder`](crate::builder::Builder) makes; that is builder policy chosen within this + /// constraint. + pub(crate) fn permits_cross_address_transfers(&self) -> bool { + !matches!( + (self.protocol_version, self.value_pool), + (ProtocolVersion::V2, ValuePool::Orchard) + ) + } + + /// Whether an authorized bundle of this version must carry a canonically-sized proof. /// - /// `OrchardNu6_3Onward` mandates this restriction; every other value of - /// `BundlePoolRestrictions` leaves it free. This is not necessarily the same - /// cross-address-enabled decision the [`Builder`](crate::builder::Builder) makes; - /// that is determined by builder policy chosen within this constraint. - pub(crate) fn requires_cross_address_restriction(self) -> bool { - matches!(self, BundlePoolRestrictions::OrchardNu6_3Onward) + /// The historical pre-NU6.2 Orchard pool ([`ProtocolVersion::InsecureV0`]) is only used to + /// parse already-committed transactions, whose proofs cannot be re-canonicalized, so its + /// proof size is not enforced. Every later version requires a canonical proof, rejecting + /// non-canonical (e.g. padded) proofs (GHSA-2x4w-pxqw-58v9). + pub(crate) fn enforces_canonical_proof_size(&self) -> bool { + !matches!(self.protocol_version, ProtocolVersion::InsecureV0) } } @@ -149,8 +183,8 @@ impl BundlePoolRestrictions { /// includes it in the authorizing digest. Ironwood bundles exist only in v6 transactions, so /// attempting to compute an Ironwood commitment for a v5 transaction returns an error. /// -/// This is independent of the [`BundlePoolRestrictions`] that govern construction: the same -/// Orchard bundle can be committed under either version, and the caller must pass the one +/// This is independent of the [`BundleVersion`] that governs construction: the same +/// Orchard bundle can be committed under either transaction version, and the caller must pass the one /// matching the transaction the bundle is encoded in. See [`Bundle::commitment`] and /// [`Bundle::authorizing_commitment`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -162,7 +196,13 @@ pub enum TxVersion { V6, } -/// Orchard-specific flags. +/// Flags denoting what operations may be performed by the Orchard actions +/// in a bundle. +/// +/// `Flags` are version-agnostic: a given flag set is not inherently valid for any +/// particular [`BundleVersion`]. Whether it can be used with a version is checked +/// separately, when it is encoded for that version (see [`Flags::to_byte`]) or supplied +/// to the [`Builder`](crate::builder::Builder). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Flags { /// Flag denoting whether Orchard spends are enabled in the transaction. @@ -196,7 +236,7 @@ impl Flags { /// Construct a set of flags from its constituent parts, including the cross-address bit. /// /// Crate-internal: the builder supplies `cross_address_enabled` from its prover-side default - /// for the pool restrictions (see [`Builder`](crate::builder::Builder)). + /// for the bundle version (see [`Builder`](crate::builder::Builder)). pub(crate) const fn from_parts( spends_enabled: bool, outputs_enabled: bool, @@ -283,20 +323,10 @@ impl Flags { } /// Serialize flags to a byte as defined in [Zcash Protocol Spec § 7.1: Transaction - /// Encoding And Consensus][txencoding], under the given bundle protocol. - /// - /// Returns `None` if this flag set cannot be encoded under `pool_restrictions`. This - /// happens in two cases: - /// - /// * cross-address transfers are disabled but `pool_restrictions` specifies a pre-NU6.3 - /// Orchard pool (where cross-address transfers are implicitly enabled); - /// * cross-address transfers are enabled but `pool_restrictions` specifies a post-NU6.3 - /// Orchard pool (where cross-address transfers are forbidden). - /// - /// See [`BundlePoolRestrictions`] for how to choose `pool_restrictions`. + /// Encoding And Consensus][txencoding]. /// /// [txencoding]: https://zips.z.cash/protocol/protocol.pdf#txnencoding - pub fn to_byte(&self, pool_restrictions: BundlePoolRestrictions) -> Option { + pub fn to_byte(&self, bundle_version: BundleVersion) -> Option { let mut value = 0u8; if self.spends_enabled { value |= FLAG_SPENDS_ENABLED; @@ -304,74 +334,80 @@ impl Flags { if self.outputs_enabled { value |= FLAG_OUTPUTS_ENABLED; } - // What value of `cross_address_enabled` are we able to encode based on `pool_restrictions`? - let cross_address_encodable = match pool_restrictions { - BundlePoolRestrictions::OrchardPreNu6_2 | BundlePoolRestrictions::OrchardNu6_2Only => { - true + + // Validate `cross_address_enabled` against what the value pool and protocol version can + // represent, and set the v6 flag bit where it carries that choice. A flag set whose + // `cross_address_enabled` value is not representable in this context cannot be encoded. + match (bundle_version.value_pool, bundle_version.protocol_version) { + // Cross-address Orchard pool transfers are always permitted prior to + // ProtocolVersion::V2; there is no flag bit, so a disabled flag set cannot be + // represented. + (ValuePool::Orchard, ProtocolVersion::InsecureV0 | ProtocolVersion::V1) => { + if !self.cross_address_enabled { + return None; + } } - BundlePoolRestrictions::OrchardNu6_3Onward => false, - BundlePoolRestrictions::IronwoodNu6_3Onward => { + // Cross-address Orchard pool transfers are disallowed in ProtocolVersion::V2. + (ValuePool::Orchard, ProtocolVersion::V2) => { + if self.cross_address_enabled { + return None; + } + } + // The Ironwood pool encodes the caller's choice in bit 2. + (ValuePool::Ironwood, ProtocolVersion::V2) => { if self.cross_address_enabled { value |= FLAG_V6_CROSS_ADDRESS_ENABLED; } - self.cross_address_enabled // we can encode what we wanted } - }; - if self.cross_address_enabled != cross_address_encodable { - // We can't encode the requested value. - return None; + // The Ironwood pool is not defined prior to ProtocolVersion::V2. + (ValuePool::Ironwood, _) => return None, } - assert!(Self::from_byte(value, pool_restrictions) == Some(*self)); + Some(value) } /// Parses flags from a single byte as defined in [Zcash Protocol Spec § /// 7.1: Transaction Encoding And Consensus][txencoding], according to the - /// interpretation implied by `pool_restrictions`. Returns `None` if + /// interpretation implied by `bundle_version`. Returns `None` if /// unexpected bits are set in the flag byte. /// /// The protocol specification and ZIPs 225 and 229 define bit 2 of `flags` /// to be reserved in v5 transactions, and to encode the /// `enableCrossAddress` flag in v6 transactions. However, we can (by - /// design) parse and validate the flags knowing only `pool_restrictions`: - /// bit 2 can only be 1 when `pool_restrictions == - /// BundlePoolRestrictions::IronwoodNu6_3Onward`, and otherwise MUST be 0. - /// Assuming that has been checked, cross-address transactions are then - /// always enabled before NU6.3, and are taken to be enabled when bit 2 is - /// set and `pool_restrictions == - /// BundlePoolRestrictions::IronwoodNu6_3Onward`, otherwise. + /// design) parse and validate the flags knowing only `bundle_version`: + /// bit 2 can only be 1 for a bundle in the [`ValuePool::Ironwood`] pool, and + /// otherwise MUST be 0. Assuming that has been checked, cross-address + /// transfers are always enabled prior to [`ProtocolVersion::V2`], and under + /// [`ProtocolVersion::V2`] are taken to be enabled exactly when bit 2 is set. /// - /// Note: if the wrong value of `pool_restrictions` is passed for the actual + /// Note: if the wrong value of `bundle_version` is passed for the actual /// pool and epoch of the transaction, then a consensus-invalid transaction - /// may be constructed (see [`BundlePoolRestrictions`]). + /// may be constructed (see [`BundleVersion`]). /// /// [txencoding]: https://zips.z.cash/protocol/protocol.pdf#txnencoding - pub fn from_byte(value: u8, pool_restrictions: BundlePoolRestrictions) -> Option { + pub fn from_byte(value: u8, bundle_version: BundleVersion) -> Option { // Bits 3..=7 are always reserved and MUST be 0. // https://p.z.cash/TCR:bad-txns-v5-reserved-bits-nonzero if value & FLAGS_ALWAYS_EXPECTED_UNSET != 0 { return None; } - let bit2 = value & FLAG_V6_CROSS_ADDRESS_ENABLED != 0; - // Bit 2 can only be 1 for an Ironwood bundle post-NU6.3 (necessarily a v6+ transaction). - // Otherwise it MUST be 0, independent of the tx version: + // Bit 2 can only be 1 for an Ironwood bundle. For Orchard it MUST be 0, independent of the + // tx version: // // * for a v5 transaction it is still reserved and MUST be 0; // * for a v6+ transaction it encodes `enableCrossAddress` and MUST be 0. // // https://p.z.cash/TCR:bad-txns-v5-reserved-bits-nonzero - if bit2 && pool_restrictions != BundlePoolRestrictions::IronwoodNu6_3Onward { + let bit2 = value & FLAG_V6_CROSS_ADDRESS_ENABLED != 0; + if bit2 && bundle_version.value_pool == ValuePool::Orchard { return None; } - // We have already validated bit2 and can assume that here. - let cross_address_enabled = match pool_restrictions { - BundlePoolRestrictions::OrchardPreNu6_2 | BundlePoolRestrictions::OrchardNu6_2Only => { - true - } - BundlePoolRestrictions::OrchardNu6_3Onward => false, - BundlePoolRestrictions::IronwoodNu6_3Onward => bit2, + // We have already validated bit2 against the pool type + let cross_address_enabled = match bundle_version.protocol_version { + ProtocolVersion::InsecureV0 | ProtocolVersion::V1 => true, + ProtocolVersion::V2 => bit2, }; Some(Self { spends_enabled: value & FLAG_SPENDS_ENABLED != 0, @@ -402,6 +438,14 @@ pub struct Bundle { anchor: Anchor, /// The authorization for this bundle. authorization: T, + /// The value pool and protocol version this bundle is encoded under. + /// + /// This is interpretive context rather than wire data: it is never serialized, but it + /// determines how the bundle's flags are encoded and which commitment format applies. A + /// `Bundle` is only ever constructed with flags that are representable under this version + /// (see [`Bundle::try_from_parts`] / [`Bundle::from_parts`]), so the bundle is safe to + /// serialize and commit to without supplying — and possibly mismatching — a version. + bundle_version: BundleVersion, } impl fmt::Debug for Bundle { @@ -420,6 +464,7 @@ impl fmt::Debug for Bundle { .field("value_balance", &self.value_balance) .field("anchor", &self.anchor) .field("authorization", &self.authorization) + .field("bundle_version", &self.bundle_version) .finish() } } @@ -439,6 +484,19 @@ pub(crate) fn validate_proof_size(proof: &Proof, num_actions: usize) -> Result<( } } +/// Checks that `flags` can be encoded under `bundle_version`. +/// +/// Returns [`BundleError::UnrepresentableFlags`] if it cannot. This is the shared check used by +/// the checked bundle constructors so that a constructed `Bundle` is always encodable and +/// committable under the version it carries. +fn validate_flags(flags: &Flags, bundle_version: BundleVersion) -> Result<(), BundleError> { + if flags.to_byte(bundle_version).is_some() { + Ok(()) + } else { + Err(BundleError::UnrepresentableFlags) + } +} + impl Bundle { /// Constructs a `Bundle` from its constituent parts without validating the authorization. /// @@ -446,19 +504,26 @@ impl Bundle { /// either carries no proof or carries a proof that is already known to be canonical (e.g. /// one produced by [`Proof::create`]). Construction from untrusted parts must instead go /// through a checked, authorization-specific constructor such as [`Bundle::try_from_parts`]. + /// + /// `flags` must be representable under `bundle_version`. Every `Bundle` upholds this, so that + /// [`Bundle::flag_byte`] and the commitment APIs cannot fail; callers are responsible for the + /// guarantee (it is debug-asserted here). pub(crate) fn from_parts_unchecked( actions: NonEmpty>, flags: Flags, value_balance: V, anchor: Anchor, authorization: T, + bundle_version: BundleVersion, ) -> Self { + debug_assert!(flags.to_byte(bundle_version).is_some()); Bundle { actions, flags, value_balance, anchor, authorization, + bundle_version, } } @@ -491,6 +556,26 @@ impl Bundle { &self.authorization } + /// Returns the [`BundleVersion`] (value pool and protocol version) this bundle is encoded + /// under. + pub fn bundle_version(&self) -> BundleVersion { + self.bundle_version + } + + /// Returns the byte encoding of this bundle's flags, as defined in [Zcash Protocol Spec § + /// 7.1: Transaction Encoding And Consensus][txencoding], under the bundle's own + /// [`BundleVersion`]. + /// + /// Unlike [`Flags::to_byte`], this is infallible: a `Bundle` is only ever constructed with + /// flags that are representable under its version. + /// + /// [txencoding]: https://zips.z.cash/protocol/protocol.pdf#txnencoding + pub fn flag_byte(&self) -> u8 { + self.flags + .to_byte(self.bundle_version) + .expect("flags are validated against the bundle version at construction") + } + /// Construct a new bundle by applying a transformation that might fail /// to the value balance. pub fn try_map_value_balance Result>( @@ -503,6 +588,7 @@ impl Bundle { value_balance: f(self.value_balance)?, anchor: self.anchor, authorization: self.authorization, + bundle_version: self.bundle_version, }) } @@ -522,6 +608,7 @@ impl Bundle { value_balance: self.value_balance, anchor: self.anchor, authorization: step(context, authorization), + bundle_version: self.bundle_version, } } @@ -545,6 +632,7 @@ impl Bundle { value_balance: self.value_balance, anchor: self.anchor, authorization: step(context, authorization)?, + bundle_version: self.bundle_version, }) } @@ -556,13 +644,12 @@ impl Bundle { .collect() } - /// Performs trial decryption of each action in the bundle under - /// `pool_restrictions` with each of the specified incoming viewing keys, and - /// returns a vector of each decrypted note plaintext contents along with the - /// index of the action from which it was derived. + /// Performs trial decryption of each action in the bundle with each of the + /// specified incoming viewing keys, and returns a vector of each decrypted + /// note plaintext contents along with the index of the action from which it + /// was derived. pub fn decrypt_outputs_with_keys( &self, - pool_restrictions: BundlePoolRestrictions, keys: &[IncomingViewingKey], ) -> Vec<(usize, IncomingViewingKey, Note, Address, [u8; 512])> { let prepared_keys: Vec<_> = keys @@ -573,7 +660,7 @@ impl Bundle { .iter() .enumerate() .filter_map(|(idx, action)| { - let domain = BundleDomain::for_action(action, pool_restrictions); + let domain = BundleDomain::for_action(action, self.bundle_version.note_version()); prepared_keys.iter().find_map(|(ivk, prepared_ivk)| { try_note_decryption(&domain, prepared_ivk, action) .map(|(n, a, m)| (idx, (*ivk).clone(), n, a, m)) @@ -583,35 +670,33 @@ impl Bundle { } /// Performs trial decryption of the action at `action_idx` in the bundle - /// under `pool_restrictions` with the specified incoming viewing key, and - /// returns the decrypted note plaintext contents if successful. + /// with the specified incoming viewing key, and returns the decrypted note + /// plaintext contents if successful. pub fn decrypt_output_with_key( &self, - pool_restrictions: BundlePoolRestrictions, action_idx: usize, key: &IncomingViewingKey, ) -> Option<(Note, Address, [u8; 512])> { let prepared_ivk = PreparedIncomingViewingKey::new(key); self.actions.get(action_idx).and_then(move |action| { - let domain = BundleDomain::for_action(action, pool_restrictions); + let domain = BundleDomain::for_action(action, self.bundle_version.note_version()); try_note_decryption(&domain, &prepared_ivk, action) }) } - /// Performs trial decryption of each action in the bundle under - /// `pool_restrictions` with each of the specified outgoing viewing keys, and - /// returns a vector of each decrypted note plaintext contents along with the - /// index of the action from which it was derived. + /// Performs trial decryption of each action in the bundle with each of the + /// specified outgoing viewing keys, and returns a vector of each decrypted + /// note plaintext contents along with the index of the action from which it + /// was derived. pub fn recover_outputs_with_ovks( &self, - pool_restrictions: BundlePoolRestrictions, keys: &[OutgoingViewingKey], ) -> Vec<(usize, OutgoingViewingKey, Note, Address, [u8; 512])> { self.actions .iter() .enumerate() .filter_map(|(idx, action)| { - let domain = BundleDomain::for_action(action, pool_restrictions); + let domain = BundleDomain::for_action(action, self.bundle_version.note_version()); keys.iter().find_map(move |key| { try_output_recovery_with_ovk( &domain, @@ -626,17 +711,16 @@ impl Bundle { .collect() } - /// Attempts to decrypt the action at the specified index under - /// `pool_restrictions` with the specified outgoing viewing key, and returns - /// the decrypted note plaintext contents if successful. + /// Attempts to decrypt the action at the specified index with the specified + /// outgoing viewing key, and returns the decrypted note plaintext contents + /// if successful. pub fn recover_output_with_ovk( &self, - pool_restrictions: BundlePoolRestrictions, action_idx: usize, key: &OutgoingViewingKey, ) -> Option<(Note, Address, [u8; 512])> { self.actions.get(action_idx).and_then(move |action| { - let domain = BundleDomain::for_action(action, pool_restrictions); + let domain = BundleDomain::for_action(action, self.bundle_version.note_version()); try_output_recovery_with_ovk( &domain, key, @@ -651,23 +735,17 @@ impl Bundle { impl> Bundle { /// Computes this bundle's transaction-ID commitment component. /// - /// `pool_restrictions` selects the flag-byte encoding; `tx_version` selects the commitment - /// personalizations and whether the bundle anchor bytes are included here or in the + /// The flag-byte encoding follows the bundle's own [`BundleVersion`]; `tx_version` selects the + /// commitment personalizations and whether the bundle anchor bytes are included here or in the /// authorizing digest. In a v5 transaction the anchor bytes are included here; in a v6 /// transaction they are included by [`Bundle::authorizing_commitment`] instead. /// /// # Errors /// - /// Returns [`CommitmentError::UnrepresentableFlags`] if the flags cannot - /// be encoded under the given pool restrictions, or - /// [`CommitmentError::InvalidTransactionVersion`] if `tx_version` is not - /// valid for `pool_restrictions`. - pub fn commitment( - &self, - pool_restrictions: BundlePoolRestrictions, - tx_version: TxVersion, - ) -> Result { - hash_bundle_txid_data(self, pool_restrictions, tx_version).map(BundleCommitment) + /// Returns [`CommitmentError::InvalidTransactionVersion`] if `tx_version` is not valid for the + /// bundle's [`BundleVersion`] (e.g. an Ironwood bundle committed with [`TxVersion::V5`]). + pub fn commitment(&self, tx_version: TxVersion) -> Result { + hash_bundle_txid_data(self, tx_version).map(BundleCommitment) } /// Returns the transaction binding validating key for this bundle. @@ -700,17 +778,32 @@ impl Authorization for EffectsOnly { impl Bundle { /// Constructs an effects-only `Bundle` from its constituent parts. /// - /// An effects-only bundle carries no proof, so there is no proof size to validate, - /// and flags are not checked against circuit support (there is no proof key to - /// check against). + /// An effects-only bundle carries no proof, so there is no proof size to validate, and flags + /// are not checked against circuit support (there is no proof key to check against). The flags + /// are, however, checked for representability under `bundle_version`, so that the resulting + /// bundle is safe to serialize and commit to. + /// + /// # Errors + /// + /// Returns [`BundleError::UnrepresentableFlags`] if `flags` cannot be encoded under + /// `bundle_version`. pub fn from_parts( actions: NonEmpty::SpendAuth>>, flags: Flags, value_balance: V, anchor: Anchor, authorization: EffectsOnly, - ) -> Self { - Bundle::from_parts_unchecked(actions, flags, value_balance, anchor, authorization) + bundle_version: BundleVersion, + ) -> Result { + validate_flags(&flags, bundle_version)?; + Ok(Bundle::from_parts_unchecked( + actions, + flags, + value_balance, + anchor, + authorization, + bundle_version, + )) } } @@ -762,6 +855,14 @@ pub enum BundleError { /// The length of the proof that was provided. actual: usize, }, + /// The bundle's flags cannot be encoded under its [`BundleVersion`]. This happens in two + /// cases: + /// + /// * cross-address transfers are disabled but the version specifies a pre-NU6.3 Orchard pool + /// (where cross-address transfers are implicitly enabled); + /// * cross-address transfers are enabled but the version specifies a post-NU6.3 Orchard pool + /// (where cross-address transfers are forbidden). + UnrepresentableFlags, } impl fmt::Display for BundleError { @@ -771,6 +872,10 @@ impl fmt::Display for BundleError { f, "Orchard proof has non-canonical length {actual}; expected {expected} bytes", ), + BundleError::UnrepresentableFlags => write!( + f, + "bundle flags are not representable under the bundle's value pool and protocol version", + ), } } } @@ -781,30 +886,16 @@ impl core::error::Error for BundleError {} #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum CommitmentError { - /// The bundle's flags cannot be encoded under the requested `pool_restrictions`. - /// This happens in two cases: - /// - /// * cross-address transfers are disabled but `pool_restrictions` specifies a pre-NU6.3 - /// Orchard pool (where cross-address transfers are implicitly enabled); - /// * cross-address transfers are enabled but `pool_restrictions` specifies a post-NU6.3 - /// Orchard pool (where cross-address transfers are forbidden). - UnrepresentableFlags, - /// The requested transaction version is not valid for the requested pool - /// restrictions. + /// The requested transaction version is not valid for the bundle's [`BundleVersion`]. /// - /// Ironwood bundles exist only in v6 transactions, so - /// `BundlePoolRestrictions::IronwoodNu6_3Onward` cannot be committed with - /// `TxVersion::V5`. + /// Ironwood bundles exist only in v6 transactions, so an Ironwood bundle + /// (`BundleVersion::ironwood_v2()`) cannot be committed with `TxVersion::V5`. InvalidTransactionVersion, } impl fmt::Display for CommitmentError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - CommitmentError::UnrepresentableFlags => write!( - f, - "bundle flags are not representable according to the requested pool restrictions", - ), CommitmentError::InvalidTransactionVersion => write!( f, "Ironwood bundles can only be committed in a v6 transaction", @@ -815,62 +906,65 @@ impl fmt::Display for CommitmentError { impl core::error::Error for CommitmentError {} -/// A flag type that identifies whether proof sizes are checked in bundle construction. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProofSizeEnforcement { - /// Proofs may exceed the canonical size - Unenforced, - /// Proofs may not exceed the canonical size - Strict, -} - impl Bundle { /// Constructs an authorized `Bundle` from its constituent parts. /// - /// This is the only constructor for an authorized bundle: it validates that the proof has - /// exactly [`Proof::expected_proof_size`] bytes for `actions.len()`, so an authorized bundle - /// can never hold a non-canonical proof. This matters when building a bundle from untrusted - /// input (e.g. deserializing from bytes), as it prevents a proof from being padded with - /// arbitrary data, which would otherwise impose unbounded bandwidth and storage costs without - /// affecting proof validity (GHSA-2x4w-pxqw-58v9). Circuit-key support for the bundle flags is - /// checked when proving or verifying the proof. + /// This is the only constructor for an authorized bundle. For every version except the + /// historical pre-NU6.2 Orchard pool (see [`BundleVersion::enforces_canonical_proof_size`]) it + /// validates that the proof has exactly [`Proof::expected_proof_size`] bytes for + /// `actions.len()`, so such an authorized bundle can never hold a non-canonical proof. This + /// matters when building a bundle from untrusted input (e.g. deserializing from bytes), as it + /// prevents a proof from being padded with arbitrary data, which would otherwise impose + /// unbounded bandwidth and storage costs without affecting proof validity + /// (GHSA-2x4w-pxqw-58v9). Circuit-key support for the bundle flags is checked when proving or + /// verifying the proof. + /// + /// The flags are also checked for representability under `bundle_version`, so that the + /// resulting bundle is safe to serialize and commit to. + /// + /// # Errors + /// + /// Returns [`BundleError::NonCanonicalProofSize`] if the proof length is not canonical (for a + /// version that enforces it), or [`BundleError::UnrepresentableFlags`] if `flags` cannot be + /// encoded under `bundle_version`. pub fn try_from_parts( actions: NonEmpty::SpendAuth>>, flags: Flags, value_balance: V, anchor: Anchor, authorization: Authorized, - size_enforcement: ProofSizeEnforcement, + bundle_version: BundleVersion, ) -> Result { - if size_enforcement == ProofSizeEnforcement::Strict { + if bundle_version.enforces_canonical_proof_size() { validate_proof_size(authorization.proof(), actions.len())?; } + validate_flags(&flags, bundle_version)?; Ok(Bundle::from_parts_unchecked( actions, flags, value_balance, anchor, authorization, + bundle_version, )) } /// Computes the authorizing-data commitment for this bundle. /// - /// This together with `Bundle::commitment` binds the entire bundle. `pool_restrictions` and - /// `tx_version` select the commitment personalization; in a v6 transaction this digest also - /// includes the bundle anchor bytes (in a v5 transaction they are included by - /// [`Bundle::commitment`] instead). + /// This together with `Bundle::commitment` binds the entire bundle. The bundle's own + /// [`BundleVersion`] and `tx_version` select the commitment personalization; in a v6 + /// transaction this digest also includes the bundle anchor bytes (in a v5 transaction they are + /// included by [`Bundle::commitment`] instead). /// /// # Errors /// - /// Returns [`CommitmentError::InvalidTransactionVersion`] if `tx_version` - /// is not valid for `pool_restrictions`. + /// Returns [`CommitmentError::InvalidTransactionVersion`] if `tx_version` is not valid for the + /// bundle's [`BundleVersion`]. pub fn authorizing_commitment( &self, - pool_restrictions: BundlePoolRestrictions, tx_version: TxVersion, ) -> Result { - hash_bundle_auth_data(self, pool_restrictions, tx_version).map(BundleAuthorizingCommitment) + hash_bundle_auth_data(self, tx_version).map(BundleAuthorizingCommitment) } /// Verifies the proof for this bundle. @@ -952,7 +1046,7 @@ pub mod testing { use proptest::prelude::*; use crate::{ - bundle::BundlePoolRestrictions, + bundle::BundleVersion, primitives::redpallas::{self, testing::arb_binding_signing_key}, value::{testing::arb_note_value_bounded, NoteValue, ValueSum, MAX_NOTE_VALUE}, Anchor, NoteVersion, Proof, @@ -965,16 +1059,29 @@ pub mod testing { /// Marker type for a bundle that contains no authorizing data. pub type Unauthorized = super::EffectsOnly; - /// Create an arbitrary bundle pool restriction. - pub fn arb_bundle_pool_restriction() -> impl Strategy { + /// Create an arbitrary [`BundleVersion`]. + pub fn arb_bundle_version() -> impl Strategy { prop_oneof![ - Just(BundlePoolRestrictions::OrchardPreNu6_2), - Just(BundlePoolRestrictions::OrchardNu6_2Only), - Just(BundlePoolRestrictions::OrchardNu6_3Onward), - Just(BundlePoolRestrictions::IronwoodNu6_3Onward), + Just(BundleVersion::orchard_insecure_v0()), + Just(BundleVersion::orchard_v1()), + Just(BundleVersion::orchard_v2()), + Just(BundleVersion::ironwood_v2()), ] } + /// Returns `flags` with its `cross_address_enabled` bit forced to the value representable under + /// `bundle_version`, leaving the spend/output bits untouched. + /// + /// The arbitrary-bundle strategies generate flags independently of the version; this pairs them + /// into a combination that a `Bundle` can actually be constructed from. + fn flags_for_version(bundle_version: BundleVersion, flags: Flags) -> Flags { + Flags::from_parts( + flags.spends_enabled(), + flags.outputs_enabled(), + bundle_version.permits_cross_address_transfers(), + ) + } + /// Generate an unauthorized action having spend and output values less than MAX_NOTE_VALUE / n_actions. pub fn arb_unauthorized_action_n( note_version: NoteVersion, @@ -1029,7 +1136,7 @@ pub mod testing { prop_compose! { /// Create an arbitrary set of flags with cross-address transfers enabled. - /// This is representable for all `pool_restrictions` other than Orchard post-NU6.3. + /// This is representable for all `bundle_version` other than Orchard post-NU6.3. /// /// Use `arb_flags_ironwood_post_nu6_3` for a strategy that can also disable /// cross-address transfers. @@ -1068,15 +1175,17 @@ pub mod testing { /// [`crate::builder::testing::arb_bundle`] pub fn arb_unauthorized_bundle(n_actions: usize) ( - pool_restrictions in arb_bundle_pool_restriction(), + bundle_version in arb_bundle_version(), flags in arb_flags(), ) ( - acts in vec(arb_unauthorized_action_n(pool_restrictions.note_version(), n_actions, flags), n_actions), + acts in vec(arb_unauthorized_action_n(bundle_version.note_version(), n_actions, flags), n_actions), anchor in arb_base().prop_map(Anchor::from), - flags in Just(flags) + flags in Just(flags), + bundle_version in Just(bundle_version), ) -> Bundle { let (balances, actions): (Vec, Vec>) = acts.into_iter().unzip(); + let flags = flags_for_version(bundle_version, flags); Bundle::from_parts( NonEmpty::from_vec(actions).unwrap(), @@ -1084,7 +1193,9 @@ pub mod testing { balances.into_iter().sum::>().unwrap(), anchor, super::EffectsOnly, + bundle_version, ) + .expect("flags are normalized to be representable under bundle_version") } } @@ -1094,21 +1205,23 @@ pub mod testing { /// [`crate::builder::testing::arb_bundle`] pub fn arb_bundle(n_actions: usize) ( - pool_restrictions in arb_bundle_pool_restriction(), + bundle_version in arb_bundle_version(), flags in arb_flags(), ) ( - acts in vec(arb_action_n(pool_restrictions.note_version(), n_actions, flags), n_actions), + acts in vec(arb_action_n(bundle_version.note_version(), n_actions, flags), n_actions), anchor in arb_base().prop_map(Anchor::from), sk in arb_binding_signing_key(), rng_seed in prop::array::uniform32(prop::num::u8::ANY), // A fake proof of the canonical length, so the bundle passes `try_from_parts`. fake_proof in vec(prop::num::u8::ANY, Proof::expected_proof_size(n_actions)), fake_sighash in prop::array::uniform32(prop::num::u8::ANY), - flags in Just(flags) + flags in Just(flags), + bundle_version in Just(bundle_version), ) -> Bundle { let (balances, actions): (Vec, Vec>) = acts.into_iter().unzip(); let rng = StdRng::from_seed(rng_seed); + let flags = flags_for_version(bundle_version, flags); Bundle::try_from_parts( NonEmpty::from_vec(actions).unwrap(), @@ -1119,9 +1232,9 @@ pub mod testing { proof: Proof::new(fake_proof), binding_signature: sk.sign(rng, &fake_sighash), }, - super::ProofSizeEnforcement::Strict + bundle_version, ) - .expect("fake proof has the canonical length") + .expect("fake proof has the canonical length and flags are representable") } } } @@ -1134,7 +1247,7 @@ pub(crate) mod tests { use super::testing::{arb_bundle, arb_flags, arb_flags_ironwood_post_nu6_3}; use super::{ - Authorized, Bundle, BundleError, BundlePoolRestrictions, CommitmentError, Flags, TxVersion, + Authorized, Bundle, BundleError, BundleVersion, CommitmentError, Flags, TxVersion, }; use crate::Proof; @@ -1151,6 +1264,7 @@ pub(crate) mod tests { *bundle.value_balance(), *bundle.anchor(), bundle.authorization().clone(), + bundle.bundle_version(), ) } @@ -1181,17 +1295,11 @@ pub(crate) mod tests { ), ] { assert_eq!( - flags.to_byte(BundlePoolRestrictions::OrchardNu6_2Only), + flags.to_byte(BundleVersion::orchard_v1()), orchard_pre_nu6_3 ); - assert_eq!( - flags.to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), - orchard_nu6_3 - ); - assert_eq!( - flags.to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward), - ironwood_nu6_3 - ); + assert_eq!(flags.to_byte(BundleVersion::orchard_v2()), orchard_nu6_3); + assert_eq!(flags.to_byte(BundleVersion::ironwood_v2()), ironwood_nu6_3); } } @@ -1200,12 +1308,9 @@ pub(crate) mod tests { // A byte with bit 2 clear parses as an unrestricted bundle for Orchard pre-NU6.3, // and a restricted bundle for Orchard or Ironwood post-NU6.3. for value in 0b000..=0b011 { - let pre_nu6_3_flags = - Flags::from_byte(value, BundlePoolRestrictions::OrchardNu6_2Only).unwrap(); - let nu6_3_flags = - Flags::from_byte(value, BundlePoolRestrictions::OrchardNu6_3Onward).unwrap(); - let ironwood_flags = - Flags::from_byte(value, BundlePoolRestrictions::IronwoodNu6_3Onward).unwrap(); + let pre_nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v1()).unwrap(); + let nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v2()).unwrap(); + let ironwood_flags = Flags::from_byte(value, BundleVersion::ironwood_v2()).unwrap(); assert_eq!( pre_nu6_3_flags.spends_enabled(), @@ -1222,29 +1327,26 @@ pub(crate) mod tests { // Each parse round-trips to the same byte under its own era, but the // restricted set is unrepresentable pre-NU6.3. assert_eq!( - pre_nu6_3_flags.to_byte(BundlePoolRestrictions::OrchardNu6_2Only), + pre_nu6_3_flags.to_byte(BundleVersion::orchard_v1()), Some(value) ); assert_eq!( - nu6_3_flags.to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), + nu6_3_flags.to_byte(BundleVersion::orchard_v2()), Some(value) ); - assert_eq!( - nu6_3_flags.to_byte(BundlePoolRestrictions::OrchardNu6_2Only), - None - ); + assert_eq!(nu6_3_flags.to_byte(BundleVersion::orchard_v1()), None); } assert_eq!( - Flags::from_byte(0b011, BundlePoolRestrictions::OrchardNu6_2Only), + Flags::from_byte(0b011, BundleVersion::orchard_v1()), Some(Flags::ENABLED) ); assert_eq!( - Flags::from_byte(0b011, BundlePoolRestrictions::OrchardNu6_3Onward), + Flags::from_byte(0b011, BundleVersion::orchard_v2()), Some(Flags::CROSS_ADDRESS_DISABLED) ); assert_eq!( - Flags::from_byte(0b011, BundlePoolRestrictions::IronwoodNu6_3Onward), + Flags::from_byte(0b011, BundleVersion::ironwood_v2()), Some(Flags::CROSS_ADDRESS_DISABLED) ); } @@ -1253,23 +1355,20 @@ pub(crate) mod tests { fn only_orchard_post_nu6_3_requires_the_cross_address_restriction() { // Consensus mandates the restriction only for the Orchard pool at NU6.3; every other // variant leaves the choice free (the builder then applies its prover-side default). - assert!(BundlePoolRestrictions::OrchardNu6_3Onward.requires_cross_address_restriction()); - for pool_restrictions in [ - BundlePoolRestrictions::OrchardPreNu6_2, - BundlePoolRestrictions::OrchardNu6_2Only, - BundlePoolRestrictions::IronwoodNu6_3Onward, + assert!(!BundleVersion::orchard_v2().permits_cross_address_transfers()); + for bundle_version in [ + BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_v1(), + BundleVersion::ironwood_v2(), ] { - assert!(!pool_restrictions.requires_cross_address_restriction()); + assert!(bundle_version.permits_cross_address_transfers()); } } #[test] fn pre_nu6_3_flags_parsing_rejects_reserved_bits() { for value in 0b100..=u8::MAX { - assert_eq!( - Flags::from_byte(value, BundlePoolRestrictions::OrchardNu6_2Only), - None - ); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v1()), None); } } @@ -1278,36 +1377,23 @@ pub(crate) mod tests { for value in 0b100..=0b111 { // Orchard post-NU6.3 mandates the restriction, so bit 2 (`enableCrossAddress`) // set is rejected there. - assert_eq!( - Flags::from_byte(value, BundlePoolRestrictions::OrchardNu6_3Onward), - None - ); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v2()), None); // Bit 2 set is only valid for the Ironwood pool, where it is recognized as // cross-address enabled and round-trips. - let flags = - Flags::from_byte(value, BundlePoolRestrictions::IronwoodNu6_3Onward).unwrap(); + let flags = Flags::from_byte(value, BundleVersion::ironwood_v2()).unwrap(); assert!(flags.cross_address_enabled()); - assert_eq!( - flags.to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward), - Some(value) - ); + assert_eq!(flags.to_byte(BundleVersion::ironwood_v2()), Some(value)); // Pre-NU6.3 formats encode the same flag set with bit 2 reserved zero. assert_eq!( - flags.to_byte(BundlePoolRestrictions::OrchardNu6_2Only), + flags.to_byte(BundleVersion::orchard_v1()), Some(value & 0b011) ); } // Bits 3.. are always reserved, in every NU6.3 pool. for value in 0b1000..=u8::MAX { - assert_eq!( - Flags::from_byte(value, BundlePoolRestrictions::OrchardNu6_3Onward), - None - ); - assert_eq!( - Flags::from_byte(value, BundlePoolRestrictions::IronwoodNu6_3Onward), - None - ); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v2()), None); + assert_eq!(Flags::from_byte(value, BundleVersion::ironwood_v2()), None); } } @@ -1328,13 +1414,14 @@ pub(crate) mod tests { #[test] fn empty_commitments_are_domain_separated() { use crate::bundle::commitments::{hash_bundle_auth_empty, hash_bundle_txid_empty}; + use crate::ValuePool; // The three commitment formats — Orchard v5, Orchard v6, Ironwood v6 — use distinct // personalizations, so the absent-bundle digests are all different from one another. let formats = [ - (BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5), - (BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V6), - (BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6), + (ValuePool::Orchard, TxVersion::V5), + (ValuePool::Orchard, TxVersion::V6), + (ValuePool::Ironwood, TxVersion::V6), ]; for i in 0..formats.len() { for j in (i + 1)..formats.len() { @@ -1352,11 +1439,11 @@ pub(crate) mod tests { } assert!(matches!( - hash_bundle_txid_empty(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5), + hash_bundle_txid_empty(ValuePool::Ironwood, TxVersion::V5), Err(CommitmentError::InvalidTransactionVersion) )); assert!(matches!( - hash_bundle_auth_empty(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5), + hash_bundle_auth_empty(ValuePool::Ironwood, TxVersion::V5), Err(CommitmentError::InvalidTransactionVersion) )); } @@ -1368,10 +1455,10 @@ pub(crate) mod tests { #[test] fn arb_flags_ironwood_post_nu6_3_round_trips(flags in arb_flags_ironwood_post_nu6_3()) { let encoded = flags - .to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward) + .to_byte(BundleVersion::ironwood_v2()) .expect("all Ironwood post-NU6.3 flag strategy outputs encode under Ironwood post-NU6.3"); - prop_assert_eq!(Flags::from_byte(encoded, BundlePoolRestrictions::IronwoodNu6_3Onward), Some(flags)); + prop_assert_eq!(Flags::from_byte(encoded, BundleVersion::ironwood_v2()), Some(flags)); } #[test] @@ -1379,70 +1466,63 @@ pub(crate) mod tests { // `arb_flags` always enables cross-address transfers, which Orchard post-NU6.3 // forbids, so encoding under those restrictions must fail. The cross-address- // disabled projection must still encode and round-trip. - prop_assert_eq!(flags.to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), None); + prop_assert_eq!(flags.to_byte(BundleVersion::orchard_v2()), None); let mut disabled = flags; disabled.cross_address_enabled = false; let encoded = disabled - .to_byte(BundlePoolRestrictions::OrchardNu6_3Onward) + .to_byte(BundleVersion::orchard_v2()) .expect("cross-address-disabled flags encode under Orchard post-NU6.3"); prop_assert_eq!( - Flags::from_byte(encoded, BundlePoolRestrictions::OrchardNu6_3Onward), + Flags::from_byte(encoded, BundleVersion::orchard_v2()), Some(disabled) ); } #[test] fn commitment_hashes_the_wire_flag_byte(bundle in arb_bundle(3)) { - // Rebuild the bundle with `V = i64` so that `commitment()` is available. - let bundle = Bundle::from_parts_unchecked( - bundle.actions().clone(), - *bundle.flags(), - 0i64, - *bundle.anchor(), - bundle.authorization().clone(), - ); - let mut flags = *bundle.flags(); - flags.cross_address_enabled = false; + let actions = bundle.actions().clone(); + let anchor = *bundle.anchor(); + let authorization = bundle.authorization().clone(); + let spends_enabled = bundle.flags().spends_enabled(); + let outputs_enabled = bundle.flags().outputs_enabled(); - let restricted = Bundle::from_parts_unchecked( - bundle.actions().clone(), - flags, - *bundle.value_balance(), - *bundle.anchor(), - bundle.authorization().clone(), - ); + let enabled = Flags::from_parts(spends_enabled, outputs_enabled, true); + let disabled = Flags::from_parts(spends_enabled, outputs_enabled, false); - // The restricted bundle's NU6.3 wire byte equals the unrestricted bundle's - // pre-NU6.3 byte, so their commitments agree. - prop_assert_eq!( - restricted.flags().to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), - bundle.flags().to_byte(BundlePoolRestrictions::OrchardNu6_2Only) - ); - let restricted_commitment: [u8; 32] = restricted - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5) - .expect("restricted flags are representable under NU6.3") - .into(); - let legacy_commitment: [u8; 32] = bundle - .commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5) - .expect("unrestricted flags are representable pre-NU6.3") - .into(); + // Build the same actions under different (flags, version) combinations, with `V = i64` + // so that `commitment()` is available. + let build = |flags, bundle_version| { + Bundle::from_parts_unchecked( + actions.clone(), + flags, + 0i64, + anchor, + authorization.clone(), + bundle_version, + ) + }; + + // Orchard pre-NU6.3 has cross-address implicitly enabled and Orchard NU6.3 has it + // disabled, but both encode to the same wire byte, so their commitments agree. + let legacy = build(enabled, BundleVersion::orchard_v1()); + let restricted = build(disabled, BundleVersion::orchard_v2()); + prop_assert_eq!(restricted.flag_byte(), legacy.flag_byte()); + + let restricted_commitment: [u8; 32] = + restricted.commitment(TxVersion::V5).unwrap().into(); + let legacy_commitment: [u8; 32] = legacy.commitment(TxVersion::V5).unwrap().into(); prop_assert_eq!(restricted_commitment, legacy_commitment); - // The unrestricted NU6.3 encoding sets bit 2, producing a distinct digest. Only + // The unrestricted Ironwood encoding sets bit 2, producing a distinct digest. Only // the Ironwood pool may set it; Orchard post-NU6.3 prohibits cross-address transfers. - let unrestricted_commitment: [u8; 32] = bundle - .commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6) - .expect("unrestricted flags are representable under Ironwood NU6.3") - .into(); + let unrestricted = build(enabled, BundleVersion::ironwood_v2()); + let unrestricted_commitment: [u8; 32] = + unrestricted.commitment(TxVersion::V6).unwrap().into(); prop_assert_ne!(unrestricted_commitment, restricted_commitment); - // The restricted flag set cannot be committed under pre-NU6.3 encoding. - prop_assert_eq!(restricted.flags().to_byte(BundlePoolRestrictions::OrchardNu6_2Only), None); - prop_assert!(matches!( - restricted.commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5), - Err(CommitmentError::UnrepresentableFlags) - )); + // The restricted flag set has no pre-NU6.3 encoding, so no such bundle can be built. + prop_assert_eq!(disabled.to_byte(BundleVersion::orchard_v1()), None); } #[test] @@ -1453,17 +1533,23 @@ pub(crate) mod tests { 0i64, *bundle.anchor(), bundle.authorization().clone(), + BundleVersion::ironwood_v2(), + ); + let ironwood = Bundle::from_parts_unchecked( + bundle.actions().clone(), + *bundle.flags(), + *bundle.value_balance(), + *bundle.anchor(), + bundle.authorization().clone(), + BundleVersion::ironwood_v2(), ); prop_assert!(matches!( - bundle_i64.commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5), + bundle_i64.commitment(TxVersion::V5), Err(CommitmentError::InvalidTransactionVersion) )); prop_assert!(matches!( - bundle.authorizing_commitment( - BundlePoolRestrictions::IronwoodNu6_3Onward, - TxVersion::V5 - ), + ironwood.authorizing_commitment(TxVersion::V5), Err(CommitmentError::InvalidTransactionVersion) )); } @@ -1475,31 +1561,37 @@ pub(crate) mod tests { #[test] fn anchor_placement_follows_tx_version(bundle in arb_bundle(3)) { // Orchard post-NU6.3 cannot encode cross-address transfers, so clear the bit to keep - // the flags representable in every format under test. - let mut flags = *bundle.flags(); - flags.cross_address_enabled = false; + // the flags representable in every version under test. + let flags = Flags::from_parts( + bundle.flags().spends_enabled(), + bundle.flags().outputs_enabled(), + false, + ); - let with_anchor = |anchor| { + let with_anchor = |anchor, bundle_version| { Bundle::from_parts_unchecked( bundle.actions().clone(), flags, 0i64, anchor, bundle.authorization().clone(), + bundle_version, ) }; - let a = with_anchor(crate::Anchor::from_bytes([0u8; 32]).unwrap()); - let b = with_anchor(crate::Anchor::from_bytes([6u8; 32]).unwrap()); + let anchor_a = crate::Anchor::from_bytes([0u8; 32]).unwrap(); + let anchor_b = crate::Anchor::from_bytes([6u8; 32]).unwrap(); - for (pool_restrictions, tx, anchor_in_txid_digest) in [ - (BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5, true), - (BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V6, false), - (BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6, false), + for (bundle_version, tx, anchor_in_txid_digest) in [ + (BundleVersion::orchard_v2(), TxVersion::V5, true), + (BundleVersion::orchard_v2(), TxVersion::V6, false), + (BundleVersion::ironwood_v2(), TxVersion::V6, false), ] { - let txid_a: [u8; 32] = a.commitment(pool_restrictions, tx).unwrap().into(); - let txid_b: [u8; 32] = b.commitment(pool_restrictions, tx).unwrap().into(); - let auth_a = a.authorizing_commitment(pool_restrictions, tx).unwrap().0; - let auth_b = b.authorizing_commitment(pool_restrictions, tx).unwrap().0; + let a = with_anchor(anchor_a, bundle_version); + let b = with_anchor(anchor_b, bundle_version); + let txid_a: [u8; 32] = a.commitment(tx).unwrap().into(); + let txid_b: [u8; 32] = b.commitment(tx).unwrap().into(); + let auth_a = a.authorizing_commitment(tx).unwrap().0; + let auth_b = b.authorizing_commitment(tx).unwrap().0; if anchor_in_txid_digest { prop_assert_ne!(txid_a, txid_b); prop_assert_eq!(auth_a.as_bytes(), auth_b.as_bytes()); @@ -1509,14 +1601,11 @@ pub(crate) mod tests { } } - let orchard_v5: [u8; 32] = a - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5) - .unwrap() - .into(); - let orchard_v6: [u8; 32] = a - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V6) - .unwrap() - .into(); + // The v5 and v6 Orchard formats are domain-separated, so the same bundle commits to + // distinct transaction-ID digests under each. + let a_v2 = with_anchor(anchor_a, BundleVersion::orchard_v2()); + let orchard_v5: [u8; 32] = a_v2.commitment(TxVersion::V5).unwrap().into(); + let orchard_v6: [u8; 32] = a_v2.commitment(TxVersion::V6).unwrap().into(); prop_assert_ne!(orchard_v5, orchard_v6); } @@ -1527,6 +1616,8 @@ pub(crate) mod tests { let actions = bundle.actions().clone(); let expected = Proof::expected_proof_size(actions.len()); let flags = *bundle.flags(); + // Ironwood enforces canonical proof size and accepts any cross-address flag value. + let bundle_version = BundleVersion::ironwood_v2(); let value_balance = *bundle.value_balance(); let anchor = *bundle.anchor(); let binding_signature = bundle.authorization().binding_signature().clone(); @@ -1541,7 +1632,7 @@ pub(crate) mod tests { Proof::new(vec![0u8; proof_len]), binding_signature.clone(), ), - crate::bundle::ProofSizeEnforcement::Strict + bundle_version, ) }; @@ -1578,7 +1669,7 @@ pub(crate) mod tests { value_balance, anchor, authorization, - crate::bundle::ProofSizeEnforcement::Strict, + BundleVersion::orchard_v2(), ) .expect("canonical proof size is accepted"); prop_assert!(!bundle.flags().cross_address_enabled()); @@ -1606,25 +1697,58 @@ pub(crate) mod tests { Proof::new(vec![0u8; expected + 1]), binding_signature, ), - crate::bundle::ProofSizeEnforcement::Strict, + BundleVersion::orchard_v2(), ) .err(), Some(BundleError::NonCanonicalProofSize { expected, actual: expected + 1 }) ); } + + #[test] + fn insecure_v0_skips_proof_size_enforcement(bundle in arb_bundle(3)) { + // The historical pre-NU6.2 Orchard pool does not enforce canonical proof size, so a + // padded proof is accepted: its transaction is already committed and cannot be + // re-canonicalized. + let expected = Proof::expected_proof_size(bundle.actions().len()); + let padded = Bundle::try_from_parts( + bundle.actions().clone(), + Flags::ENABLED, + *bundle.value_balance(), + *bundle.anchor(), + Authorized::from_parts( + Proof::new(vec![0u8; expected + 1]), + bundle.authorization().binding_signature().clone(), + ), + BundleVersion::orchard_insecure_v0(), + ); + prop_assert!(padded.is_ok()); + } } #[cfg(feature = "circuit")] #[test] - fn commitment_fails_for_unrepresentable_flags() { - let bundle = with_cross_address_disabled(sample_authorized_bundle(1)) - .try_map_value_balance(i64::try_from) - .expect("generated bundle value balance fits in i64"); + fn from_parts_rejects_unrepresentable_flags() { + // A cross-address-disabled flag set has no pre-NU6.3 Orchard encoding, so a bundle + // carrying that combination cannot be constructed under `orchard_v1()`. + let bundle = sample_authorized_bundle(1); + let flags = Flags::from_parts( + bundle.flags().spends_enabled(), + bundle.flags().outputs_enabled(), + false, + ); - assert!(matches!( - bundle.commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5), - Err(CommitmentError::UnrepresentableFlags) - )); + assert_eq!( + Bundle::try_from_parts( + bundle.actions().clone(), + flags, + *bundle.value_balance(), + *bundle.anchor(), + bundle.authorization().clone(), + BundleVersion::orchard_v1(), + ) + .err(), + Some(BundleError::UnrepresentableFlags) + ); } #[cfg(feature = "circuit")] diff --git a/src/bundle/commitments.rs b/src/bundle/commitments.rs index 99bef96a9..9be40dfd5 100644 --- a/src/bundle/commitments.rs +++ b/src/bundle/commitments.rs @@ -2,8 +2,9 @@ use blake2b_simd::{Hash as Blake2bHash, Params, State}; -use crate::bundle::{ - Authorization, Authorized, Bundle, BundlePoolRestrictions, CommitmentError, TxVersion, +use crate::{ + bundle::{Authorization, Authorized, Bundle, CommitmentError, TxVersion}, + ValuePool, }; const ZCASH_ORCHARD_V5_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOrchardHash"; @@ -71,23 +72,17 @@ enum BundleCommitmentFormat { IronwoodV6, } -impl BundlePoolRestrictions { +impl ValuePool { fn commitment_format( self, tx_version: TxVersion, ) -> Result { - Ok(match self { - BundlePoolRestrictions::OrchardPreNu6_2 - | BundlePoolRestrictions::OrchardNu6_2Only - | BundlePoolRestrictions::OrchardNu6_3Onward => match tx_version { - TxVersion::V5 => BundleCommitmentFormat::OrchardV5, - TxVersion::V6 => BundleCommitmentFormat::OrchardV6, - }, - BundlePoolRestrictions::IronwoodNu6_3Onward => match tx_version { - TxVersion::V5 => return Err(CommitmentError::InvalidTransactionVersion), - TxVersion::V6 => BundleCommitmentFormat::IronwoodV6, - }, - }) + match (self, tx_version) { + (ValuePool::Orchard, TxVersion::V5) => Ok(BundleCommitmentFormat::OrchardV5), + (ValuePool::Orchard, TxVersion::V6) => Ok(BundleCommitmentFormat::OrchardV6), + (ValuePool::Ironwood, TxVersion::V5) => Err(CommitmentError::InvalidTransactionVersion), + (ValuePool::Ironwood, TxVersion::V6) => Ok(BundleCommitmentFormat::IronwoodV6), + } } } @@ -130,16 +125,18 @@ fn hasher(personal: &[u8; 16]) -> State { /// personalization string. In the v6 format the anchor is included by /// `hash_bundle_auth_data` instead. /// -/// Returns an error if the bundle flags cannot be encoded in the domain's bundle format, -/// or if `tx_version` is not valid for `pool_restrictions`. +/// Returns [`CommitmentError::InvalidTransactionVersion`] if `tx_version` is not valid for the +/// bundle's [`BundleVersion`]. /// /// [zip244]: https://zips.z.cash/zip-0244 pub(crate) fn hash_bundle_txid_data>( bundle: &Bundle, - pool_restrictions: BundlePoolRestrictions, tx_version: TxVersion, ) -> Result { - let format = pool_restrictions.commitment_format(tx_version)?; + let format = bundle + .bundle_version() + .value_pool() + .commitment_format(tx_version)?; let personalizations = format.personalizations(); let mut h = hasher(personalizations.bundle); let mut ch = hasher(personalizations.actions_compact); @@ -163,10 +160,7 @@ pub(crate) fn hash_bundle_txid_data>( h.update(ch.finalize().as_bytes()); h.update(mh.finalize().as_bytes()); h.update(nh.finalize().as_bytes()); - h.update(&[bundle - .flags() - .to_byte(pool_restrictions) - .ok_or(CommitmentError::UnrepresentableFlags)?]); + h.update(&[bundle.flag_byte()]); h.update(&(*bundle.value_balance()).into().to_le_bytes()); if format.includes_anchor_in_txid_digest() { h.update(&bundle.anchor().to_bytes()); @@ -179,11 +173,11 @@ pub(crate) fn hash_bundle_txid_data>( /// /// [zip244]: https://zips.z.cash/zip-0244 pub fn hash_bundle_txid_empty( - pool_restrictions: BundlePoolRestrictions, + value_pool: ValuePool, tx_version: TxVersion, ) -> Result { Ok(hasher( - pool_restrictions + value_pool .commitment_format(tx_version)? .personalizations() .bundle, @@ -198,10 +192,12 @@ pub fn hash_bundle_txid_empty( /// [zip244]: https://zips.z.cash/zip-0244 pub(crate) fn hash_bundle_auth_data( bundle: &Bundle, - pool_restrictions: BundlePoolRestrictions, tx_version: TxVersion, ) -> Result { - let format = pool_restrictions.commitment_format(tx_version)?; + let format = bundle + .bundle_version() + .value_pool() + .commitment_format(tx_version)?; let mut h = hasher(format.personalizations().auth); h.update(bundle.authorization().proof().as_ref()); for action in bundle.actions().iter() { @@ -221,11 +217,11 @@ pub(crate) fn hash_bundle_auth_data( /// /// [zip244]: https://zips.z.cash/zip-0244 pub fn hash_bundle_auth_empty( - pool_restrictions: BundlePoolRestrictions, + value_pool: ValuePool, tx_version: TxVersion, ) -> Result { Ok(hasher( - pool_restrictions + value_pool .commitment_format(tx_version)? .personalizations() .auth, diff --git a/src/circuit.rs b/src/circuit.rs index 5f50a3dc8..06830e69f 100644 --- a/src/circuit.rs +++ b/src/circuit.rs @@ -1401,7 +1401,7 @@ mod tests { use super::{Circuit, Instance, OrchardCircuitVersion, Proof, ProvingKey, VerifyingKey, K}; use crate::{ - bundle::{BundlePoolRestrictions, Flags}, + bundle::{BundleVersion, Flags}, keys::SpendValidatingKey, note::{Note, NoteVersion, Rho}, tree::MerklePath, @@ -1567,8 +1567,8 @@ mod tests { let cmx = crate::note::ExtractedNoteCommitment::from_bytes(&read_32_bytes(&mut r)).unwrap(); let enable_spend = read_bool(&mut r); let enable_output = read_bool(&mut r); - let (cross_address_bit, pool_restrictions) = match encoding { - ProofFixtureEncoding::LegacyTwoFlags => (0, BundlePoolRestrictions::OrchardNu6_2Only), + let (cross_address_bit, bundle_version) = match encoding { + ProofFixtureEncoding::LegacyTwoFlags => (0, BundleVersion::orchard_v1()), ProofFixtureEncoding::PostNu6_3ThreeFlags => { // The fixture stores the instance-level *disable* bit; the NU6.3 flag // byte carries the *enable* bit, so invert when reconstructing. @@ -1579,13 +1579,13 @@ mod tests { let cross_address_disabled = read_bool(&mut r); ( u8::from(!cross_address_disabled) << 2, - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), ) } }; let flags = Flags::from_byte( u8::from(enable_spend) | (u8::from(enable_output) << 1) | cross_address_bit, - pool_restrictions, + bundle_version, ) .expect("test vectors use canonical flag encodings"); diff --git a/src/lib.rs b/src/lib.rs index fefb52b02..4d1602c94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,3 +117,42 @@ impl Proof { BASE + PER_ACTION * num_actions } } + +/// The set of value pools supported by the Orchard protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ValuePool { + /// The Orchard value pool. + Orchard, + /// The Ironwood value pool. + Ironwood, +} + +/// The versions of the Orchard protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProtocolVersion { + /// The original version of the protocol, used in Zcash prior to NU6.2, only instantiated for + /// the Orchard value pool. + /// + /// Uses the historical unsound Orchard circuit. Cross-address transfers are permitted and + /// notes use the V2 plaintext format. Used to reconstruct the historical verifying key and to + /// parse/verify historical bundles, not to build new ones. + InsecureV0, + /// The version of the Orchard protocol used in Zcash for NU6.2, only instantiated for the + /// Orchard value pool. + /// + /// Uses the post-NU6.2 fixed Orchard circuit. Cross-address transfers are permitted and notes + /// use the V2 plaintext format. + V1, + /// The version of the Orchard protocol used in Zcash NU6.3, instantiated for both the Orchard + /// and Ironwood value pools. + /// + /// Uses the post-NU6.3 circuit for both the Orchard and Ironwood value pools. + /// + /// For transactional bundles affecting the [`ValuePool::Orchard`] value pool, + /// `enableCrossAddress = 0` is required by consensus, so cross-address transfers are + /// prohibited and Orchard actions are disallowed in coinbase. Notes use V2 plaintexts. + /// + /// For transactional bundles affecting the [`ValuePool::Ironwood`] value pool, cross-address + /// transfers are permitted and notes use V3 plaintexts. + V2, +} diff --git a/src/note_encryption.rs b/src/note_encryption.rs index 2104a4de5..bcbd23a54 100644 --- a/src/note_encryption.rs +++ b/src/note_encryption.rs @@ -13,7 +13,6 @@ use zcash_note_encryption::{ use crate::{ action::Action, - bundle::BundlePoolRestrictions, keys::{ DiversifiedTransmissionKey, Diversifier, EphemeralPublicKey, EphemeralSecretKey, OutgoingViewingKey, PreparedEphemeralPublicKey, PreparedIncomingViewingKey, SharedSecret, @@ -126,13 +125,13 @@ impl DomainVersion for IronwoodVersion { #[derive(Debug)] pub(crate) struct BundleDomainPolicy { - pool_restrictions: BundlePoolRestrictions, + note_version: NoteVersion, } impl DomainPolicy for BundleDomainPolicy { fn note_version(&self, plaintext: &[u8]) -> Option { let note_version = NoteVersion::from_lead_byte(*plaintext.first()?)?; - if note_version == self.pool_restrictions.note_version() { + if note_version == self.note_version { Some(note_version) } else { None @@ -196,24 +195,21 @@ pub type OrchardDomain = NoteEncryptionDomain; /// [`NoteVersion::V3`] note plaintexts, which use lead byte `0x03`. pub type IronwoodDomain = NoteEncryptionDomain; -/// Bundle-pool-restricted note encryption logic. +/// Note encryption logic restricted to a single note plaintext version. /// -/// This domain is used by public bundle helpers that are given the target -/// [`BundlePoolRestrictions`]. Trial decryption still happens once; after -/// decryption succeeds, the revealed note plaintext lead byte selects the note -/// version and the bundle pool restrictions are enforced against it. +/// This domain is used by public bundle helpers that are given the bundle's +/// [`NoteVersion`]. Trial decryption still happens once; after decryption +/// succeeds, the revealed note plaintext lead byte selects the note version, which is +/// enforced to match the expected one. pub(crate) type BundleDomain = NoteEncryptionDomain; impl BundleDomain { /// Constructs a domain that can be used to trial-decrypt this action's - /// output note under `pool_restrictions`. - pub(crate) fn for_action( - act: &Action, - pool_restrictions: BundlePoolRestrictions, - ) -> Self { + /// output note as a note of `note_version`. + pub(crate) fn for_action(act: &Action, note_version: NoteVersion) -> Self { Self { rho: act.rho(), - policy: BundleDomainPolicy { pool_restrictions }, + policy: BundleDomainPolicy { note_version }, } } } diff --git a/src/pczt.rs b/src/pczt.rs index f9a227e0c..82cf4cc31 100644 --- a/src/pczt.rs +++ b/src/pczt.rs @@ -11,7 +11,7 @@ use zcash_note_encryption::OutgoingCipherKey; use zip32::ChildIndex; use crate::{ - bundle::Flags, + bundle::{BundleVersion, Flags}, keys::{FullViewingKey, SpendingKey}, note::{ExtractedNoteCommitment, Nullifier, RandomSeed, Rho, TransmittedNoteCiphertext}, primitives::redpallas::{self, Binding, SpendAuth}, @@ -64,6 +64,13 @@ pub struct Bundle { /// are consistent with these flags (i.e. are dummies as appropriate). pub(crate) flags: Flags, + /// The value pool and protocol version this bundle is encoded under. + /// + /// This is set by the Creator, and determines how the bundle's flags are interpreted and + /// which [`crate::Bundle`] the Transaction Extractor produces. The flags are always + /// consistent with this version (by parsing or construction). + pub(crate) bundle_version: BundleVersion, + /// The sum of the values of all `actions`. /// /// This is initialized by the Creator, and updated by the Constructor as spends or @@ -98,6 +105,16 @@ impl Bundle { pub fn actions_mut(&mut self) -> &mut [Action] { &mut self.actions } + + /// Returns the byte encoding of this bundle's flags under its own [`BundleVersion`]. + /// + /// This is infallible: a PCZT bundle is only ever constructed (by parsing or by the builder) + /// with flags that are representable under its version. + pub fn flag_byte(&self) -> u8 { + self.flags + .to_byte(self.bundle_version) + .expect("flags are validated against the bundle version at construction") + } } /// PCZT fields that are specific to producing an Orchard action within a transaction. @@ -357,7 +374,7 @@ mod tests { use crate::{ builder::{Builder, BundleMetadata, BundleType}, - bundle::{BundlePoolRestrictions, Flags}, + bundle::{BundleVersion, Flags}, circuit::{OrchardCircuitVersion, ProvingKey, VerifyingKey}, constants::MERKLE_DEPTH_ORCHARD, keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, @@ -392,8 +409,8 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_3Onward; - let note_version = pool_restrictions.note_version(); + let bundle_version = BundleVersion::orchard_v2(); + let note_version = bundle_version.note_version(); let rho = Rho::from_nf_old(Nullifier::dummy(&mut rng)); let note = Note::new( @@ -407,7 +424,7 @@ mod tests { let anchor = merkle_path.root(note.commitment().into()); let mut builder = Builder::new( - pool_restrictions, + bundle_version, BundleType::Transactional { spends_enabled: true, outputs_enabled: true, @@ -442,7 +459,7 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_2Only, + BundleVersion::orchard_v1(), BundleType::DEFAULT, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -461,7 +478,7 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), BundleType::DEFAULT, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -478,8 +495,8 @@ mod tests { #[test] fn shielding_bundle() { - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_2Only; - let pk = ProvingKey::build(pool_restrictions.circuit_version()); + let bundle_version = BundleVersion::orchard_v1(); + let pk = ProvingKey::build(bundle_version.circuit_version()); let mut rng = OsRng; let sk = SpendingKey::random(&mut rng); @@ -488,7 +505,7 @@ mod tests { // Run the Creator and Constructor roles. let mut builder = Builder::new( - pool_restrictions, + bundle_version, BundleType::DEFAULT, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -547,7 +564,7 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), BundleType::DEFAULT, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ); @@ -638,8 +655,8 @@ mod tests { (root.into(), merkle_path) }; - let pool_restrictions = BundlePoolRestrictions::IronwoodNu6_3Onward; - let mut builder = Builder::new(pool_restrictions, BundleType::DEFAULT, anchor); + let bundle_version = BundleVersion::ironwood_v2(); + let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, anchor); builder .add_spend(fvk.clone(), note, merkle_path.into()) .unwrap(); @@ -677,8 +694,8 @@ mod tests { #[test] fn shielded_bundle() { - let pool_restrictions = BundlePoolRestrictions::OrchardNu6_2Only; - let pk = ProvingKey::build(pool_restrictions.circuit_version()); + let bundle_version = BundleVersion::orchard_v1(); + let pk = ProvingKey::build(bundle_version.circuit_version()); let mut rng = OsRng; // Pretend we derived the spending key via ZIP 32. @@ -698,7 +715,7 @@ mod tests { value, rho, RandomSeed::random(&mut rng, &rho), - pool_restrictions.note_version(), + bundle_version.note_version(), ) .into_option() { @@ -732,11 +749,7 @@ mod tests { }; // Run the Creator and Constructor roles. - let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_2Only, - BundleType::DEFAULT, - anchor, - ); + let mut builder = Builder::new(BundleVersion::orchard_v1(), BundleType::DEFAULT, anchor); builder .add_spend(fvk.clone(), note, merkle_path.into()) .unwrap(); @@ -852,15 +865,15 @@ mod tests { } #[test] - fn parse_uses_pool_restrictions_for_flags() { + fn parse_uses_bundle_version_for_flags() { let anchor: crate::Anchor = EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(); // Bit 2 is reserved pre-NU6.3, and rejected for Orchard post-NU6.3 (which mandates // the cross-address restriction); only Ironwood may set it. for pr in [ - BundlePoolRestrictions::OrchardPreNu6_2, - BundlePoolRestrictions::OrchardNu6_2Only, - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_v1(), + BundleVersion::orchard_v2(), ] { assert!(matches!( super::Bundle::parse( @@ -879,7 +892,7 @@ mod tests { let parsed = super::Bundle::parse( vec![], 0b0000_0100, - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), (0, false), anchor.to_bytes(), None, @@ -889,22 +902,18 @@ mod tests { assert!(parsed.flags().cross_address_enabled()); assert_eq!( - parsed - .flags() - .to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward), + parsed.flags().to_byte(BundleVersion::ironwood_v2()), Some(0b0000_0100) ); assert_eq!( - parsed - .flags() - .to_byte(BundlePoolRestrictions::OrchardNu6_2Only), + parsed.flags().to_byte(BundleVersion::orchard_v1()), Some(0b0000_0000) ); let restricted = super::Bundle::parse( vec![], 0b0000_0011, - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), (0, false), anchor.to_bytes(), None, @@ -914,31 +923,27 @@ mod tests { assert!(!restricted.flags().cross_address_enabled()); assert_eq!( - restricted - .flags() - .to_byte(BundlePoolRestrictions::OrchardNu6_3Onward), + restricted.flags().to_byte(BundleVersion::orchard_v2()), Some(0b0000_0011) ); assert_eq!( - restricted - .flags() - .to_byte(BundlePoolRestrictions::OrchardNu6_2Only), + restricted.flags().to_byte(BundleVersion::orchard_v1()), None ); } #[test] fn parse_preserves_note_versions() { - let pool_restrictions = BundlePoolRestrictions::IronwoodNu6_3Onward; + let bundle_version = BundleVersion::ironwood_v2(); let pczt_bundle = ironwood_output_pczt_bundle(OsRng); - let flags = pczt_bundle.flags.to_byte(pool_restrictions).unwrap(); + let flags = pczt_bundle.flags.to_byte(bundle_version).unwrap(); let anchor = pczt_bundle.anchor.to_bytes(); let actions = pczt_bundle.actions; let parsed = super::Bundle::parse( actions, flags, - pool_restrictions, + bundle_version, (5000, true), anchor, None, @@ -953,9 +958,9 @@ mod tests { #[test] fn parse_rejects_output_note_version_mismatch() { - let pool_restrictions = BundlePoolRestrictions::IronwoodNu6_3Onward; + let bundle_version = BundleVersion::ironwood_v2(); let pczt_bundle = ironwood_output_pczt_bundle(OsRng); - let flags = pczt_bundle.flags.to_byte(pool_restrictions).unwrap(); + let flags = pczt_bundle.flags.to_byte(bundle_version).unwrap(); let anchor = pczt_bundle.anchor.to_bytes(); let mut actions = pczt_bundle.actions; actions[0].output.note_version = NoteVersion::V2; @@ -964,7 +969,7 @@ mod tests { super::Bundle::parse( actions, flags, - pool_restrictions, + bundle_version, (5000, true), anchor, None, @@ -1084,7 +1089,7 @@ mod tests { let anchor = merkle_path.root(note.commitment().into()); let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), BundleType::Transactional { spends_enabled: true, outputs_enabled: true, @@ -1182,6 +1187,10 @@ mod tests { pczt_bundle.actions.len() ) ])); + // Cross-address-disabled flags are only representable from NU6.3 onward, and the Orchard + // pool at NU6.3 mandates the restriction; that is the version under which an extracted + // bundle can legitimately carry these flags. + pczt_bundle.bundle_version = BundleVersion::orchard_v2(); pczt_bundle.flags = Flags::CROSS_ADDRESS_DISABLED; let bundle = pczt_bundle.extract::().unwrap().unwrap(); diff --git a/src/pczt/parse.rs b/src/pczt/parse.rs index e3faf21b6..ade4226b4 100644 --- a/src/pczt/parse.rs +++ b/src/pczt/parse.rs @@ -12,7 +12,7 @@ use zip32::ChildIndex; use super::{Action, Bundle, Output, Spend, Zip32Derivation}; use crate::{ - bundle::{BundlePoolRestrictions, Flags}, + bundle::{BundleVersion, Flags}, keys::{FullViewingKey, SpendingKey}, note::{ ExtractedNoteCommitment, NoteVersion, Nullifier, RandomSeed, Rho, TransmittedNoteCiphertext, @@ -26,22 +26,22 @@ use crate::{ impl Bundle { /// Parses a PCZT bundle from its component parts. /// - /// See [`BundlePoolRestrictions`] for the choice of `pool_restrictions`. + /// See [`BundleVersion`] for the choice of `bundle_version`. /// /// `value_sum` is represented as `(magnitude, is_negative)`. pub fn parse( actions: Vec, flags: u8, - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, value_sum: (u64, bool), anchor: [u8; 32], zkproof: Option>, bsk: Option<[u8; 32]>, ) -> Result { let flags = - Flags::from_byte(flags, pool_restrictions).ok_or(ParseError::UnexpectedFlagBitsSet)?; + Flags::from_byte(flags, bundle_version).ok_or(ParseError::UnexpectedFlagBitsSet)?; - let note_version = pool_restrictions.note_version(); + let note_version = bundle_version.note_version(); for action in actions.iter() { if *action.output.note_version() != note_version { return Err(ParseError::InvalidNoteVersion); @@ -74,6 +74,7 @@ impl Bundle { Ok(Self { actions, flags, + bundle_version, value_sum, anchor, zkproof, diff --git a/src/pczt/prover.rs b/src/pczt/prover.rs index 28401a31d..d46b33323 100644 --- a/src/pczt/prover.rs +++ b/src/pczt/prover.rs @@ -22,12 +22,12 @@ impl super::Bundle { /// /// # Errors /// - /// Returns [`ProverError::DisallowedCrossAddressTransfer`] if the bundle's - /// pool restrictions disable cross-address transfers, and any action's output + /// Returns [`ProverError::DisallowedCrossAddressTransfer`] if the bundle + /// disables cross-address transfers, and any action's output /// is addressed differently than its spent note. /// /// Returns [`ProverError::ProofFailed`] containing - /// [`plonk::Error::InvalidInstances`] if the bundle's pool restrictions disable + /// [`plonk::Error::InvalidInstances`] if the bundle disables /// cross-address transfers, and `pk` is not an /// [`OrchardCircuitVersion::PostNu6_3`] proving key. /// @@ -188,7 +188,7 @@ impl fmt::Display for ProverError { super::VerifyError::DisallowedCrossAddressTransfer => write!( f, "an action outputs to a different expanded receiver than it spends from, but the \ - bundle's pool restrictions disable cross-address transfers" + bundle disables cross-address transfers" ), e => write!(f, "cross-address restriction verification failed: {e}"), }, diff --git a/src/pczt/tx_extractor.rs b/src/pczt/tx_extractor.rs index 8a035a334..e71e5e37b 100644 --- a/src/pczt/tx_extractor.rs +++ b/src/pczt/tx_extractor.rs @@ -112,6 +112,7 @@ impl super::Bundle { value_balance, self.anchor, authorization, + self.bundle_version, )) } else { None @@ -143,6 +144,8 @@ pub enum TxExtractorError { /// The length of the proof that was provided. actual: usize, }, + /// The bundle's flags cannot be encoded under its value pool and protocol version. + UnrepresentableFlags, } impl From for TxExtractorError { @@ -160,6 +163,9 @@ impl From for TxExtractorError { crate::bundle::BundleError::NonCanonicalProofSize { expected, actual } => { TxExtractorError::NonCanonicalProofSize { expected, actual } } + crate::bundle::BundleError::UnrepresentableFlags => { + TxExtractorError::UnrepresentableFlags + } } } } @@ -192,6 +198,10 @@ impl fmt::Display for TxExtractorError { f, "Orchard `zkproof` has non-canonical length {actual}; expected {expected} bytes", ), + TxExtractorError::UnrepresentableFlags => write!( + f, + "Orchard bundle flags are not representable under its value pool and protocol version", + ), } } } diff --git a/src/pczt/verify.rs b/src/pczt/verify.rs index 6b7aa6d3b..84075a0c7 100644 --- a/src/pczt/verify.rs +++ b/src/pczt/verify.rs @@ -228,7 +228,7 @@ impl fmt::Display for VerifyError { VerifyError::DisallowedCrossAddressTransfer => write!( f, "an action outputs to a different expanded receiver than it spends from, but the \ - bundle's pool restriction disables cross-address transfers" + bundle disables cross-address transfers" ), VerifyError::InvalidExtractedNoteCommitment => { write!(f, "output note doesn't match `cmx`") diff --git a/tests/builder.rs b/tests/builder.rs index 3977502e6..a731afdbd 100644 --- a/tests/builder.rs +++ b/tests/builder.rs @@ -3,7 +3,7 @@ use incrementalmerkletree::{Hashable, Marking, Retention}; use orchard::{ builder::{Builder, BundleType}, - bundle::{Authorized, BatchValidator, BundlePoolRestrictions, TxVersion}, + bundle::{Authorized, BatchValidator, BundleVersion, TxVersion}, circuit::{OrchardCircuitVersion, ProvingKey, VerifyingKey}, keys::{FullViewingKey, PreparedIncomingViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, note::{ExtractedNoteCommitment, NoteVersion}, @@ -40,15 +40,10 @@ fn single_leaf_witness(cmx: &ExtractedNoteCommitment) -> (MerkleHashOrchard, Mer (root, merkle_path.into()) } -fn verify_bundle( - bundle: &Bundle, - vk: &VerifyingKey, - pool_restrictions: BundlePoolRestrictions, - tx_version: TxVersion, -) { +fn verify_bundle(bundle: &Bundle, vk: &VerifyingKey, tx_version: TxVersion) { assert!(matches!(bundle.verify_proof(vk), Ok(()))); let sighash: [u8; 32] = bundle - .commitment(pool_restrictions, tx_version) + .commitment(tx_version) .expect("bundle flags are representable in this format") .into(); let bvk = bundle.binding_validating_key(); @@ -68,15 +63,15 @@ const SHIELDING: BundleType = BundleType::Transactional { bundle_required: false, }; -/// Creates a builder of the given `pool_restrictions` and `bundle_type` over the +/// Creates a builder of the given `bundle_version` and `bundle_type` over the /// empty-tree anchor, with a single 5000-zat output to `recipient`. fn output_only_builder( - pool_restrictions: BundlePoolRestrictions, + bundle_version: BundleVersion, bundle_type: BundleType, recipient: Address, ) -> Builder { let anchor = MerkleHashOrchard::empty_root(32.into()).into(); - let mut builder = Builder::new(pool_restrictions, bundle_type, anchor); + let mut builder = Builder::new(bundle_version, bundle_type, anchor); assert_eq!( builder.add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]), Ok(()) @@ -96,17 +91,12 @@ fn bundle_chain() { // Create a shielding bundle. let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder( - BundlePoolRestrictions::OrchardNu6_2Only, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::orchard_v1(), SHIELDING, recipient); let (unauthorized, bundle_meta) = builder.build(&mut rng).unwrap().unwrap(); assert_eq!( unauthorized .decrypt_output_with_key( - BundlePoolRestrictions::OrchardNu6_2Only, bundle_meta .output_action_index(0) .expect("Output 0 can be found"), @@ -117,7 +107,7 @@ fn bundle_chain() { ); let sighash = unauthorized - .commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&pk, &mut rng).unwrap(); @@ -125,12 +115,7 @@ fn bundle_chain() { }; // Verify the shielding bundle. - verify_bundle( - &shielding_bundle, - &vk, - BundlePoolRestrictions::OrchardNu6_2Only, - TxVersion::V5, - ); + verify_bundle(&shielding_bundle, &vk, TxVersion::V5); // Create a shielded bundle spending the previous output. let shielded_bundle: Bundle<_, i64> = { @@ -149,7 +134,7 @@ fn bundle_chain() { let (root, merkle_path) = single_leaf_witness(&cmx); let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_2Only, + BundleVersion::orchard_v1(), BundleType::DEFAULT, root.into(), ); @@ -160,7 +145,7 @@ fn bundle_chain() { ); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized - .commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&pk, &mut rng).unwrap(); @@ -170,12 +155,7 @@ fn bundle_chain() { }; // Verify the shielded bundle. - verify_bundle( - &shielded_bundle, - &vk, - BundlePoolRestrictions::OrchardNu6_2Only, - TxVersion::V5, - ); + verify_bundle(&shielded_bundle, &vk, TxVersion::V5); } // A bundle built with the circuit version set to `InsecurePreNu6_2` produces a proof against @@ -192,15 +172,11 @@ fn builder_builds_for_insecure_circuit_version() { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let builder = output_only_builder( - BundlePoolRestrictions::OrchardPreNu6_2, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::orchard_insecure_v0(), SHIELDING, recipient); let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); let sighash: [u8; 32] = unauthorized - .commitment(BundlePoolRestrictions::OrchardPreNu6_2, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&insecure_pk, &mut rng).unwrap(); @@ -220,11 +196,7 @@ fn builder_builds_for_post_nu6_3_circuit_version() { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let builder = output_only_builder( - BundlePoolRestrictions::IronwoodNu6_3Onward, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); assert_eq!( @@ -233,18 +205,13 @@ fn builder_builds_for_post_nu6_3_circuit_version() { ); let sighash: [u8; 32] = unauthorized - .commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6) + .commitment(TxVersion::V6) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&post_nu6_3_pk, &mut rng).unwrap(); let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); - verify_bundle( - &bundle, - &post_nu6_3_vk, - BundlePoolRestrictions::IronwoodNu6_3Onward, - TxVersion::V6, - ); + verify_bundle(&bundle, &post_nu6_3_vk, TxVersion::V6); } #[test] @@ -255,11 +222,7 @@ fn ironwood_builder_outputs_decrypt_with_ironwood_domain() { let recipient = fvk.address_at(0u32, Scope::External); let ivk = PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::External)); - let builder = output_only_builder( - BundlePoolRestrictions::IronwoodNu6_3Onward, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); let (bundle, bundle_meta) = builder.build::(&mut rng).unwrap().unwrap(); let action = &bundle.actions()[bundle_meta .output_action_index(0) @@ -286,10 +249,10 @@ fn ironwood_bundle_helpers_decrypt_and_recover_outputs() { let recipient = fvk.address_at(0u32, Scope::External); let ivk = fvk.to_ivk(Scope::External); let ovk = fvk.to_ovk(Scope::External); - let pool_restrictions = BundlePoolRestrictions::IronwoodNu6_3Onward; + let bundle_version = BundleVersion::ironwood_v2(); let anchor = MerkleHashOrchard::empty_root(32.into()).into(); - let mut builder = Builder::new(pool_restrictions, SHIELDING, anchor); + let mut builder = Builder::new(bundle_version, SHIELDING, anchor); assert_eq!( builder.add_output( Some(ovk.clone()), @@ -304,19 +267,15 @@ fn ironwood_bundle_helpers_decrypt_and_recover_outputs() { .output_action_index(0) .expect("Output 0 can be found"); - assert!(bundle - .decrypt_output_with_key(BundlePoolRestrictions::OrchardNu6_2Only, action_idx, &ivk,) - .is_none()); - let (note, decrypted_to, memo) = bundle - .decrypt_output_with_key(pool_restrictions, action_idx, &ivk) + .decrypt_output_with_key(action_idx, &ivk) .expect("V3 output decrypts through the bundle helper"); assert_eq!(note.version(), NoteVersion::V3); assert_eq!(note.value(), NoteValue::from_raw(5000)); assert_eq!(decrypted_to, recipient); assert_eq!(memo, [0u8; 512]); - let decrypted = bundle.decrypt_outputs_with_keys(pool_restrictions, &[ivk]); + let decrypted = bundle.decrypt_outputs_with_keys(&[ivk]); assert_eq!(decrypted.len(), 1); assert_eq!(decrypted[0].0, action_idx); assert_eq!(decrypted[0].2.version(), NoteVersion::V3); @@ -324,19 +283,15 @@ fn ironwood_bundle_helpers_decrypt_and_recover_outputs() { assert_eq!(decrypted[0].3, recipient); assert_eq!(decrypted[0].4, [0u8; 512]); - assert!(bundle - .recover_output_with_ovk(BundlePoolRestrictions::OrchardNu6_2Only, action_idx, &ovk,) - .is_none()); - let (note, recovered_to, memo) = bundle - .recover_output_with_ovk(pool_restrictions, action_idx, &ovk) + .recover_output_with_ovk(action_idx, &ovk) .expect("V3 output recovers through the bundle helper"); assert_eq!(note.version(), NoteVersion::V3); assert_eq!(note.value(), NoteValue::from_raw(5000)); assert_eq!(recovered_to, recipient); assert_eq!(memo, [0u8; 512]); - let recovered = bundle.recover_outputs_with_ovks(pool_restrictions, &[ovk]); + let recovered = bundle.recover_outputs_with_ovks(&[ovk]); assert_eq!(recovered.len(), 1); assert_eq!(recovered[0].0, action_idx); assert_eq!(recovered[0].2.version(), NoteVersion::V3); @@ -361,7 +316,7 @@ fn post_nu6_3_coinbase_bundle_proves_and_verifies() { let recipient = fvk.address_at(0u32, Scope::External); let builder = output_only_builder( - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), BundleType::Coinbase, recipient, ); @@ -372,18 +327,13 @@ fn post_nu6_3_coinbase_bundle_proves_and_verifies() { assert!(unauthorized.flags().cross_address_enabled()); let sighash: [u8; 32] = unauthorized - .commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6) + .commitment(TxVersion::V6) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&post_nu6_3_pk, &mut rng).unwrap(); let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); - verify_bundle( - &bundle, - &post_nu6_3_vk, - BundlePoolRestrictions::IronwoodNu6_3Onward, - TxVersion::V6, - ); + verify_bundle(&bundle, &post_nu6_3_vk, TxVersion::V6); } // A post-NU 6.3 restricted bundle chain: an ordinary shielding bundle, followed by a bundle @@ -402,27 +352,18 @@ fn post_nu6_3_restricted_bundle_chain() { let recipient = fvk.address_at(0u32, Scope::External); let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder( - BundlePoolRestrictions::OrchardNu6_2Only, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::orchard_v1(), SHIELDING, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized - .commitment(BundlePoolRestrictions::OrchardNu6_2Only, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&fixed_pk, &mut rng).unwrap(); proven.apply_signatures(rng, sighash, &[]).unwrap() }; - verify_bundle( - &shielding_bundle, - &fixed_vk, - BundlePoolRestrictions::OrchardNu6_2Only, - TxVersion::V5, - ); + verify_bundle(&shielding_bundle, &fixed_vk, TxVersion::V5); let change_addr = fvk.address_at(0u32, Scope::Internal); let restricted_bundle: Bundle<_, i64> = { @@ -440,7 +381,7 @@ fn post_nu6_3_restricted_bundle_chain() { let (root, merkle_path) = single_leaf_witness(&cmx); let mut builder = Builder::new( - BundlePoolRestrictions::OrchardNu6_3Onward, + BundleVersion::orchard_v2(), BundleType::Transactional { spends_enabled: true, outputs_enabled: true, @@ -469,7 +410,6 @@ fn post_nu6_3_restricted_bundle_chain() { assert_eq!( unauthorized .decrypt_output_with_key( - BundlePoolRestrictions::OrchardNu6_3Onward, bundle_meta .output_action_index(0) .expect("Output 0 can be found"), @@ -485,7 +425,6 @@ fn post_nu6_3_restricted_bundle_chain() { // from anyone (including a quantum adversary) who recovers that ivk from the address. assert!(unauthorized .decrypt_output_with_key( - BundlePoolRestrictions::OrchardNu6_3Onward, bundle_meta .spend_action_index(0) .expect("Spend 0 can be found"), @@ -494,7 +433,7 @@ fn post_nu6_3_restricted_bundle_chain() { .is_none()); let sighash = unauthorized - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&post_nu6_3_pk, &mut rng).unwrap(); @@ -504,12 +443,7 @@ fn post_nu6_3_restricted_bundle_chain() { }; assert_eq!(restricted_bundle.value_balance(), &2000); - verify_bundle( - &restricted_bundle, - &post_nu6_3_vk, - BundlePoolRestrictions::OrchardNu6_3Onward, - TxVersion::V5, - ); + verify_bundle(&restricted_bundle, &post_nu6_3_vk, TxVersion::V5); assert!(restricted_bundle.verify_proof(&fixed_vk).is_err()); let mut validator = BatchValidator::new(&post_nu6_3_vk); @@ -517,7 +451,7 @@ fn post_nu6_3_restricted_bundle_chain() { .add_bundle( &restricted_bundle, restricted_bundle - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(), ) @@ -531,23 +465,22 @@ fn post_nu6_3_restricted_bundle_chain() { .add_bundle( &restricted_bundle, restricted_bundle - .commitment(BundlePoolRestrictions::OrchardNu6_3Onward, TxVersion::V5) + .commitment(TxVersion::V5) .expect("bundle flags are representable in this format") .into(), ) .is_err()); } -// `IronwoodNu6_3Onward` is the post-NU6.3 `BundlePoolRestrictions` variant that allows -// any choice of the `enableCrossAddress` flag. It shares the post-NU6.3 circuit with the -// `OrchardNU6_3Onward`, and uses V3 note plaintexts. A transactional +// `BundleVersion::ironwood_v2()` is the post-NU6.3 Ironwood bundle version, which allows +// any choice of the `enableCrossAddress` flag. It shares the post-NU6.3 circuit with +// `BundleVersion::orchard_v2()`, and uses V3 note plaintexts. A transactional // Ironwood bundle is therefore an ordinary spend+output bundle on the post-NU6.3 circuit // whose NU6.3 flag byte sets bit 2. #[test] fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { let mut rng = OsRng; - let post_nu6_3_pk = - ProvingKey::build(BundlePoolRestrictions::IronwoodNu6_3Onward.circuit_version()); + let post_nu6_3_pk = ProvingKey::build(BundleVersion::ironwood_v2().circuit_version()); let post_nu6_3_vk = VerifyingKey::build(OrchardCircuitVersion::PostNu6_3); let sk = SpendingKey::from_bytes([0; 32]).unwrap(); @@ -556,14 +489,10 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { // Shield a note to spend (an unrestricted, output-only post-NU6.3 bundle). let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder( - BundlePoolRestrictions::IronwoodNu6_3Onward, - SHIELDING, - recipient, - ); + let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized - .commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6) + .commitment(TxVersion::V6) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&post_nu6_3_pk, &mut rng).unwrap(); @@ -589,7 +518,7 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { // cross-address transfer, which Ironwood permits but post-NU6.3 Orchard would forbid. let change_addr = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( - BundlePoolRestrictions::IronwoodNu6_3Onward, + BundleVersion::ironwood_v2(), BundleType::DEFAULT, root.into(), ); @@ -608,12 +537,12 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { assert!(unauthorized.flags().cross_address_enabled()); let flag_byte = unauthorized .flags() - .to_byte(BundlePoolRestrictions::IronwoodNu6_3Onward) + .to_byte(BundleVersion::ironwood_v2()) .expect("flags are representable under Ironwood"); assert_eq!(flag_byte & 0b100, 0b100); let sighash = unauthorized - .commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V6) + .commitment(TxVersion::V6) .expect("bundle flags are representable in this format") .into(); let proven = unauthorized.create_proof(&post_nu6_3_pk, &mut rng).unwrap(); @@ -621,10 +550,5 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { .apply_signatures(rng, sighash, &[SpendAuthorizingKey::from(&sk)]) .unwrap(); - verify_bundle( - &bundle, - &post_nu6_3_vk, - BundlePoolRestrictions::IronwoodNu6_3Onward, - TxVersion::V6, - ); + verify_bundle(&bundle, &post_nu6_3_vk, TxVersion::V6); } From 4ce1b23862662c830886ae2a90f63cad0bc227a9 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Sun, 28 Jun 2026 22:21:27 -0600 Subject: [PATCH 2/5] Lift Flags out of BundleType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BundleType` previously embedded part of a bundle's `Flags` (`Transactional` carried `spends_enabled`/`outputs_enabled`, and `BundleType::flags` derived the cross-address bit from the bundle version). This conflated two independent concerns: the construction discipline (how the builder pads and pairs actions) and the bundle's flag set (which spend/output/cross-address capabilities it advertises). It also meant the caller could not restrict cross-address transfers more tightly than the bundle version's default chose to. Make the two orthogonal. `BundleType` is now just the construction policy — `Transactional { bundle_required }` or `Coinbase` — and the bundle's `Flags` are supplied separately to the builder. The default flag set moves to `BundleVersion::default_flags` (spends and outputs enabled, cross-address transfers enabled unless the version mandates the restriction); a caller may pass a more restricted set the version permits. Validate the flags when the builder is constructed: `Builder::new` (and the free `bundle` function) now take `Flags` and are fallible, rejecting a flag set that cannot be encoded under the bundle version with `BuildError::UnrepresentableFlags`, and rejecting a `Coinbase` builder whose flags enable spends with `BuildError::CoinbaseSpendsEnabled`. Because the flags are validated up front, `build_bundle` can assume they are encodable (a `debug_assert!` documents the invariant). `BundleType::num_actions` now reads the spend/output/cross-address policy from the supplied `Flags` rather than re-deriving it from the bundle version. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 61 +++-- benches/circuit.rs | 6 +- benches/note_decryption.rs | 6 +- src/builder.rs | 458 ++++++++++++++++++++++--------------- src/bundle.rs | 12 + src/pczt.rs | 64 ++++-- tests/builder.rs | 62 ++--- 7 files changed, 408 insertions(+), 261 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d03479a9..1e0d872ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ non-serialized context, so a bundle can be serialized and committed to without s supplying a — possibly mismatching — version, and is encodable and committable by construction. The post-NU 6.3 Action circuit enforces the cross-address restriction. Existing callers keep the current behavior by constructing bundles with -`BundleVersion::orchard_v1()` (and `OrchardCircuitVersion::FixedPostNu6_2` when building -proving/verifying keys). +`BundleVersion::orchard_v1()` and `BundleVersion::orchard_v1().default_flags()` (and +`OrchardCircuitVersion::FixedPostNu6_2` when building proving/verifying keys). ### Added - NU6.3 and Ironwood bundle-version APIs: @@ -34,7 +34,10 @@ proving/verifying keys). the value within that constraint). `BundleVersion::value_pool` and `BundleVersion::protocol_version` return the bundle's `ValuePool` and `ProtocolVersion`; the Ironwood pool (`ironwood_v2`) shares the post-NU6.3 circuit and - uses V3 note plaintexts. + uses V3 note plaintexts. `BundleVersion::default_flags` returns the least-restrictive + `Flags` consensus permits under the bundle version (spends and outputs enabled, + cross-address transfers enabled except where the version mandates the restriction), + the suitable default for the builder that a caller may restrict further. - `orchard::bundle::TxVersion`, the transaction version (`V5` or `V6`) a bundle's commitments are computed for. At NU6.3 an Orchard bundle may be encoded in a v5 or a v6 transaction; the two use different commitment @@ -75,7 +78,7 @@ proving/verifying keys). - `orchard::builder::Builder::changes` - `impl orchard::builder::OutputView for ChangeInfo` - New builder errors: - - `orchard::builder::BuildError::{CrossAddressDisabled, InvalidNoteVersion}` + - `orchard::builder::BuildError::{CrossAddressDisabled, InvalidNoteVersion, UnrepresentableFlags, CoinbaseSpendsEnabled}` - `orchard::builder::OutputError::{SpendsDisabled, CrossAddressDisabled, RecipientNotOwned}` - Ironwood and note-version APIs: - `orchard::NoteVersion`, the note plaintext version selector, with variants @@ -104,25 +107,34 @@ proving/verifying keys). underlying `orchard::pczt::VerifyError`. ### Changed -- Bundle construction now requires an explicit `BundleVersion`: +- Bundle construction now requires an explicit `BundleVersion` and `Flags`: - `orchard::builder::Builder::new` now takes - `(BundleVersion, BundleType, Anchor)`; the builder derives the circuit - version from the bundle version rather than from an explicit + `(BundleType, BundleVersion, Flags, Anchor)` and returns + `Result`; the `Flags` are validated against the + `BundleVersion` (rejected with `BuildError::UnrepresentableFlags`), and a + `BundleType::Coinbase` builder requires spends-disabled flags (rejected with + `BuildError::CoinbaseSpendsEnabled`). `BundleVersion::default_flags` supplies a + suitable default that the caller may restrict further. The builder derives the + circuit version from the bundle version rather than from an explicit `OrchardCircuitVersion`. - - `orchard::builder::bundle` now takes a `BundleVersion` in place of + - `orchard::builder::bundle` now takes a `BundleVersion` and `Flags` in place of the circuit-version argument, and takes the wallet-controlled change outputs as a separate `changes: Vec` argument (plain `outputs` and `changes` are distinct). It rejects supplied `OutputInfo`/`ChangeInfo` values whose note version does not match the `BundleVersion`, returning `BuildError::InvalidNoteVersion`. - - `orchard::builder::BundleType::Transactional` no longer embeds a full - `Flags`; it now carries `{ spends_enabled, outputs_enabled, bundle_required }`. - - `orchard::builder::BundleType::{num_actions, flags}` now take a - `BundleVersion`. For bundles that disable cross-address transfers, - `num_actions` counts `num_spends + num_outputs` requested actions (a requested - spend and a requested output never share an action) rather than the maximum of - the two, and `BundleMetadata` maps them to distinct actions; wallets - estimating fees (e.g. per ZIP 317) must account for the larger action count. + - `orchard::builder::BundleType` no longer carries any flag settings; it is now + just the construction policy, `Transactional { bundle_required }` or + `Coinbase`. The bundle's `Flags` are supplied separately to the builder. `flags` + are no longer derived from the bundle type, so `BundleType::flags` has been + removed. + - `orchard::builder::BundleType::num_actions` now takes a `Flags` (in place of a + `BundleVersion`), reading the spend/output/cross-address policy directly from it. + For bundles that disable cross-address transfers, `num_actions` counts + `num_spends + num_outputs` requested actions (a requested spend and a requested + output never share an action) rather than the maximum of the two, and + `BundleMetadata` maps them to distinct actions; wallets estimating fees (e.g. per + ZIP 317) must account for the larger action count. - `orchard::builder::OutputInfo::{new, dummy}` now take an `orchard::NoteVersion`, so callers choose between V2 Orchard notes and V3 Ironwood notes; builder-created outputs use the note version associated with the selected @@ -139,14 +151,13 @@ proving/verifying keys). (`Builder::add_output` returns `OutputError::CrossAddressDisabled`); retained shielded value must be added with `Builder::add_change_output`, which rejects a recipient not owned by the full viewing key (`OutputError::RecipientNotOwned`) - and requires spends to be enabled (`OutputError::SpendsDisabled`). The builder - chooses the cross-address bit as a prover-side default — the least-restrictive - value consensus permits: enabled, except for the Orchard pool under - `BundleVersion::orchard_v2()`, where consensus mandates the restriction. - `BundleVersion` exposes only that consensus constraint; the default lives in builder - logic. The `Flags` codec still represents NU6.3 `enableCrossAddress = 0` flag sets, so a - future builder could expose the choice where consensus leaves it free (e.g. Ironwood); - this branch does not. Coinbase bundles follow the same constraints as non-coinbase + and requires spends to be enabled (`OutputError::SpendsDisabled`). The cross-address + bit is now a caller-supplied flag rather than a builder-chosen default: + `BundleVersion::default_flags` returns the least-restrictive flag set consensus permits + — cross-address transfers enabled, except for the Orchard pool under + `BundleVersion::orchard_v2()`, where consensus mandates the restriction — and a caller + may restrict it further (a tighter choice the bundle version permits) before passing the + flags to the builder. Coinbase bundles follow the same constraints as non-coinbase bundles: post-NU6.3 Orchard coinbase transactions cannot contain Orchard actions, so post-NU6.3 coinbase bundle construction in this crate is only useful for `BundleVersion::ironwood_v2()`. @@ -269,7 +280,7 @@ proving/verifying keys). canonical proof-size check from the `BundleVersion` (enforced for every version except `BundleVersion::orchard_insecure_v0`). - `orchard::builder::Builder::new_for_version`; use - `Builder::new(bundle_version, bundle_type, anchor)`. + `Builder::new(bundle_type, bundle_version, flags, anchor)`. - `orchard::builder::bundle_for_version`; use `builder::bundle` with `BundleVersion` and a `Vec`. - Zero-argument `orchard::circuit::{ProvingKey, VerifyingKey}::build`; pass an diff --git a/benches/circuit.rs b/benches/circuit.rs index 3772c88eb..0a17e97b5 100644 --- a/benches/circuit.rs +++ b/benches/circuit.rs @@ -27,10 +27,12 @@ fn criterion_benchmark(c: &mut Criterion) { let create_bundle = |num_recipients| { let mut builder = Builder::new( - BundleVersion::orchard_v1(), BundleType::DEFAULT, + BundleVersion::orchard_v1(), + BundleVersion::orchard_v1().default_flags(), Anchor::from_bytes([0; 32]).unwrap(), - ); + ) + .unwrap(); for _ in 0..num_recipients { builder .add_output(None, recipient, NoteValue::from_raw(10), [0; 512]) diff --git a/benches/note_decryption.rs b/benches/note_decryption.rs index 0ac59ccd6..cad9201f6 100644 --- a/benches/note_decryption.rs +++ b/benches/note_decryption.rs @@ -46,10 +46,12 @@ fn bench_note_decryption(c: &mut Criterion) { let bundle = { let mut builder = Builder::new( - BundleVersion::orchard_v1(), BundleType::DEFAULT, + BundleVersion::orchard_v1(), + BundleVersion::orchard_v1().default_flags(), Anchor::from_bytes([0; 32]).unwrap(), - ); + ) + .unwrap(); // The builder pads to two actions, and shuffles their order. Add two recipients // so the first action is always decryptable. builder diff --git a/src/builder.rs b/src/builder.rs index 64da4de3a..5ae0c8d01 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -37,55 +37,36 @@ use { const MIN_ACTIONS: usize = 2; /// An enumeration of rules for Orchard bundle construction. +/// +/// This selects only the construction discipline; the bundle's [`Flags`] are supplied separately +/// to the builder (see [`Builder::new`] and [`BundleVersion::default_flags`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BundleType { /// A transactional bundle will be padded if necessary to contain at least 2 actions, /// irrespective of whether any genuine actions are required. Transactional { - /// Whether Orchard spends are enabled for the bundle. - spends_enabled: bool, - /// Whether Orchard outputs are enabled for the bundle. - outputs_enabled: bool, /// A flag that, when set to `true`, indicates that a bundle should be produced even if no /// spends or outputs have been added to the bundle; in such a circumstance, all of the /// actions in the resulting bundle will be dummies. bundle_required: bool, }, - /// A coinbase bundle disables nonzero-valued Orchard spends, and is built with - /// `Flags::from_parts(false, true, bundle_version.permits_cross_address_transfers())`: - /// spends disabled, outputs enabled, and cross-address transfers enabled if - /// possible for this bundle's version. No padding is performed. + /// A coinbase bundle performs no padding and requires the bundle's flags to disable spends. /// - /// Since coinbase transactions have `enableSpends = 0`, every spend must be a - /// dummy. Coinbase transactions are not otherwise any different wrt cross-address - /// restrictions from other transactions that have dummy inputs. For simplicity, we - /// therefore set `enableCrossAddress` in the same way we would for a non-coinbase - /// transaction (which is allowed by the consensus rules). + /// Since coinbase transactions have `enableSpends = 0`, every spend must be a dummy. Coinbase + /// transactions are not otherwise any different wrt cross-address restrictions from other + /// transactions that have dummy inputs. Coinbase, } impl BundleType { - /// The default bundle type enables spends and outputs, and does not require a bundle to be - /// produced if no spends or outputs have been added to the bundle. The builder defaults the - /// cross-address bit to the least-restrictive value that the consensus rules implied - /// by the bundle's pool restrictions will permit: enabled, unless those rules mandate - /// the restriction. + /// The default bundle type: a transactional bundle that is not required to be produced if no + /// spends or outputs have been added. pub const DEFAULT: BundleType = BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }; - - /// The DISABLED bundle type does not permit any bundle to be produced, and when used in the - /// builder will prevent any spends or outputs from being added. - pub const DISABLED: BundleType = BundleType::Transactional { - spends_enabled: false, - outputs_enabled: false, bundle_required: false, }; - /// Returns the number of logical actions that builder will produce in constructing a bundle - /// of this type, given the specified numbers of spends and outputs. + /// Returns the number of logical actions that the builder will produce in constructing a bundle + /// of this type with the given `flags`, given the specified numbers of spends and outputs. /// /// In the current implementation, for a bundle (regardless of type) that disables /// cross-address transfers, a requested spend and a requested output do not share an @@ -95,27 +76,22 @@ impl BundleType { /// count. /// /// Returns an error if the specified number of spends and outputs is incompatible with - /// this bundle type. + /// this bundle type and flags. /// /// [ZIP 317]: https://zips.z.cash/zip-0317 pub fn num_actions( &self, + flags: Flags, num_spends: usize, num_outputs: usize, - bundle_version: BundleVersion, ) -> Result { match self { - BundleType::Transactional { - spends_enabled, - outputs_enabled, - bundle_required, - } => { - let cross_address_enabled = bundle_version.permits_cross_address_transfers(); + BundleType::Transactional { bundle_required } => { // When cross-address transfers are disabled, every action's output is // addressed to the note it spends. For this implementation, a requested // spend and a requested output never share an action: each is paired with // a fabricated zero-valued counterpart instead. - let num_requested_actions = if !cross_address_enabled { + let num_requested_actions = if !flags.cross_address_enabled() { num_spends .checked_add(num_outputs) .ok_or("num_spends + num_outputs overflowed")? @@ -123,9 +99,9 @@ impl BundleType { core::cmp::max(num_spends, num_outputs) }; - if !*spends_enabled && num_spends > 0 { + if !flags.spends_enabled() && num_spends > 0 { Err("Spends are disabled, so num_spends must be zero") - } else if !*outputs_enabled && num_outputs > 0 { + } else if !flags.outputs_enabled() && num_outputs > 0 { Err("Outputs are disabled, so num_outputs must be zero") } else { Ok(if *bundle_required || num_requested_actions > 0 { @@ -144,32 +120,6 @@ impl BundleType { } } } - - /// Returns the set of flags that will be used for bundle construction under - /// `bundle_version`. - /// - /// As a prover-side default, the builder enables cross-address transfers whenever the version - /// permits them (the least restrictive policy consensus allows). This is the bundle's - /// `cross_address_enabled` flag, not bit 2 of the wire `flags` byte: in v5 transactions bit 2 - /// is reserved and always 0 regardless of epoch. - pub fn flags(&self, bundle_version: BundleVersion) -> Flags { - match self { - BundleType::Transactional { - spends_enabled, - outputs_enabled, - .. - } => Flags::from_parts( - *spends_enabled, - *outputs_enabled, - bundle_version.permits_cross_address_transfers(), - ), - BundleType::Coinbase => Flags::from_parts( - false, - true, - bundle_version.permits_cross_address_transfers(), - ), - } - } } /// An error type for the kinds of errors that can occur during bundle construction. @@ -203,6 +153,10 @@ pub enum BuildError { /// A supplied output or change output has a note version that is /// inconsistent with the bundle version. InvalidNoteVersion, + /// The builder's flags cannot be encoded under its [`BundleVersion`]. + UnrepresentableFlags, + /// A coinbase bundle was requested with flags that enable spends. + CoinbaseSpendsEnabled, } impl fmt::Display for BuildError { @@ -239,6 +193,12 @@ impl fmt::Display for BuildError { "A supplied output or change output has a note version that does not match \ the bundle version.", ), + UnrepresentableFlags => f.write_str( + "The requested flags cannot be encoded under the requested bundle version.", + ), + CoinbaseSpendsEnabled => { + f.write_str("A coinbase bundle was requested with flags that enable spends.") + } } } } @@ -762,31 +722,53 @@ impl BundleMetadata { /// to receive funds. #[derive(Debug)] pub struct Builder { + bundle_type: BundleType, + bundle_version: BundleVersion, + flags: Flags, spends: Vec, outputs: Vec, changes: Vec, - bundle_version: BundleVersion, - bundle_type: BundleType, anchor: Anchor, } impl Builder { - /// Constructs a new empty builder for an Orchard bundle following `bundle_version`. + /// Constructs a new empty builder for an Orchard bundle following `bundle_version` with the + /// given `flags`. /// - /// `bundle_version` is the information needed to determine restrictions on bundles - /// created by this builder: its shielded pool (Orchard or Ironwood) and the epoch range - /// (pre-NU6.2, NU6.2 only, NU6.3 onward) that it targets. It influences the circuit version, - /// the flag-byte format, and the cross-address policy, and is threaded into building, - /// committing, and parsing. See [`BundleVersion`]. - pub fn new(bundle_version: BundleVersion, bundle_type: BundleType, anchor: Anchor) -> Self { - Builder { + /// `bundle_version` is the [`ValuePool`](crate::ValuePool) (Orchard or Ironwood) and + /// [`ProtocolVersion`](crate::ProtocolVersion) of the bundles created by this builder. It + /// influences the circuit version, the flag-byte format, and the cross-address policy, and is + /// threaded into building, committing, and parsing. See [`BundleVersion`]. + /// + /// `flags` are the bundle's flags; [`BundleVersion::default_flags`] provides a suitable default + /// that a caller may restrict further. + /// + /// # Errors + /// + /// Returns [`BuildError::UnrepresentableFlags`] if `flags` cannot be encoded under + /// `bundle_version`, or [`BuildError::CoinbaseSpendsEnabled`] if `bundle_type` is + /// [`BundleType::Coinbase`] but `flags` enable spends. + pub fn new( + bundle_type: BundleType, + bundle_version: BundleVersion, + flags: Flags, + anchor: Anchor, + ) -> Result { + if flags.to_byte(bundle_version).is_none() { + return Err(BuildError::UnrepresentableFlags); + } + if matches!(bundle_type, BundleType::Coinbase) && flags.spends_enabled() { + return Err(BuildError::CoinbaseSpendsEnabled); + } + Ok(Builder { + bundle_type, + bundle_version, + flags, spends: vec![], outputs: vec![], changes: vec![], - bundle_version, - bundle_type, anchor, - } + }) } /// Returns the note version associated with this builder's bundle version. @@ -818,8 +800,7 @@ impl Builder { note: Note, merkle_path: MerklePath, ) -> Result<(), SpendError> { - let flags = self.bundle_type.flags(self.bundle_version); - if !flags.spends_enabled() { + if !self.flags.spends_enabled() { return Err(SpendError::SpendsDisabled); } @@ -847,11 +828,10 @@ impl Builder { value: NoteValue, memo: [u8; 512], ) -> Result<(), OutputError> { - let flags = self.bundle_type.flags(self.bundle_version); - if !flags.outputs_enabled() { + if !self.flags.outputs_enabled() { return Err(OutputError::OutputsDisabled); } - if !flags.cross_address_enabled() { + if !self.flags.cross_address_enabled() { return Err(OutputError::CrossAddressDisabled); } @@ -897,15 +877,14 @@ impl Builder { value: NoteValue, memo: [u8; 512], ) -> Result<(), OutputError> { - let flags = self.bundle_type.flags(self.bundle_version); - if !flags.outputs_enabled() { + if !self.flags.outputs_enabled() { return Err(OutputError::OutputsDisabled); } // In a bundle that disables cross-address transfers, every change output pairs with // a fabricated wallet-controlled spend, so spends must be enabled. (In a bundle that // permits cross-address transfers, a change output is just an owned output and does // not require spends.) - if !flags.cross_address_enabled() && !flags.spends_enabled() { + if !self.flags.cross_address_enabled() && !self.flags.spends_enabled() { return Err(OutputError::SpendsDisabled); } @@ -979,6 +958,7 @@ impl Builder { bundle( rng, self.bundle_version, + self.flags, self.anchor, self.bundle_type, self.spends, @@ -996,6 +976,7 @@ impl Builder { build_bundle( rng, self.bundle_version, + self.flags, self.anchor, self.bundle_type, self.spends, @@ -1031,10 +1012,18 @@ impl Builder { /// /// In a bundle that disables cross-address transfers, `outputs` must be empty (every output /// is addressed to the note it spends); retained value must be supplied as `changes`. +/// +/// # Errors +/// +/// Returns [`BuildError::UnrepresentableFlags`] if `flags` cannot be encoded under +/// `bundle_version`, or [`BuildError::CoinbaseSpendsEnabled`] if `bundle_type` is +/// [`BundleType::Coinbase`] but `flags` enable spends. +#[allow(clippy::too_many_arguments)] #[cfg(feature = "circuit")] pub fn bundle>( rng: impl RngCore, bundle_version: BundleVersion, + flags: Flags, anchor: Anchor, bundle_type: BundleType, spends: Vec, @@ -1044,6 +1033,7 @@ pub fn bundle>( build_bundle( rng, bundle_version, + flags, anchor, bundle_type, spends, @@ -1124,6 +1114,7 @@ fn finish_unauthorized_bundle, R: RngCore>( fn build_bundle( mut rng: R, bundle_version: BundleVersion, + flags: Flags, anchor: Anchor, bundle_type: BundleType, spends: Vec, @@ -1131,7 +1122,17 @@ fn build_bundle( changes: Vec, finisher: impl FnOnce(Vec, Flags, ValueSum, BundleMetadata, R) -> Result, ) -> Result { - let flags = bundle_type.flags(bundle_version); + // Every build path funnels through here (the free `bundle` function, `Builder::build`, and + // `Builder::build_for_pczt`), so validate the version-dependent invariants here rather than + // trusting each caller: the flags must be encodable under the bundle version, and a coinbase + // bundle must not enable spends. `Builder::new` also enforces both up front, for fail-fast + // construction. + if flags.to_byte(bundle_version).is_none() { + return Err(BuildError::UnrepresentableFlags); + } + if matches!(bundle_type, BundleType::Coinbase) && flags.spends_enabled() { + return Err(BuildError::CoinbaseSpendsEnabled); + } let note_version = bundle_version.note_version(); let num_requested_spends = spends.len(); @@ -1173,7 +1174,7 @@ fn build_bundle( } let num_actions = bundle_type - .num_actions(num_requested_spends, num_requested_outputs, bundle_version) + .num_actions(flags, num_requested_spends, num_requested_outputs) .map_err(|_| BuildError::BundleTypeNotSatisfiable)?; let (pre_actions, bundle_meta) = if !flags.cross_address_enabled() { @@ -1703,7 +1704,13 @@ pub mod testing { fn into_bundle>(mut self) -> Bundle { let fvk = FullViewingKey::from(&self.sk); let bundle_version = BundleVersion::orchard_v1(); - let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, self.anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), + self.anchor, + ) + .unwrap(); for (note, path) in self.notes.into_iter() { builder.add_spend(fvk.clone(), note, path).unwrap(); @@ -1808,7 +1815,7 @@ mod tests { }; use crate::{ builder::BundleType, - bundle::{Authorized, Bundle, BundleVersion}, + bundle::{Authorized, Bundle, BundleVersion, Flags}, circuit::{OrchardCircuitVersion, ProvingKey}, constants::MERKLE_DEPTH_ORCHARD, keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, @@ -1833,43 +1840,45 @@ mod tests { (note, merkle_path, anchor) } - fn restricted_bundle_type(bundle_required: bool) -> BundleType { - BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required, - } + fn transactional(bundle_required: bool) -> BundleType { + BundleType::Transactional { bundle_required } } #[test] - fn transactional_cross_address_defaults_to_least_restrictive() { - let bundle_type = BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }; - - // The builder defaults to the least-restrictive value consensus permits. Pool restrictions - // leave the choice free everywhere except Orchard post-NU6.3, so the default is enabled - // for every variant but that one. - - // Orchard pre-NU6.3: free, so the default is enabled. + fn default_flags_match_pool_policy() { + // The builder's default flags enable spends and outputs and leave cross-address transfers + // as permissive as consensus allows. The bundle version leaves the cross-address choice free + // everywhere except Orchard from NU6.3 onward, where it is mandatorily disabled. for bundle_version in [ BundleVersion::orchard_insecure_v0(), BundleVersion::orchard_v1(), + BundleVersion::ironwood_v2(), ] { - assert!(bundle_type.flags(bundle_version).cross_address_enabled()); + let flags = bundle_version.default_flags(); + assert!(flags.spends_enabled()); + assert!(flags.outputs_enabled()); + assert!(flags.cross_address_enabled()); } - // Orchard post-NU6.3: consensus mandates the restriction, so the default is disabled. - assert!(!bundle_type - .flags(BundleVersion::orchard_v2()) - .cross_address_enabled()); + // Orchard from NU6.3 onward mandates the cross-address restriction. + let flags = BundleVersion::orchard_v2().default_flags(); + assert!(flags.spends_enabled()); + assert!(flags.outputs_enabled()); + assert!(!flags.cross_address_enabled()); - // Ironwood post-NU6.3: free, so the default is enabled (currently built unrestricted). - assert!(bundle_type - .flags(BundleVersion::ironwood_v2()) - .cross_address_enabled()); + // The default flag bytes follow from the settings above. + assert_eq!( + BundleVersion::orchard_v2() + .default_flags() + .to_byte(BundleVersion::orchard_v2()), + Some(0b011), + ); + assert_eq!( + BundleVersion::ironwood_v2() + .default_flags() + .to_byte(BundleVersion::ironwood_v2()), + Some(0b111), + ); } /// Creates a builder with the given `bundle_version` and `bundle_type` over the @@ -1883,11 +1892,24 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); + // Coinbase bundles must disable spends; transactional bundles use the version's defaults. + let flags = if matches!(bundle_type, BundleType::Coinbase) { + Flags::from_parts( + false, + true, + bundle_version.permits_cross_address_transfers(), + ) + } else { + bundle_version.default_flags() + }; + let mut builder = Builder::new( - bundle_version, bundle_type, + bundle_version, + flags, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .expect("flags are valid for the bundle version"); builder .add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]) .expect("output-only builders accept ordinary outputs"); @@ -1941,38 +1963,71 @@ mod tests { } #[test] - fn coinbase_is_not_exceptional_for_cross_address() { - // Coinbase bundles always disable spends, but are otherwise no different wrt the - // cross-address restriction: they set `enableCrossAddress` exactly as a non-coinbase - // transaction would for the same pool restrictions. - for pr in [ - BundleVersion::orchard_insecure_v0(), - BundleVersion::orchard_v1(), - BundleVersion::orchard_v2(), - BundleVersion::ironwood_v2(), - ] { - let flags = BundleType::Coinbase.flags(pr); - assert!(!flags.spends_enabled()); - assert!(flags.outputs_enabled()); - assert_eq!( - flags.cross_address_enabled(), - pr.permits_cross_address_transfers() - ); - } - // Orchard post-NU6.3 mandates the restriction, so the coinbase flag byte has bit 2 - // clear; Ironwood leaves the builder default (set). - assert_eq!( - BundleType::Coinbase - .flags(BundleVersion::orchard_v2()) - .to_byte(BundleVersion::orchard_v2()), - Some(0b010) - ); - assert_eq!( - BundleType::Coinbase - .flags(BundleVersion::ironwood_v2()) - .to_byte(BundleVersion::ironwood_v2()), - Some(0b110) + fn coinbase_rejects_spends_enabled_flags() { + let anchor = EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(); + let bundle_version = BundleVersion::ironwood_v2(); + + // A coinbase bundle must disable spends; the builder rejects spends-enabled flags at + // construction rather than silently producing an invalid bundle. + assert!(matches!( + Builder::new( + BundleType::Coinbase, + bundle_version, + bundle_version.default_flags(), + anchor, + ), + Err(BuildError::CoinbaseSpendsEnabled) + )); + + // Spends-disabled flags are accepted. + assert!(Builder::new( + BundleType::Coinbase, + bundle_version, + Flags::from_parts( + false, + true, + bundle_version.permits_cross_address_transfers() + ), + anchor, + ) + .is_ok()); + } + + #[test] + fn free_bundle_rejects_coinbase_spends_enabled() { + let mut rng = OsRng; + let anchor: Anchor = EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(); + let bundle_version = BundleVersion::ironwood_v2(); + + // The coinbase-spends invariant is enforced on every build path, not just at + // `Builder::new`: a direct caller of the free `bundle` function cannot silently produce a + // coinbase bundle with `enableSpends` set. + let result = bundle::( + &mut rng, + bundle_version, + bundle_version.default_flags(), // spends enabled + anchor, + BundleType::Coinbase, + vec![], + vec![], + vec![], ); + assert!(matches!(result, Err(BuildError::CoinbaseSpendsEnabled))); + } + + #[test] + fn new_rejects_unrepresentable_flags() { + // Orchard from NU6.3 onward cannot encode cross-address-enabled flags. + let bundle_version = BundleVersion::orchard_v2(); + assert!(matches!( + Builder::new( + BundleType::DEFAULT, + bundle_version, + Flags::ENABLED, + EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), + ), + Err(BuildError::UnrepresentableFlags) + )); } #[test] @@ -1992,7 +2047,13 @@ mod tests { bundle_version.note_version(), ); - let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); + let mut builder = Builder::new( + transactional(false), + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); assert_eq!( builder.add_output( None, @@ -2085,10 +2146,12 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( + transactional(true), BundleVersion::orchard_v2(), - restricted_bundle_type(true), + BundleVersion::orchard_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_change_output(fvk, None, recipient, NoteValue::ZERO, [0u8; 512]) @@ -2128,8 +2191,9 @@ mod tests { bundle::( &mut rng, bundle_version, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - restricted_bundle_type(false), + transactional(false), vec![], vec![OutputInfo::new( None, @@ -2155,8 +2219,9 @@ mod tests { let (bundle, bundle_meta) = bundle::( &mut rng, bundle_version, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - restricted_bundle_type(false), + transactional(false), vec![], vec![], vec![change_output], @@ -2174,14 +2239,14 @@ mod tests { let sk = SpendingKey::random(&mut rng); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::Internal); - let bundle_type = BundleType::Transactional { - spends_enabled: false, - outputs_enabled: true, - bundle_required: false, - }; + let bundle_type = transactional(false); let bundle_version = BundleVersion::orchard_v2(); - // Under OrchardNu6_3Onward this is spends-disabled and cross-address-disabled. - let flags = bundle_type.flags(bundle_version); + // Under Orchard from NU6.3 onward this is spends-disabled and cross-address-disabled. + let flags = Flags::from_parts( + false, + true, + bundle_version.permits_cross_address_transfers(), + ); assert!(!flags.spends_enabled()); assert!(flags.outputs_enabled()); assert!(!flags.cross_address_enabled()); @@ -2200,6 +2265,7 @@ mod tests { bundle::( &mut rng, bundle_version, + flags, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), bundle_type, vec![], @@ -2225,7 +2291,13 @@ mod tests { NoteValue::from_raw(15_000), mismatched_note_version, ); - let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); assert_eq!(builder.add_spend(fvk.clone(), note, merkle_path), Ok(())); let (note, merkle_path, anchor) = note_with_path( @@ -2238,6 +2310,7 @@ mod tests { assert!(bundle::( &mut rng, bundle_version, + bundle_version.default_flags(), anchor, BundleType::DEFAULT, vec![spend], @@ -2257,6 +2330,7 @@ mod tests { bundle::( &mut rng, bundle_version, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), BundleType::DEFAULT, vec![], @@ -2279,6 +2353,7 @@ mod tests { bundle::( &mut rng, bundle_version, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), BundleType::DEFAULT, vec![], @@ -2298,14 +2373,12 @@ mod tests { FullViewingKey::from(&SpendingKey::random(&mut rng)).address_at(0u32, Scope::External); let bundle_version = BundleVersion::orchard_v1(); let mut builder = Builder::new( + BundleType::DEFAULT, bundle_version, - BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); // Even in a bundle that permits cross-address transfers, a change output's ownership // is validated eagerly (the fvk is no longer dead weight). @@ -2341,15 +2414,18 @@ mod tests { // Orchard actions at all post NU6.3. So the case unsupported by the builder would only // happen by voluntarily disabling `enableSpends` and/or `enableCrossAddress` when // consensus does not require it. + let bundle_version = BundleVersion::orchard_v2(); let mut builder = Builder::new( - BundleVersion::orchard_v2(), - BundleType::Transactional { - spends_enabled: false, - outputs_enabled: true, - bundle_required: false, - }, + transactional(false), + bundle_version, + Flags::from_parts( + false, + true, + bundle_version.permits_cross_address_transfers(), + ), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); assert_eq!( builder.add_change_output(fvk, None, recipient, NoteValue::from_raw(5_000), [0u8; 512]), @@ -2374,7 +2450,13 @@ mod tests { bundle_version.note_version(), ); - let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); + let mut builder = Builder::new( + transactional(false), + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); builder .add_change_output( @@ -2411,10 +2493,12 @@ mod tests { // A change-only bundle: the padding dummy spend is signed during `prepare`, so // a single `sign` call with the change key completes the actions. let mut builder = Builder::new( + transactional(false), BundleVersion::orchard_v2(), - restricted_bundle_type(false), + BundleVersion::orchard_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_change_output( change_fvk, @@ -2456,7 +2540,13 @@ mod tests { bundle_version.note_version(), ); - let mut builder = Builder::new(bundle_version, restricted_bundle_type(false), anchor); + let mut builder = Builder::new( + transactional(false), + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); builder .add_change_output( @@ -2502,10 +2592,12 @@ mod tests { // `circuit::tests::restricted_statement_requires_supporting_key`. let build_restricted = |rng: &mut OsRng| { Builder::new( + transactional(true), BundleVersion::orchard_v2(), - restricted_bundle_type(true), + BundleVersion::orchard_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) + .unwrap() .build::(rng) .unwrap() .unwrap() diff --git a/src/bundle.rs b/src/bundle.rs index 29d9a5ba9..916140e86 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -164,6 +164,18 @@ impl BundleVersion { ) } + /// The default [`Flags`] for a bundle of this version: spends and outputs enabled, with the + /// cross-address bit set to the least-restrictive value the version permits (enabled unless + /// the version mandates the restriction). + /// + /// This is the prover-side default a builder uses when the caller does not restrict the bundle + /// further. Where the version leaves the cross-address choice free (e.g. the Ironwood pool), a + /// caller may instead pass a more restricted flag set such as + /// [`Flags::CROSS_ADDRESS_DISABLED`]; the chosen flags must be representable under the version. + pub fn default_flags(&self) -> Flags { + Flags::from_parts(true, true, self.permits_cross_address_transfers()) + } + /// Whether an authorized bundle of this version must carry a canonically-sized proof. /// /// The historical pre-NU6.2 Orchard pool ([`ProtocolVersion::InsecureV0`]) is only used to diff --git a/src/pczt.rs b/src/pczt.rs index 82cf4cc31..ce6959091 100644 --- a/src/pczt.rs +++ b/src/pczt.rs @@ -424,14 +424,12 @@ mod tests { let anchor = merkle_path.root(note.commitment().into()); let mut builder = Builder::new( + BundleType::DEFAULT, bundle_version, - BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }, + bundle_version.default_flags(), anchor, - ); + ) + .unwrap(); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); builder .add_change_output( @@ -459,10 +457,12 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundleVersion::orchard_v1(), BundleType::DEFAULT, + BundleVersion::orchard_v1(), + BundleVersion::orchard_v1().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]) .unwrap(); @@ -478,10 +478,12 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundleVersion::ironwood_v2(), BundleType::DEFAULT, + BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]) .unwrap(); @@ -505,10 +507,12 @@ mod tests { // Run the Creator and Constructor roles. let mut builder = Builder::new( - bundle_version, BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]) .unwrap(); @@ -564,10 +568,12 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( - BundleVersion::ironwood_v2(), BundleType::DEFAULT, + BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - ); + ) + .unwrap(); builder .add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]) .unwrap(); @@ -656,7 +662,13 @@ mod tests { }; let bundle_version = BundleVersion::ironwood_v2(); - let mut builder = Builder::new(bundle_version, BundleType::DEFAULT, anchor); + let mut builder = Builder::new( + BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); builder .add_spend(fvk.clone(), note, merkle_path.into()) .unwrap(); @@ -749,7 +761,14 @@ mod tests { }; // Run the Creator and Constructor roles. - let mut builder = Builder::new(BundleVersion::orchard_v1(), BundleType::DEFAULT, anchor); + let bundle_version = BundleVersion::orchard_v1(); + let mut builder = Builder::new( + BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), + anchor, + ) + .unwrap(); builder .add_spend(fvk.clone(), note, merkle_path.into()) .unwrap(); @@ -1088,15 +1107,14 @@ mod tests { let merkle_path = MerklePath::dummy(&mut rng); let anchor = merkle_path.root(note.commitment().into()); + let bundle_version = BundleVersion::orchard_v2(); let mut builder = Builder::new( - BundleVersion::orchard_v2(), - BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }, + BundleType::DEFAULT, + bundle_version, + bundle_version.default_flags(), anchor, - ); + ) + .unwrap(); builder.add_spend(spend_fvk, note, merkle_path).unwrap(); let (mut pczt_bundle, bundle_meta) = builder.build_for_pczt(&mut rng).unwrap(); diff --git a/tests/builder.rs b/tests/builder.rs index a731afdbd..11020b01a 100644 --- a/tests/builder.rs +++ b/tests/builder.rs @@ -3,7 +3,7 @@ use incrementalmerkletree::{Hashable, Marking, Retention}; use orchard::{ builder::{Builder, BundleType}, - bundle::{Authorized, BatchValidator, BundleVersion, TxVersion}, + bundle::{Authorized, BatchValidator, BundleVersion, Flags, TxVersion}, circuit::{OrchardCircuitVersion, ProvingKey, VerifyingKey}, keys::{FullViewingKey, PreparedIncomingViewingKey, Scope, SpendAuthorizingKey, SpendingKey}, note::{ExtractedNoteCommitment, NoteVersion}, @@ -56,22 +56,22 @@ fn verify_bundle(bundle: &Bundle, vk: &VerifyingKey, tx_version ); } -/// The output-only bundle type used by the shielding steps of these tests. -const SHIELDING: BundleType = BundleType::Transactional { - spends_enabled: false, - outputs_enabled: true, - bundle_required: false, -}; +/// The flags used by the output-only (shielding and coinbase) steps of these tests: spends +/// disabled, outputs enabled, cross-address transfers enabled. Every output-only bundle here +/// targets a pool that permits cross-address transfers (Orchard pre-NU6.3 and Ironwood). +const SHIELDING_FLAGS: Flags = Flags::SPENDS_DISABLED; /// Creates a builder of the given `bundle_version` and `bundle_type` over the -/// empty-tree anchor, with a single 5000-zat output to `recipient`. +/// empty-tree anchor, with a single 5000-zat output to `recipient`. The builder disables +/// spends, since these helpers build output-only (shielding or coinbase) bundles. fn output_only_builder( bundle_version: BundleVersion, bundle_type: BundleType, recipient: Address, ) -> Builder { let anchor = MerkleHashOrchard::empty_root(32.into()).into(); - let mut builder = Builder::new(bundle_version, bundle_type, anchor); + let mut builder = Builder::new(bundle_type, bundle_version, SHIELDING_FLAGS, anchor) + .expect("shielding flags are valid for the bundle version"); assert_eq!( builder.add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]), Ok(()) @@ -91,7 +91,8 @@ fn bundle_chain() { // Create a shielding bundle. let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder(BundleVersion::orchard_v1(), SHIELDING, recipient); + let builder = + output_only_builder(BundleVersion::orchard_v1(), BundleType::DEFAULT, recipient); let (unauthorized, bundle_meta) = builder.build(&mut rng).unwrap().unwrap(); assert_eq!( @@ -134,10 +135,12 @@ fn bundle_chain() { let (root, merkle_path) = single_leaf_witness(&cmx); let mut builder = Builder::new( - BundleVersion::orchard_v1(), BundleType::DEFAULT, + BundleVersion::orchard_v1(), + BundleVersion::orchard_v1().default_flags(), root.into(), - ); + ) + .unwrap(); assert_eq!(builder.add_spend(fvk, note, merkle_path), Ok(())); assert_eq!( builder.add_output(None, recipient, NoteValue::from_raw(5000), [0u8; 512]), @@ -172,7 +175,11 @@ fn builder_builds_for_insecure_circuit_version() { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let builder = output_only_builder(BundleVersion::orchard_insecure_v0(), SHIELDING, recipient); + let builder = output_only_builder( + BundleVersion::orchard_insecure_v0(), + BundleType::DEFAULT, + recipient, + ); let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); let sighash: [u8; 32] = unauthorized @@ -196,7 +203,7 @@ fn builder_builds_for_post_nu6_3_circuit_version() { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); + let builder = output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); assert_eq!( @@ -222,7 +229,7 @@ fn ironwood_builder_outputs_decrypt_with_ironwood_domain() { let recipient = fvk.address_at(0u32, Scope::External); let ivk = PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::External)); - let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); + let builder = output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); let (bundle, bundle_meta) = builder.build::(&mut rng).unwrap().unwrap(); let action = &bundle.actions()[bundle_meta .output_action_index(0) @@ -252,7 +259,8 @@ fn ironwood_bundle_helpers_decrypt_and_recover_outputs() { let bundle_version = BundleVersion::ironwood_v2(); let anchor = MerkleHashOrchard::empty_root(32.into()).into(); - let mut builder = Builder::new(bundle_version, SHIELDING, anchor); + let mut builder = Builder::new(BundleType::DEFAULT, bundle_version, SHIELDING_FLAGS, anchor) + .expect("shielding flags are valid for the bundle version"); assert_eq!( builder.add_output( Some(ovk.clone()), @@ -352,7 +360,8 @@ fn post_nu6_3_restricted_bundle_chain() { let recipient = fvk.address_at(0u32, Scope::External); let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder(BundleVersion::orchard_v1(), SHIELDING, recipient); + let builder = + output_only_builder(BundleVersion::orchard_v1(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized @@ -381,14 +390,12 @@ fn post_nu6_3_restricted_bundle_chain() { let (root, merkle_path) = single_leaf_witness(&cmx); let mut builder = Builder::new( + BundleType::DEFAULT, BundleVersion::orchard_v2(), - BundleType::Transactional { - spends_enabled: true, - outputs_enabled: true, - bundle_required: false, - }, + BundleVersion::orchard_v2().default_flags(), root.into(), - ); + ) + .unwrap(); assert_eq!(builder.add_spend(fvk.clone(), note, merkle_path), Ok(())); assert_eq!( builder.add_change_output( @@ -489,7 +496,8 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { // Shield a note to spend (an unrestricted, output-only post-NU6.3 bundle). let shielding_bundle: Bundle<_, i64> = { - let builder = output_only_builder(BundleVersion::ironwood_v2(), SHIELDING, recipient); + let builder = + output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized .commitment(TxVersion::V6) @@ -518,10 +526,12 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { // cross-address transfer, which Ironwood permits but post-NU6.3 Orchard would forbid. let change_addr = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( - BundleVersion::ironwood_v2(), BundleType::DEFAULT, + BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v2().default_flags(), root.into(), - ); + ) + .unwrap(); assert_eq!(builder.add_spend(fvk.clone(), note, merkle_path), Ok(())); assert_eq!( builder.add_output(None, change_addr, NoteValue::from_raw(5000), [0u8; 512]), From cbb6ed1d5cc1d7ec941ffe90763114757636539a Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Mon, 29 Jun 2026 11:58:44 -0600 Subject: [PATCH 3/5] Fix private intra-doc link in Bundle::try_from_parts docs The `try_from_parts` doc linked to the `pub(crate)` `BundleVersion::enforces_canonical_proof_size`, which trips `rustdoc::private_intra_doc_links` (an error under `-D warnings`). Point at the public `BundleVersion::orchard_insecure_v0` constructor instead: it builds the sole bundle version whose proof size is not enforced, so it is the more useful public reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bundle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundle.rs b/src/bundle.rs index 916140e86..5fb3954b9 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -922,7 +922,7 @@ impl Bundle { /// Constructs an authorized `Bundle` from its constituent parts. /// /// This is the only constructor for an authorized bundle. For every version except the - /// historical pre-NU6.2 Orchard pool (see [`BundleVersion::enforces_canonical_proof_size`]) it + /// historical pre-NU6.2 Orchard pool ([`BundleVersion::orchard_insecure_v0`]) it /// validates that the proof has exactly [`Proof::expected_proof_size`] bytes for /// `actions.len()`, so such an authorized bundle can never hold a non-canonical proof. This /// matters when building a bundle from untrusted input (e.g. deserializing from bytes), as it From 2f322a220d2750c3fa752132f51594b8358f653f Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Mon, 29 Jun 2026 15:28:11 -0600 Subject: [PATCH 4/5] Use 1-based version numbering for ProtocolVersion and BundleVersion Rename the `ProtocolVersion` variants and `BundleVersion` constructors from 0-based to 1-based version numbers, at the protocol developers' request: ProtocolVersion::InsecureV0 -> InsecureV1 ProtocolVersion::V1 -> V2 ProtocolVersion::V2 -> V3 BundleVersion::orchard_insecure_v0 -> orchard_insecure_v1 BundleVersion::orchard_v1 -> orchard_v2 BundleVersion::orchard_v2 -> orchard_v3 BundleVersion::ironwood_v2 -> ironwood_v3 This is a pure rename with no change in behavior; each version keeps the same mapping to consensus epochs, circuit versions, and note plaintext versions. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 22 ++--- benches/circuit.rs | 4 +- benches/note_decryption.rs | 4 +- src/builder.rs | 58 ++++++------- src/bundle.rs | 162 ++++++++++++++++++------------------- src/circuit.rs | 4 +- src/lib.rs | 6 +- src/pczt.rs | 46 +++++------ tests/builder.rs | 36 ++++----- 9 files changed, 171 insertions(+), 171 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0d872ea..7bf97ed73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,18 +14,18 @@ non-serialized context, so a bundle can be serialized and committed to without s supplying a — possibly mismatching — version, and is encodable and committable by construction. The post-NU 6.3 Action circuit enforces the cross-address restriction. Existing callers keep the current behavior by constructing bundles with -`BundleVersion::orchard_v1()` and `BundleVersion::orchard_v1().default_flags()` (and +`BundleVersion::orchard_v2()` and `BundleVersion::orchard_v2().default_flags()` (and `OrchardCircuitVersion::FixedPostNu6_2` when building proving/verifying keys). ### Added - NU6.3 and Ironwood bundle-version APIs: - `orchard::ValuePool`, the value pool an Orchard bundle belongs to (`Orchard` or `Ironwood`), and `orchard::ProtocolVersion`, the Orchard protocol version - (`InsecureV0`, the historical pre-NU6.2 protocol that uses the unsound circuit; `V1`, - NU6.2; `V2`, NU6.3, which also instantiates the Ironwood pool). + (`InsecureV1`, the historical pre-NU6.2 protocol that uses the unsound circuit; `V2`, + NU6.2; `V3`, NU6.3, which also instantiates the Ironwood pool). - `orchard::bundle::BundleVersion`, the `(value pool, protocol version)` of an Orchard - bundle. Its `const fn` constructors `orchard_insecure_v0`, `orchard_v1`, `orchard_v2`, - and `ironwood_v2` make only the valid combinations representable. It determines the + bundle. Its `const fn` constructors `orchard_insecure_v1`, `orchard_v2`, `orchard_v3`, + and `ironwood_v3` make only the valid combinations representable. It determines the note plaintext version (`BundleVersion::note_version`), the circuit version (`BundleVersion::circuit_version`, when the `circuit` feature is enabled), the flag-byte interpretation (pre-NU6.3 rules, where bit 2 is reserved and cross-address @@ -33,7 +33,7 @@ Existing callers keep the current behavior by constructing bundles with and whether consensus mandates the cross-address restriction (the builder then chooses the value within that constraint). `BundleVersion::value_pool` and `BundleVersion::protocol_version` return the bundle's `ValuePool` and - `ProtocolVersion`; the Ironwood pool (`ironwood_v2`) shares the post-NU6.3 circuit and + `ProtocolVersion`; the Ironwood pool (`ironwood_v3`) shares the post-NU6.3 circuit and uses V3 note plaintexts. `BundleVersion::default_flags` returns the least-restrictive `Flags` consensus permits under the bundle version (spends and outputs enabled, cross-address transfers enabled except where the version mandates the restriction), @@ -141,7 +141,7 @@ Existing callers keep the current behavior by constructing bundles with `BundleVersion`. - `orchard::builder::BundleMetadata::output_action_index` now indexes the plain outputs first, followed by the wallet-controlled change outputs. -- For `BundleVersion::orchard_v2()`, the builder constructs +- For `BundleVersion::orchard_v3()`, the builder constructs withdrawal/change bundles that disable cross-address transfers: every action's output is addressed to the expanded receiver of the note it spends. The fabricated zero-value output paired with each real spend carries a randomized, @@ -155,12 +155,12 @@ Existing callers keep the current behavior by constructing bundles with bit is now a caller-supplied flag rather than a builder-chosen default: `BundleVersion::default_flags` returns the least-restrictive flag set consensus permits — cross-address transfers enabled, except for the Orchard pool under - `BundleVersion::orchard_v2()`, where consensus mandates the restriction — and a caller + `BundleVersion::orchard_v3()`, where consensus mandates the restriction — and a caller may restrict it further (a tighter choice the bundle version permits) before passing the flags to the builder. Coinbase bundles follow the same constraints as non-coinbase bundles: post-NU6.3 Orchard coinbase transactions cannot contain Orchard actions, so post-NU6.3 coinbase bundle construction in this crate is only useful for - `BundleVersion::ironwood_v2()`. + `BundleVersion::ironwood_v3()`. - `orchard::bundle::Flags::{to_byte, from_byte}` now take a `BundleVersion`. Bit 2 (`enableCrossAddress`) is only representable for the Ironwood pool post-NU6.3; it is rejected for pre-NU6.3 (where bit 2 is @@ -204,7 +204,7 @@ Existing callers keep the current behavior by constructing bundles with - `try_from_parts` no longer takes an `orchard::bundle::ProofSizeEnforcement`: the canonical proof-size check (GHSA-2x4w-pxqw-58v9) is derived from the bundle version, enforced for every version except the historical pre-NU6.2 Orchard pool - (`BundleVersion::orchard_insecure_v0`), whose already-committed transactions may carry + (`BundleVersion::orchard_insecure_v1`), whose already-committed transactions may carry non-canonical proofs. - Circuit APIs now require explicit circuit versions: - `orchard::circuit::Circuit::from_action_context` now takes an @@ -278,7 +278,7 @@ Existing callers keep the current behavior by constructing bundles with ### Removed - `orchard::bundle::ProofSizeEnforcement`; `Bundle::try_from_parts` now derives the canonical proof-size check from the `BundleVersion` (enforced for every version except - `BundleVersion::orchard_insecure_v0`). + `BundleVersion::orchard_insecure_v1`). - `orchard::builder::Builder::new_for_version`; use `Builder::new(bundle_type, bundle_version, flags, anchor)`. - `orchard::builder::bundle_for_version`; use `builder::bundle` with diff --git a/benches/circuit.rs b/benches/circuit.rs index 0a17e97b5..b83958978 100644 --- a/benches/circuit.rs +++ b/benches/circuit.rs @@ -28,8 +28,8 @@ fn criterion_benchmark(c: &mut Criterion) { let create_bundle = |num_recipients| { let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::orchard_v1(), - BundleVersion::orchard_v1().default_flags(), + BundleVersion::orchard_v2(), + BundleVersion::orchard_v2().default_flags(), Anchor::from_bytes([0; 32]).unwrap(), ) .unwrap(); diff --git a/benches/note_decryption.rs b/benches/note_decryption.rs index cad9201f6..44db99f25 100644 --- a/benches/note_decryption.rs +++ b/benches/note_decryption.rs @@ -47,8 +47,8 @@ fn bench_note_decryption(c: &mut Criterion) { let bundle = { let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::orchard_v1(), - BundleVersion::orchard_v1().default_flags(), + BundleVersion::orchard_v2(), + BundleVersion::orchard_v2().default_flags(), Anchor::from_bytes([0; 32]).unwrap(), ) .unwrap(); diff --git a/src/builder.rs b/src/builder.rs index 5ae0c8d01..74db89d21 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1703,7 +1703,7 @@ pub mod testing { /// Create a bundle from the set of arbitrary bundle inputs. fn into_bundle>(mut self) -> Bundle { let fvk = FullViewingKey::from(&self.sk); - let bundle_version = BundleVersion::orchard_v1(); + let bundle_version = BundleVersion::orchard_v2(); let mut builder = Builder::new( BundleType::DEFAULT, bundle_version, @@ -1850,9 +1850,9 @@ mod tests { // as permissive as consensus allows. The bundle version leaves the cross-address choice free // everywhere except Orchard from NU6.3 onward, where it is mandatorily disabled. for bundle_version in [ - BundleVersion::orchard_insecure_v0(), - BundleVersion::orchard_v1(), - BundleVersion::ironwood_v2(), + BundleVersion::orchard_insecure_v1(), + BundleVersion::orchard_v2(), + BundleVersion::ironwood_v3(), ] { let flags = bundle_version.default_flags(); assert!(flags.spends_enabled()); @@ -1861,22 +1861,22 @@ mod tests { } // Orchard from NU6.3 onward mandates the cross-address restriction. - let flags = BundleVersion::orchard_v2().default_flags(); + let flags = BundleVersion::orchard_v3().default_flags(); assert!(flags.spends_enabled()); assert!(flags.outputs_enabled()); assert!(!flags.cross_address_enabled()); // The default flag bytes follow from the settings above. assert_eq!( - BundleVersion::orchard_v2() + BundleVersion::orchard_v3() .default_flags() - .to_byte(BundleVersion::orchard_v2()), + .to_byte(BundleVersion::orchard_v3()), Some(0b011), ); assert_eq!( - BundleVersion::ironwood_v2() + BundleVersion::ironwood_v3() .default_flags() - .to_byte(BundleVersion::ironwood_v2()), + .to_byte(BundleVersion::ironwood_v3()), Some(0b111), ); } @@ -1922,7 +1922,7 @@ mod tests { let mut rng = OsRng; let builder = - output_only_builder(&mut rng, BundleVersion::orchard_v1(), BundleType::DEFAULT); + output_only_builder(&mut rng, BundleVersion::orchard_v2(), BundleType::DEFAULT); let balance: i64 = builder.value_balance().unwrap(); assert_eq!(balance, -5000); @@ -1949,7 +1949,7 @@ mod tests { // be an Ironwood bundle. There the builder leaves cross-address enabled by default, // and therefore ordinary outputs build normally. let builder = - output_only_builder(&mut rng, BundleVersion::ironwood_v2(), BundleType::Coinbase); + output_only_builder(&mut rng, BundleVersion::ironwood_v3(), BundleType::Coinbase); let (bundle, _) = builder .build::(&mut rng) @@ -1965,7 +1965,7 @@ mod tests { #[test] fn coinbase_rejects_spends_enabled_flags() { let anchor = EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(); - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); // A coinbase bundle must disable spends; the builder rejects spends-enabled flags at // construction rather than silently producing an invalid bundle. @@ -1997,7 +1997,7 @@ mod tests { fn free_bundle_rejects_coinbase_spends_enabled() { let mut rng = OsRng; let anchor: Anchor = EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(); - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); // The coinbase-spends invariant is enforced on every build path, not just at // `Builder::new`: a direct caller of the free `bundle` function cannot silently produce a @@ -2018,7 +2018,7 @@ mod tests { #[test] fn new_rejects_unrepresentable_flags() { // Orchard from NU6.3 onward cannot encode cross-address-enabled flags. - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); assert!(matches!( Builder::new( BundleType::DEFAULT, @@ -2039,7 +2039,7 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, @@ -2147,8 +2147,8 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( transactional(true), - BundleVersion::orchard_v2(), - BundleVersion::orchard_v2().default_flags(), + BundleVersion::orchard_v3(), + BundleVersion::orchard_v3().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap(); @@ -2185,7 +2185,7 @@ mod tests { let sk = SpendingKey::random(&mut rng); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); assert!(matches!( bundle::( @@ -2240,7 +2240,7 @@ mod tests { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::Internal); let bundle_type = transactional(false); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); // Under Orchard from NU6.3 onward this is spends-disabled and cross-address-disabled. let flags = Flags::from_parts( false, @@ -2282,7 +2282,7 @@ mod tests { let sk = SpendingKey::random(&mut rng); let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let mismatched_note_version = NoteVersion::V2; let (note, merkle_path, anchor) = note_with_path( @@ -2371,7 +2371,7 @@ mod tests { let owned = fvk.address_at(0u32, Scope::Internal); let foreign = FullViewingKey::from(&SpendingKey::random(&mut rng)).address_at(0u32, Scope::External); - let bundle_version = BundleVersion::orchard_v1(); + let bundle_version = BundleVersion::orchard_v2(); let mut builder = Builder::new( BundleType::DEFAULT, bundle_version, @@ -2414,7 +2414,7 @@ mod tests { // Orchard actions at all post NU6.3. So the case unsupported by the builder would only // happen by voluntarily disabling `enableSpends` and/or `enableCrossAddress` when // consensus does not require it. - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let mut builder = Builder::new( transactional(false), bundle_version, @@ -2442,7 +2442,7 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, @@ -2494,8 +2494,8 @@ mod tests { // a single `sign` call with the change key completes the actions. let mut builder = Builder::new( transactional(false), - BundleVersion::orchard_v2(), - BundleVersion::orchard_v2().default_flags(), + BundleVersion::orchard_v3(), + BundleVersion::orchard_v3().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap(); @@ -2532,7 +2532,7 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let (note, merkle_path, anchor) = note_with_path( &mut rng, spend_recipient, @@ -2584,7 +2584,7 @@ mod tests { #[test] fn create_proof_supports_cross_address_disabled_only_for_post_nu6_3() { - // A cross-address-disabled bundle can only be built under `BundleVersion::orchard_v2()` + // A cross-address-disabled bundle can only be built under `BundleVersion::orchard_v3()` // (`BundleVersion` owns the cross-address policy), which builds post-NU6.3 // circuits. Proving therefore requires a matching post-NU6.3 key; a pre-NU6.3 key // is rejected as a circuit-version mismatch. The lower-level interlock that rejects @@ -2593,8 +2593,8 @@ mod tests { let build_restricted = |rng: &mut OsRng| { Builder::new( transactional(true), - BundleVersion::orchard_v2(), - BundleVersion::orchard_v2().default_flags(), + BundleVersion::orchard_v3(), + BundleVersion::orchard_v3().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap() diff --git a/src/bundle.rs b/src/bundle.rs index 5fb3954b9..1fc4eb57b 100644 --- a/src/bundle.rs +++ b/src/bundle.rs @@ -53,7 +53,7 @@ impl Action { /// A shielded [`ValuePool`] (Orchard or Ironwood) together with the [`ProtocolVersion`] /// under which a bundle in that pool is built. The Ironwood pool only exists from NU6.3 -/// onward, so it is only valid in combination with [`ProtocolVersion::V2`]. +/// onward, so it is only valid in combination with [`ProtocolVersion::V3`]. /// /// This pins the `circuit_version` and the flag-byte format, and determines whether /// cross-address transfers are permitted (`permits_cross_address_transfers`). @@ -77,38 +77,38 @@ pub struct BundleVersion { impl BundleVersion { /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under - /// [`ProtocolVersion::InsecureV0`] (the Orchard pool prior to NU6.2). - pub const fn orchard_insecure_v0() -> Self { + /// [`ProtocolVersion::InsecureV1`] (the Orchard pool prior to NU6.2). + pub const fn orchard_insecure_v1() -> Self { Self { value_pool: ValuePool::Orchard, - protocol_version: ProtocolVersion::InsecureV0, + protocol_version: ProtocolVersion::InsecureV1, } } /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under - /// [`ProtocolVersion::V1`] (the Orchard pool from NU6.2 until NU6.3). - pub const fn orchard_v1() -> Self { + /// [`ProtocolVersion::V2`] (the Orchard pool from NU6.2 until NU6.3). + pub const fn orchard_v2() -> Self { Self { value_pool: ValuePool::Orchard, - protocol_version: ProtocolVersion::V1, + protocol_version: ProtocolVersion::V2, } } /// The [`BundleVersion`] for the [`ValuePool::Orchard`] pool under - /// [`ProtocolVersion::V2`] (the Orchard pool at NU6.3 and later). - pub const fn orchard_v2() -> Self { + /// [`ProtocolVersion::V3`] (the Orchard pool at NU6.3 and later). + pub const fn orchard_v3() -> Self { Self { value_pool: ValuePool::Orchard, - protocol_version: ProtocolVersion::V2, + protocol_version: ProtocolVersion::V3, } } /// The [`BundleVersion`] for the [`ValuePool::Ironwood`] pool under - /// [`ProtocolVersion::V2`] (the Ironwood pool, introduced at NU6.3). - pub const fn ironwood_v2() -> Self { + /// [`ProtocolVersion::V3`] (the Ironwood pool, introduced at NU6.3). + pub const fn ironwood_v3() -> Self { Self { value_pool: ValuePool::Ironwood, - protocol_version: ProtocolVersion::V2, + protocol_version: ProtocolVersion::V3, } } @@ -126,15 +126,15 @@ impl BundleVersion { /// with this bundle version. /// /// This is many-to-one: both the [`ValuePool::Orchard`] and [`ValuePool::Ironwood`] pools - /// under [`ProtocolVersion::V2`] share the post-NU6.3 circuit, so build a key with + /// under [`ProtocolVersion::V3`] share the post-NU6.3 circuit, so build a key with /// `ProvingKey::build(bundle_version.circuit_version())` / /// `VerifyingKey::build(bundle_version.circuit_version())`. #[cfg(feature = "circuit")] pub fn circuit_version(&self) -> OrchardCircuitVersion { match self.protocol_version { - ProtocolVersion::InsecureV0 => OrchardCircuitVersion::InsecurePreNu6_2, - ProtocolVersion::V1 => OrchardCircuitVersion::FixedPostNu6_2, - ProtocolVersion::V2 => OrchardCircuitVersion::PostNu6_3, + ProtocolVersion::InsecureV1 => OrchardCircuitVersion::InsecurePreNu6_2, + ProtocolVersion::V2 => OrchardCircuitVersion::FixedPostNu6_2, + ProtocolVersion::V3 => OrchardCircuitVersion::PostNu6_3, } } @@ -153,14 +153,14 @@ impl BundleVersion { /// action (`enableCrossAddress = 1` in a v6 transaction). /// /// Every version permits them except the [`ValuePool::Orchard`] pool under - /// [`ProtocolVersion::V2`], which mandates the cross-address restriction. This is not + /// [`ProtocolVersion::V3`], which mandates the cross-address restriction. This is not /// necessarily the same cross-address-enabled decision the /// [`Builder`](crate::builder::Builder) makes; that is builder policy chosen within this /// constraint. pub(crate) fn permits_cross_address_transfers(&self) -> bool { !matches!( (self.protocol_version, self.value_pool), - (ProtocolVersion::V2, ValuePool::Orchard) + (ProtocolVersion::V3, ValuePool::Orchard) ) } @@ -178,12 +178,12 @@ impl BundleVersion { /// Whether an authorized bundle of this version must carry a canonically-sized proof. /// - /// The historical pre-NU6.2 Orchard pool ([`ProtocolVersion::InsecureV0`]) is only used to + /// The historical pre-NU6.2 Orchard pool ([`ProtocolVersion::InsecureV1`]) is only used to /// parse already-committed transactions, whose proofs cannot be re-canonicalized, so its /// proof size is not enforced. Every later version requires a canonical proof, rejecting /// non-canonical (e.g. padded) proofs (GHSA-2x4w-pxqw-58v9). pub(crate) fn enforces_canonical_proof_size(&self) -> bool { - !matches!(self.protocol_version, ProtocolVersion::InsecureV0) + !matches!(self.protocol_version, ProtocolVersion::InsecureV1) } } @@ -352,26 +352,26 @@ impl Flags { // `cross_address_enabled` value is not representable in this context cannot be encoded. match (bundle_version.value_pool, bundle_version.protocol_version) { // Cross-address Orchard pool transfers are always permitted prior to - // ProtocolVersion::V2; there is no flag bit, so a disabled flag set cannot be + // ProtocolVersion::V3; there is no flag bit, so a disabled flag set cannot be // represented. - (ValuePool::Orchard, ProtocolVersion::InsecureV0 | ProtocolVersion::V1) => { + (ValuePool::Orchard, ProtocolVersion::InsecureV1 | ProtocolVersion::V2) => { if !self.cross_address_enabled { return None; } } - // Cross-address Orchard pool transfers are disallowed in ProtocolVersion::V2. - (ValuePool::Orchard, ProtocolVersion::V2) => { + // Cross-address Orchard pool transfers are disallowed in ProtocolVersion::V3. + (ValuePool::Orchard, ProtocolVersion::V3) => { if self.cross_address_enabled { return None; } } // The Ironwood pool encodes the caller's choice in bit 2. - (ValuePool::Ironwood, ProtocolVersion::V2) => { + (ValuePool::Ironwood, ProtocolVersion::V3) => { if self.cross_address_enabled { value |= FLAG_V6_CROSS_ADDRESS_ENABLED; } } - // The Ironwood pool is not defined prior to ProtocolVersion::V2. + // The Ironwood pool is not defined prior to ProtocolVersion::V3. (ValuePool::Ironwood, _) => return None, } @@ -389,8 +389,8 @@ impl Flags { /// design) parse and validate the flags knowing only `bundle_version`: /// bit 2 can only be 1 for a bundle in the [`ValuePool::Ironwood`] pool, and /// otherwise MUST be 0. Assuming that has been checked, cross-address - /// transfers are always enabled prior to [`ProtocolVersion::V2`], and under - /// [`ProtocolVersion::V2`] are taken to be enabled exactly when bit 2 is set. + /// transfers are always enabled prior to [`ProtocolVersion::V3`], and under + /// [`ProtocolVersion::V3`] are taken to be enabled exactly when bit 2 is set. /// /// Note: if the wrong value of `bundle_version` is passed for the actual /// pool and epoch of the transaction, then a consensus-invalid transaction @@ -418,8 +418,8 @@ impl Flags { // We have already validated bit2 against the pool type let cross_address_enabled = match bundle_version.protocol_version { - ProtocolVersion::InsecureV0 | ProtocolVersion::V1 => true, - ProtocolVersion::V2 => bit2, + ProtocolVersion::InsecureV1 | ProtocolVersion::V2 => true, + ProtocolVersion::V3 => bit2, }; Some(Self { spends_enabled: value & FLAG_SPENDS_ENABLED != 0, @@ -901,7 +901,7 @@ pub enum CommitmentError { /// The requested transaction version is not valid for the bundle's [`BundleVersion`]. /// /// Ironwood bundles exist only in v6 transactions, so an Ironwood bundle - /// (`BundleVersion::ironwood_v2()`) cannot be committed with `TxVersion::V5`. + /// (`BundleVersion::ironwood_v3()`) cannot be committed with `TxVersion::V5`. InvalidTransactionVersion, } @@ -922,7 +922,7 @@ impl Bundle { /// Constructs an authorized `Bundle` from its constituent parts. /// /// This is the only constructor for an authorized bundle. For every version except the - /// historical pre-NU6.2 Orchard pool ([`BundleVersion::orchard_insecure_v0`]) it + /// historical pre-NU6.2 Orchard pool ([`BundleVersion::orchard_insecure_v1`]) it /// validates that the proof has exactly [`Proof::expected_proof_size`] bytes for /// `actions.len()`, so such an authorized bundle can never hold a non-canonical proof. This /// matters when building a bundle from untrusted input (e.g. deserializing from bytes), as it @@ -1074,10 +1074,10 @@ pub mod testing { /// Create an arbitrary [`BundleVersion`]. pub fn arb_bundle_version() -> impl Strategy { prop_oneof![ - Just(BundleVersion::orchard_insecure_v0()), - Just(BundleVersion::orchard_v1()), + Just(BundleVersion::orchard_insecure_v1()), Just(BundleVersion::orchard_v2()), - Just(BundleVersion::ironwood_v2()), + Just(BundleVersion::orchard_v3()), + Just(BundleVersion::ironwood_v3()), ] } @@ -1307,11 +1307,11 @@ pub(crate) mod tests { ), ] { assert_eq!( - flags.to_byte(BundleVersion::orchard_v1()), + flags.to_byte(BundleVersion::orchard_v2()), orchard_pre_nu6_3 ); - assert_eq!(flags.to_byte(BundleVersion::orchard_v2()), orchard_nu6_3); - assert_eq!(flags.to_byte(BundleVersion::ironwood_v2()), ironwood_nu6_3); + assert_eq!(flags.to_byte(BundleVersion::orchard_v3()), orchard_nu6_3); + assert_eq!(flags.to_byte(BundleVersion::ironwood_v3()), ironwood_nu6_3); } } @@ -1320,9 +1320,9 @@ pub(crate) mod tests { // A byte with bit 2 clear parses as an unrestricted bundle for Orchard pre-NU6.3, // and a restricted bundle for Orchard or Ironwood post-NU6.3. for value in 0b000..=0b011 { - let pre_nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v1()).unwrap(); - let nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v2()).unwrap(); - let ironwood_flags = Flags::from_byte(value, BundleVersion::ironwood_v2()).unwrap(); + let pre_nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v2()).unwrap(); + let nu6_3_flags = Flags::from_byte(value, BundleVersion::orchard_v3()).unwrap(); + let ironwood_flags = Flags::from_byte(value, BundleVersion::ironwood_v3()).unwrap(); assert_eq!( pre_nu6_3_flags.spends_enabled(), @@ -1339,26 +1339,26 @@ pub(crate) mod tests { // Each parse round-trips to the same byte under its own era, but the // restricted set is unrepresentable pre-NU6.3. assert_eq!( - pre_nu6_3_flags.to_byte(BundleVersion::orchard_v1()), + pre_nu6_3_flags.to_byte(BundleVersion::orchard_v2()), Some(value) ); assert_eq!( - nu6_3_flags.to_byte(BundleVersion::orchard_v2()), + nu6_3_flags.to_byte(BundleVersion::orchard_v3()), Some(value) ); - assert_eq!(nu6_3_flags.to_byte(BundleVersion::orchard_v1()), None); + assert_eq!(nu6_3_flags.to_byte(BundleVersion::orchard_v2()), None); } assert_eq!( - Flags::from_byte(0b011, BundleVersion::orchard_v1()), + Flags::from_byte(0b011, BundleVersion::orchard_v2()), Some(Flags::ENABLED) ); assert_eq!( - Flags::from_byte(0b011, BundleVersion::orchard_v2()), + Flags::from_byte(0b011, BundleVersion::orchard_v3()), Some(Flags::CROSS_ADDRESS_DISABLED) ); assert_eq!( - Flags::from_byte(0b011, BundleVersion::ironwood_v2()), + Flags::from_byte(0b011, BundleVersion::ironwood_v3()), Some(Flags::CROSS_ADDRESS_DISABLED) ); } @@ -1367,11 +1367,11 @@ pub(crate) mod tests { fn only_orchard_post_nu6_3_requires_the_cross_address_restriction() { // Consensus mandates the restriction only for the Orchard pool at NU6.3; every other // variant leaves the choice free (the builder then applies its prover-side default). - assert!(!BundleVersion::orchard_v2().permits_cross_address_transfers()); + assert!(!BundleVersion::orchard_v3().permits_cross_address_transfers()); for bundle_version in [ - BundleVersion::orchard_insecure_v0(), - BundleVersion::orchard_v1(), - BundleVersion::ironwood_v2(), + BundleVersion::orchard_insecure_v1(), + BundleVersion::orchard_v2(), + BundleVersion::ironwood_v3(), ] { assert!(bundle_version.permits_cross_address_transfers()); } @@ -1380,7 +1380,7 @@ pub(crate) mod tests { #[test] fn pre_nu6_3_flags_parsing_rejects_reserved_bits() { for value in 0b100..=u8::MAX { - assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v1()), None); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v2()), None); } } @@ -1389,23 +1389,23 @@ pub(crate) mod tests { for value in 0b100..=0b111 { // Orchard post-NU6.3 mandates the restriction, so bit 2 (`enableCrossAddress`) // set is rejected there. - assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v2()), None); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v3()), None); // Bit 2 set is only valid for the Ironwood pool, where it is recognized as // cross-address enabled and round-trips. - let flags = Flags::from_byte(value, BundleVersion::ironwood_v2()).unwrap(); + let flags = Flags::from_byte(value, BundleVersion::ironwood_v3()).unwrap(); assert!(flags.cross_address_enabled()); - assert_eq!(flags.to_byte(BundleVersion::ironwood_v2()), Some(value)); + assert_eq!(flags.to_byte(BundleVersion::ironwood_v3()), Some(value)); // Pre-NU6.3 formats encode the same flag set with bit 2 reserved zero. assert_eq!( - flags.to_byte(BundleVersion::orchard_v1()), + flags.to_byte(BundleVersion::orchard_v2()), Some(value & 0b011) ); } // Bits 3.. are always reserved, in every NU6.3 pool. for value in 0b1000..=u8::MAX { - assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v2()), None); - assert_eq!(Flags::from_byte(value, BundleVersion::ironwood_v2()), None); + assert_eq!(Flags::from_byte(value, BundleVersion::orchard_v3()), None); + assert_eq!(Flags::from_byte(value, BundleVersion::ironwood_v3()), None); } } @@ -1467,10 +1467,10 @@ pub(crate) mod tests { #[test] fn arb_flags_ironwood_post_nu6_3_round_trips(flags in arb_flags_ironwood_post_nu6_3()) { let encoded = flags - .to_byte(BundleVersion::ironwood_v2()) + .to_byte(BundleVersion::ironwood_v3()) .expect("all Ironwood post-NU6.3 flag strategy outputs encode under Ironwood post-NU6.3"); - prop_assert_eq!(Flags::from_byte(encoded, BundleVersion::ironwood_v2()), Some(flags)); + prop_assert_eq!(Flags::from_byte(encoded, BundleVersion::ironwood_v3()), Some(flags)); } #[test] @@ -1478,15 +1478,15 @@ pub(crate) mod tests { // `arb_flags` always enables cross-address transfers, which Orchard post-NU6.3 // forbids, so encoding under those restrictions must fail. The cross-address- // disabled projection must still encode and round-trip. - prop_assert_eq!(flags.to_byte(BundleVersion::orchard_v2()), None); + prop_assert_eq!(flags.to_byte(BundleVersion::orchard_v3()), None); let mut disabled = flags; disabled.cross_address_enabled = false; let encoded = disabled - .to_byte(BundleVersion::orchard_v2()) + .to_byte(BundleVersion::orchard_v3()) .expect("cross-address-disabled flags encode under Orchard post-NU6.3"); prop_assert_eq!( - Flags::from_byte(encoded, BundleVersion::orchard_v2()), + Flags::from_byte(encoded, BundleVersion::orchard_v3()), Some(disabled) ); } @@ -1517,8 +1517,8 @@ pub(crate) mod tests { // Orchard pre-NU6.3 has cross-address implicitly enabled and Orchard NU6.3 has it // disabled, but both encode to the same wire byte, so their commitments agree. - let legacy = build(enabled, BundleVersion::orchard_v1()); - let restricted = build(disabled, BundleVersion::orchard_v2()); + let legacy = build(enabled, BundleVersion::orchard_v2()); + let restricted = build(disabled, BundleVersion::orchard_v3()); prop_assert_eq!(restricted.flag_byte(), legacy.flag_byte()); let restricted_commitment: [u8; 32] = @@ -1528,13 +1528,13 @@ pub(crate) mod tests { // The unrestricted Ironwood encoding sets bit 2, producing a distinct digest. Only // the Ironwood pool may set it; Orchard post-NU6.3 prohibits cross-address transfers. - let unrestricted = build(enabled, BundleVersion::ironwood_v2()); + let unrestricted = build(enabled, BundleVersion::ironwood_v3()); let unrestricted_commitment: [u8; 32] = unrestricted.commitment(TxVersion::V6).unwrap().into(); prop_assert_ne!(unrestricted_commitment, restricted_commitment); // The restricted flag set has no pre-NU6.3 encoding, so no such bundle can be built. - prop_assert_eq!(disabled.to_byte(BundleVersion::orchard_v1()), None); + prop_assert_eq!(disabled.to_byte(BundleVersion::orchard_v2()), None); } #[test] @@ -1545,7 +1545,7 @@ pub(crate) mod tests { 0i64, *bundle.anchor(), bundle.authorization().clone(), - BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v3(), ); let ironwood = Bundle::from_parts_unchecked( bundle.actions().clone(), @@ -1553,7 +1553,7 @@ pub(crate) mod tests { *bundle.value_balance(), *bundle.anchor(), bundle.authorization().clone(), - BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v3(), ); prop_assert!(matches!( @@ -1594,9 +1594,9 @@ pub(crate) mod tests { let anchor_b = crate::Anchor::from_bytes([6u8; 32]).unwrap(); for (bundle_version, tx, anchor_in_txid_digest) in [ - (BundleVersion::orchard_v2(), TxVersion::V5, true), - (BundleVersion::orchard_v2(), TxVersion::V6, false), - (BundleVersion::ironwood_v2(), TxVersion::V6, false), + (BundleVersion::orchard_v3(), TxVersion::V5, true), + (BundleVersion::orchard_v3(), TxVersion::V6, false), + (BundleVersion::ironwood_v3(), TxVersion::V6, false), ] { let a = with_anchor(anchor_a, bundle_version); let b = with_anchor(anchor_b, bundle_version); @@ -1615,7 +1615,7 @@ pub(crate) mod tests { // The v5 and v6 Orchard formats are domain-separated, so the same bundle commits to // distinct transaction-ID digests under each. - let a_v2 = with_anchor(anchor_a, BundleVersion::orchard_v2()); + let a_v2 = with_anchor(anchor_a, BundleVersion::orchard_v3()); let orchard_v5: [u8; 32] = a_v2.commitment(TxVersion::V5).unwrap().into(); let orchard_v6: [u8; 32] = a_v2.commitment(TxVersion::V6).unwrap().into(); prop_assert_ne!(orchard_v5, orchard_v6); @@ -1629,7 +1629,7 @@ pub(crate) mod tests { let expected = Proof::expected_proof_size(actions.len()); let flags = *bundle.flags(); // Ironwood enforces canonical proof size and accepts any cross-address flag value. - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let value_balance = *bundle.value_balance(); let anchor = *bundle.anchor(); let binding_signature = bundle.authorization().binding_signature().clone(); @@ -1681,7 +1681,7 @@ pub(crate) mod tests { value_balance, anchor, authorization, - BundleVersion::orchard_v2(), + BundleVersion::orchard_v3(), ) .expect("canonical proof size is accepted"); prop_assert!(!bundle.flags().cross_address_enabled()); @@ -1709,7 +1709,7 @@ pub(crate) mod tests { Proof::new(vec![0u8; expected + 1]), binding_signature, ), - BundleVersion::orchard_v2(), + BundleVersion::orchard_v3(), ) .err(), Some(BundleError::NonCanonicalProofSize { expected, actual: expected + 1 }) @@ -1717,7 +1717,7 @@ pub(crate) mod tests { } #[test] - fn insecure_v0_skips_proof_size_enforcement(bundle in arb_bundle(3)) { + fn insecure_v1_skips_proof_size_enforcement(bundle in arb_bundle(3)) { // The historical pre-NU6.2 Orchard pool does not enforce canonical proof size, so a // padded proof is accepted: its transaction is already committed and cannot be // re-canonicalized. @@ -1731,7 +1731,7 @@ pub(crate) mod tests { Proof::new(vec![0u8; expected + 1]), bundle.authorization().binding_signature().clone(), ), - BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_insecure_v1(), ); prop_assert!(padded.is_ok()); } @@ -1741,7 +1741,7 @@ pub(crate) mod tests { #[test] fn from_parts_rejects_unrepresentable_flags() { // A cross-address-disabled flag set has no pre-NU6.3 Orchard encoding, so a bundle - // carrying that combination cannot be constructed under `orchard_v1()`. + // carrying that combination cannot be constructed under `orchard_v2()`. let bundle = sample_authorized_bundle(1); let flags = Flags::from_parts( bundle.flags().spends_enabled(), @@ -1756,7 +1756,7 @@ pub(crate) mod tests { *bundle.value_balance(), *bundle.anchor(), bundle.authorization().clone(), - BundleVersion::orchard_v1(), + BundleVersion::orchard_v2(), ) .err(), Some(BundleError::UnrepresentableFlags) diff --git a/src/circuit.rs b/src/circuit.rs index 06830e69f..c6e8b1ed2 100644 --- a/src/circuit.rs +++ b/src/circuit.rs @@ -1568,7 +1568,7 @@ mod tests { let enable_spend = read_bool(&mut r); let enable_output = read_bool(&mut r); let (cross_address_bit, bundle_version) = match encoding { - ProofFixtureEncoding::LegacyTwoFlags => (0, BundleVersion::orchard_v1()), + ProofFixtureEncoding::LegacyTwoFlags => (0, BundleVersion::orchard_v2()), ProofFixtureEncoding::PostNu6_3ThreeFlags => { // The fixture stores the instance-level *disable* bit; the NU6.3 flag // byte carries the *enable* bit, so invert when reconstructing. @@ -1579,7 +1579,7 @@ mod tests { let cross_address_disabled = read_bool(&mut r); ( u8::from(!cross_address_disabled) << 2, - BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v3(), ) } }; diff --git a/src/lib.rs b/src/lib.rs index 4d1602c94..dc3e9cb75 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,13 +136,13 @@ pub enum ProtocolVersion { /// Uses the historical unsound Orchard circuit. Cross-address transfers are permitted and /// notes use the V2 plaintext format. Used to reconstruct the historical verifying key and to /// parse/verify historical bundles, not to build new ones. - InsecureV0, + InsecureV1, /// The version of the Orchard protocol used in Zcash for NU6.2, only instantiated for the /// Orchard value pool. /// /// Uses the post-NU6.2 fixed Orchard circuit. Cross-address transfers are permitted and notes /// use the V2 plaintext format. - V1, + V2, /// The version of the Orchard protocol used in Zcash NU6.3, instantiated for both the Orchard /// and Ironwood value pools. /// @@ -154,5 +154,5 @@ pub enum ProtocolVersion { /// /// For transactional bundles affecting the [`ValuePool::Ironwood`] value pool, cross-address /// transfers are permitted and notes use V3 plaintexts. - V2, + V3, } diff --git a/src/pczt.rs b/src/pczt.rs index ce6959091..516f27ca5 100644 --- a/src/pczt.rs +++ b/src/pczt.rs @@ -409,7 +409,7 @@ mod tests { let change_sk = SpendingKey::random(&mut rng); let change_fvk = FullViewingKey::from(&change_sk); let change_recipient = change_fvk.address_at(0u32, Scope::Internal); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let note_version = bundle_version.note_version(); let rho = Rho::from_nf_old(Nullifier::dummy(&mut rng)); @@ -458,8 +458,8 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::orchard_v1(), - BundleVersion::orchard_v1().default_flags(), + BundleVersion::orchard_v2(), + BundleVersion::orchard_v2().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap(); @@ -479,8 +479,8 @@ mod tests { let recipient = fvk.address_at(0u32, Scope::External); let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::ironwood_v2(), - BundleVersion::ironwood_v2().default_flags(), + BundleVersion::ironwood_v3(), + BundleVersion::ironwood_v3().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap(); @@ -497,7 +497,7 @@ mod tests { #[test] fn shielding_bundle() { - let bundle_version = BundleVersion::orchard_v1(); + let bundle_version = BundleVersion::orchard_v2(); let pk = ProvingKey::build(bundle_version.circuit_version()); let mut rng = OsRng; @@ -569,8 +569,8 @@ mod tests { let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::ironwood_v2(), - BundleVersion::ironwood_v2().default_flags(), + BundleVersion::ironwood_v3(), + BundleVersion::ironwood_v3().default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), ) .unwrap(); @@ -661,7 +661,7 @@ mod tests { (root.into(), merkle_path) }; - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let mut builder = Builder::new( BundleType::DEFAULT, bundle_version, @@ -706,7 +706,7 @@ mod tests { #[test] fn shielded_bundle() { - let bundle_version = BundleVersion::orchard_v1(); + let bundle_version = BundleVersion::orchard_v2(); let pk = ProvingKey::build(bundle_version.circuit_version()); let mut rng = OsRng; @@ -761,7 +761,7 @@ mod tests { }; // Run the Creator and Constructor roles. - let bundle_version = BundleVersion::orchard_v1(); + let bundle_version = BundleVersion::orchard_v2(); let mut builder = Builder::new( BundleType::DEFAULT, bundle_version, @@ -890,9 +890,9 @@ mod tests { // Bit 2 is reserved pre-NU6.3, and rejected for Orchard post-NU6.3 (which mandates // the cross-address restriction); only Ironwood may set it. for pr in [ - BundleVersion::orchard_insecure_v0(), - BundleVersion::orchard_v1(), + BundleVersion::orchard_insecure_v1(), BundleVersion::orchard_v2(), + BundleVersion::orchard_v3(), ] { assert!(matches!( super::Bundle::parse( @@ -911,7 +911,7 @@ mod tests { let parsed = super::Bundle::parse( vec![], 0b0000_0100, - BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v3(), (0, false), anchor.to_bytes(), None, @@ -921,18 +921,18 @@ mod tests { assert!(parsed.flags().cross_address_enabled()); assert_eq!( - parsed.flags().to_byte(BundleVersion::ironwood_v2()), + parsed.flags().to_byte(BundleVersion::ironwood_v3()), Some(0b0000_0100) ); assert_eq!( - parsed.flags().to_byte(BundleVersion::orchard_v1()), + parsed.flags().to_byte(BundleVersion::orchard_v2()), Some(0b0000_0000) ); let restricted = super::Bundle::parse( vec![], 0b0000_0011, - BundleVersion::orchard_v2(), + BundleVersion::orchard_v3(), (0, false), anchor.to_bytes(), None, @@ -942,18 +942,18 @@ mod tests { assert!(!restricted.flags().cross_address_enabled()); assert_eq!( - restricted.flags().to_byte(BundleVersion::orchard_v2()), + restricted.flags().to_byte(BundleVersion::orchard_v3()), Some(0b0000_0011) ); assert_eq!( - restricted.flags().to_byte(BundleVersion::orchard_v1()), + restricted.flags().to_byte(BundleVersion::orchard_v2()), None ); } #[test] fn parse_preserves_note_versions() { - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let pczt_bundle = ironwood_output_pczt_bundle(OsRng); let flags = pczt_bundle.flags.to_byte(bundle_version).unwrap(); let anchor = pczt_bundle.anchor.to_bytes(); @@ -977,7 +977,7 @@ mod tests { #[test] fn parse_rejects_output_note_version_mismatch() { - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let pczt_bundle = ironwood_output_pczt_bundle(OsRng); let flags = pczt_bundle.flags.to_byte(bundle_version).unwrap(); let anchor = pczt_bundle.anchor.to_bytes(); @@ -1107,7 +1107,7 @@ mod tests { let merkle_path = MerklePath::dummy(&mut rng); let anchor = merkle_path.root(note.commitment().into()); - let bundle_version = BundleVersion::orchard_v2(); + let bundle_version = BundleVersion::orchard_v3(); let mut builder = Builder::new( BundleType::DEFAULT, bundle_version, @@ -1208,7 +1208,7 @@ mod tests { // Cross-address-disabled flags are only representable from NU6.3 onward, and the Orchard // pool at NU6.3 mandates the restriction; that is the version under which an extracted // bundle can legitimately carry these flags. - pczt_bundle.bundle_version = BundleVersion::orchard_v2(); + pczt_bundle.bundle_version = BundleVersion::orchard_v3(); pczt_bundle.flags = Flags::CROSS_ADDRESS_DISABLED; let bundle = pczt_bundle.extract::().unwrap().unwrap(); diff --git a/tests/builder.rs b/tests/builder.rs index 11020b01a..e19f2b511 100644 --- a/tests/builder.rs +++ b/tests/builder.rs @@ -92,7 +92,7 @@ fn bundle_chain() { // Create a shielding bundle. let shielding_bundle: Bundle<_, i64> = { let builder = - output_only_builder(BundleVersion::orchard_v1(), BundleType::DEFAULT, recipient); + output_only_builder(BundleVersion::orchard_v2(), BundleType::DEFAULT, recipient); let (unauthorized, bundle_meta) = builder.build(&mut rng).unwrap().unwrap(); assert_eq!( @@ -136,8 +136,8 @@ fn bundle_chain() { let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::orchard_v1(), - BundleVersion::orchard_v1().default_flags(), + BundleVersion::orchard_v2(), + BundleVersion::orchard_v2().default_flags(), root.into(), ) .unwrap(); @@ -176,7 +176,7 @@ fn builder_builds_for_insecure_circuit_version() { let recipient = fvk.address_at(0u32, Scope::External); let builder = output_only_builder( - BundleVersion::orchard_insecure_v0(), + BundleVersion::orchard_insecure_v1(), BundleType::DEFAULT, recipient, ); @@ -203,7 +203,7 @@ fn builder_builds_for_post_nu6_3_circuit_version() { let fvk = FullViewingKey::from(&sk); let recipient = fvk.address_at(0u32, Scope::External); - let builder = output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); + let builder = output_only_builder(BundleVersion::ironwood_v3(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); assert_eq!( @@ -229,7 +229,7 @@ fn ironwood_builder_outputs_decrypt_with_ironwood_domain() { let recipient = fvk.address_at(0u32, Scope::External); let ivk = PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::External)); - let builder = output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); + let builder = output_only_builder(BundleVersion::ironwood_v3(), BundleType::DEFAULT, recipient); let (bundle, bundle_meta) = builder.build::(&mut rng).unwrap().unwrap(); let action = &bundle.actions()[bundle_meta .output_action_index(0) @@ -256,7 +256,7 @@ fn ironwood_bundle_helpers_decrypt_and_recover_outputs() { let recipient = fvk.address_at(0u32, Scope::External); let ivk = fvk.to_ivk(Scope::External); let ovk = fvk.to_ovk(Scope::External); - let bundle_version = BundleVersion::ironwood_v2(); + let bundle_version = BundleVersion::ironwood_v3(); let anchor = MerkleHashOrchard::empty_root(32.into()).into(); let mut builder = Builder::new(BundleType::DEFAULT, bundle_version, SHIELDING_FLAGS, anchor) @@ -324,7 +324,7 @@ fn post_nu6_3_coinbase_bundle_proves_and_verifies() { let recipient = fvk.address_at(0u32, Scope::External); let builder = output_only_builder( - BundleVersion::ironwood_v2(), + BundleVersion::ironwood_v3(), BundleType::Coinbase, recipient, ); @@ -361,7 +361,7 @@ fn post_nu6_3_restricted_bundle_chain() { let shielding_bundle: Bundle<_, i64> = { let builder = - output_only_builder(BundleVersion::orchard_v1(), BundleType::DEFAULT, recipient); + output_only_builder(BundleVersion::orchard_v2(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized @@ -391,8 +391,8 @@ fn post_nu6_3_restricted_bundle_chain() { let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::orchard_v2(), - BundleVersion::orchard_v2().default_flags(), + BundleVersion::orchard_v3(), + BundleVersion::orchard_v3().default_flags(), root.into(), ) .unwrap(); @@ -479,15 +479,15 @@ fn post_nu6_3_restricted_bundle_chain() { .is_err()); } -// `BundleVersion::ironwood_v2()` is the post-NU6.3 Ironwood bundle version, which allows +// `BundleVersion::ironwood_v3()` is the post-NU6.3 Ironwood bundle version, which allows // any choice of the `enableCrossAddress` flag. It shares the post-NU6.3 circuit with -// `BundleVersion::orchard_v2()`, and uses V3 note plaintexts. A transactional +// `BundleVersion::orchard_v3()`, and uses V3 note plaintexts. A transactional // Ironwood bundle is therefore an ordinary spend+output bundle on the post-NU6.3 circuit // whose NU6.3 flag byte sets bit 2. #[test] fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { let mut rng = OsRng; - let post_nu6_3_pk = ProvingKey::build(BundleVersion::ironwood_v2().circuit_version()); + let post_nu6_3_pk = ProvingKey::build(BundleVersion::ironwood_v3().circuit_version()); let post_nu6_3_vk = VerifyingKey::build(OrchardCircuitVersion::PostNu6_3); let sk = SpendingKey::from_bytes([0; 32]).unwrap(); @@ -497,7 +497,7 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { // Shield a note to spend (an unrestricted, output-only post-NU6.3 bundle). let shielding_bundle: Bundle<_, i64> = { let builder = - output_only_builder(BundleVersion::ironwood_v2(), BundleType::DEFAULT, recipient); + output_only_builder(BundleVersion::ironwood_v3(), BundleType::DEFAULT, recipient); let (unauthorized, _) = builder.build(&mut rng).unwrap().unwrap(); let sighash = unauthorized .commitment(TxVersion::V6) @@ -527,8 +527,8 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { let change_addr = fvk.address_at(0u32, Scope::Internal); let mut builder = Builder::new( BundleType::DEFAULT, - BundleVersion::ironwood_v2(), - BundleVersion::ironwood_v2().default_flags(), + BundleVersion::ironwood_v3(), + BundleVersion::ironwood_v3().default_flags(), root.into(), ) .unwrap(); @@ -547,7 +547,7 @@ fn ironwood_post_nu6_3_unrestricted_bundle_proves_and_verifies() { assert!(unauthorized.flags().cross_address_enabled()); let flag_byte = unauthorized .flags() - .to_byte(BundleVersion::ironwood_v2()) + .to_byte(BundleVersion::ironwood_v3()) .expect("flags are representable under Ironwood"); assert_eq!(flag_byte & 0b100, 0b100); From 356ca9e3ac11a94e75cce932e1a3fdf19c17df54 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Mon, 29 Jun 2026 17:13:16 -0600 Subject: [PATCH 5/5] Make `builder::bundle` argument order consistent with `Builder::new` Reorder the free `bundle` function to take `bundle_type` immediately after `rng`, matching `Builder::new`'s `(bundle_type, bundle_version, flags, anchor)` order. Also note the removal of `BundleType::DISABLED` in the changelog. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ src/builder.rs | 18 +++++++++--------- src/bundle/commitments.rs | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bf97ed73..09857dc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -283,6 +283,8 @@ Existing callers keep the current behavior by constructing bundles with `Builder::new(bundle_type, bundle_version, flags, anchor)`. - `orchard::builder::bundle_for_version`; use `builder::bundle` with `BundleVersion` and a `Vec`. +- `orchard::builder::BundleType::DISABLED`; construct the builder with a `Flags` value + that disables spends and outputs instead. - Zero-argument `orchard::circuit::{ProvingKey, VerifyingKey}::build`; pass an `OrchardCircuitVersion` explicitly. - `orchard::circuit::{ProvingKey, VerifyingKey}::build_for_version`; use diff --git a/src/builder.rs b/src/builder.rs index 74db89d21..75aa7887f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -957,10 +957,10 @@ impl Builder { ) -> Result, BundleMetadata)>, BuildError> { bundle( rng, + self.bundle_type, self.bundle_version, self.flags, self.anchor, - self.bundle_type, self.spends, self.outputs, self.changes, @@ -1022,10 +1022,10 @@ impl Builder { #[cfg(feature = "circuit")] pub fn bundle>( rng: impl RngCore, + bundle_type: BundleType, bundle_version: BundleVersion, flags: Flags, anchor: Anchor, - bundle_type: BundleType, spends: Vec, outputs: Vec, changes: Vec, @@ -2004,10 +2004,10 @@ mod tests { // coinbase bundle with `enableSpends` set. let result = bundle::( &mut rng, + BundleType::Coinbase, bundle_version, bundle_version.default_flags(), // spends enabled anchor, - BundleType::Coinbase, vec![], vec![], vec![], @@ -2190,10 +2190,10 @@ mod tests { assert!(matches!( bundle::( &mut rng, + transactional(false), bundle_version, bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - transactional(false), vec![], vec![OutputInfo::new( None, @@ -2218,10 +2218,10 @@ mod tests { .unwrap(); let (bundle, bundle_meta) = bundle::( &mut rng, + transactional(false), bundle_version, bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - transactional(false), vec![], vec![], vec![change_output], @@ -2264,10 +2264,10 @@ mod tests { assert!(matches!( bundle::( &mut rng, + bundle_type, bundle_version, flags, EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - bundle_type, vec![], vec![], vec![change_output], @@ -2309,10 +2309,10 @@ mod tests { let spend = SpendInfo::new(fvk.clone(), note, merkle_path).unwrap(); assert!(bundle::( &mut rng, + BundleType::DEFAULT, bundle_version, bundle_version.default_flags(), anchor, - BundleType::DEFAULT, vec![spend], vec![], vec![], @@ -2329,10 +2329,10 @@ mod tests { assert!(matches!( bundle::( &mut rng, + BundleType::DEFAULT, bundle_version, bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - BundleType::DEFAULT, vec![], vec![output], vec![], @@ -2352,10 +2352,10 @@ mod tests { assert!(matches!( bundle::( &mut rng, + BundleType::DEFAULT, bundle_version, bundle_version.default_flags(), EMPTY_ROOTS[MERKLE_DEPTH_ORCHARD].into(), - BundleType::DEFAULT, vec![], vec![], vec![change], diff --git a/src/bundle/commitments.rs b/src/bundle/commitments.rs index 9be40dfd5..6d431cd8a 100644 --- a/src/bundle/commitments.rs +++ b/src/bundle/commitments.rs @@ -129,6 +129,7 @@ fn hasher(personal: &[u8; 16]) -> State { /// bundle's [`BundleVersion`]. /// /// [zip244]: https://zips.z.cash/zip-0244 +/// [`BundleVersion`]: crate::bundle::BundleVersion pub(crate) fn hash_bundle_txid_data>( bundle: &Bundle, tx_version: TxVersion,