Skip to content

Commit 9d6c6db

Browse files
ebfullclaude
andcommitted
Reject Ironwood commitments for v5 transactions
Previously `commitment_format` ignored `tx_version` for the Ironwood pool, silently committing an Ironwood bundle under v6 personalization even when the caller requested `TxVersion::V5`. Add a `CommitmentError::InvalidTransactionVersion` variant and return it from the commitment APIs instead. This makes `authorizing_commitment` and the `hash_bundle_*_empty` helpers fallible, mirroring `commitment`, and threads `CommitmentError` through the internal `hash_bundle_*_data` helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a0c1b83 commit 9d6c6db

3 files changed

Lines changed: 111 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ when building proving/verifying keys).
4646
introspection for whether a circuit version (or a key's circuit version)
4747
constrains the `disableCrossAddress` public input.
4848
- `orchard::circuit::VerifyingKey::circuit_version`
49-
- `orchard::bundle::CommitmentError`, with its `UnrepresentableFlags` variant,
50-
returned by `orchard::Bundle::commitment` when a bundle's flags cannot be
51-
represented under the requested `BundlePoolRestrictions`.
49+
- `orchard::bundle::CommitmentError`, with its `UnrepresentableFlags` and
50+
`InvalidTransactionVersion` variants, returned by bundle commitment APIs when
51+
a bundle's flags cannot be represented under the requested
52+
`BundlePoolRestrictions`, or when `IronwoodNu6_3Onward` is requested for a v5
53+
transaction.
5254
- `orchard::bundle::BatchError` (requires the `circuit` feature), with its
5355
`RestrictionUnsupportedByKey` variant, returned by
5456
`orchard::bundle::BatchValidator::add_bundle` when a restricted bundle is added
@@ -160,14 +162,19 @@ when building proving/verifying keys).
160162
flag byte sets bit 2, and `TxVersion::V6` uses the v6 personalization strings
161163
and commits the anchor in the authorizing commitment instead of the effects
162164
commitment. Callers computing transaction IDs or sighashes must pass the
163-
restrictions and version matching the transaction; these APIs do not validate
164-
that the selected pool/era is consensus-valid for the selected transaction
165-
version. `Bundle::commitment` now returns
166-
`Result<BundleCommitment, CommitmentError>`, yielding
167-
`Err(CommitmentError::UnrepresentableFlags)` if the flags are unrepresentable
168-
under those restrictions.
165+
restrictions and version matching the transaction; these APIs check only that
166+
the combination is representable, not that it is consensus-valid for the
167+
transaction version. `Bundle::commitment` now returns
168+
`Result<BundleCommitment, CommitmentError>` and
169+
`Bundle::<Authorized, V>::authorizing_commitment` returns
170+
`Result<BundleAuthorizingCommitment, CommitmentError>`, with
171+
`Err(CommitmentError::InvalidTransactionVersion)` for an Ironwood bundle in a
172+
v5 transaction and `Err(CommitmentError::UnrepresentableFlags)` (from
173+
`commitment` only) for flags the restrictions cannot represent.
169174
- `orchard::bundle::commitments::{hash_bundle_txid_empty, hash_bundle_auth_empty}`
170-
now take a `BundlePoolRestrictions` and a `TxVersion`.
175+
now take a `BundlePoolRestrictions` and a `TxVersion`, and return
176+
`Result<Blake2bHash, CommitmentError>`, rejecting an Ironwood pool in a v5
177+
transaction with `CommitmentError::InvalidTransactionVersion`.
171178
- Circuit APIs now require explicit circuit versions:
172179
- `orchard::circuit::Circuit::from_action_context` now takes an
173180
`OrchardCircuitVersion` instead of implicitly selecting `FixedPostNu6_2`.

src/bundle.rs

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ impl BundlePoolRestrictions {
147147
/// The two formats use different commitment personalization strings and include the bundle's
148148
/// anchor in different digests: v5 includes the anchor in the transaction-ID digest, while v6
149149
/// includes it in the authorizing digest. Ironwood bundles exist only in v6 transactions, so
150-
/// their commitment format ignores this value.
150+
/// attempting to compute an Ironwood commitment for a v5 transaction returns an error.
151151
///
152152
/// This is independent of the [`BundlePoolRestrictions`] that govern construction: the same
153153
/// Orchard bundle can be committed under either version, and the caller must pass the one
@@ -659,15 +659,15 @@ impl<T: Authorization, V: Copy + Into<i64>> Bundle<T, V> {
659659
/// # Errors
660660
///
661661
/// Returns [`CommitmentError::UnrepresentableFlags`] if the flags cannot
662-
/// be encoded under the given pool restrictions.
662+
/// be encoded under the given pool restrictions, or
663+
/// [`CommitmentError::InvalidTransactionVersion`] if `tx_version` is not
664+
/// valid for `pool_restrictions`.
663665
pub fn commitment(
664666
&self,
665667
pool_restrictions: BundlePoolRestrictions,
666668
tx_version: TxVersion,
667669
) -> Result<BundleCommitment, CommitmentError> {
668-
hash_bundle_txid_data(self, pool_restrictions, tx_version)
669-
.map(BundleCommitment)
670-
.ok_or(CommitmentError::UnrepresentableFlags)
670+
hash_bundle_txid_data(self, pool_restrictions, tx_version).map(BundleCommitment)
671671
}
672672

673673
/// Returns the transaction binding validating key for this bundle.
@@ -789,6 +789,13 @@ pub enum CommitmentError {
789789
/// * cross-address transfers are enabled but `pool_restrictions` specifies a post-NU6.3
790790
/// Orchard pool (where cross-address transfers are forbidden).
791791
UnrepresentableFlags,
792+
/// The requested transaction version is not valid for the requested pool
793+
/// restrictions.
794+
///
795+
/// Ironwood bundles exist only in v6 transactions, so
796+
/// `BundlePoolRestrictions::IronwoodNu6_3Onward` cannot be committed with
797+
/// `TxVersion::V5`.
798+
InvalidTransactionVersion,
792799
}
793800

794801
impl fmt::Display for CommitmentError {
@@ -798,6 +805,10 @@ impl fmt::Display for CommitmentError {
798805
f,
799806
"bundle flags are not representable according to the requested pool restrictions",
800807
),
808+
CommitmentError::InvalidTransactionVersion => write!(
809+
f,
810+
"Ironwood bundles can only be committed in a v6 transaction",
811+
),
801812
}
802813
}
803814
}
@@ -849,12 +860,17 @@ impl<V> Bundle<Authorized, V> {
849860
/// `tx_version` select the commitment personalization; in a v6 transaction this digest also
850861
/// includes the bundle anchor bytes (in a v5 transaction they are included by
851862
/// [`Bundle::commitment`] instead).
863+
///
864+
/// # Errors
865+
///
866+
/// Returns [`CommitmentError::InvalidTransactionVersion`] if `tx_version`
867+
/// is not valid for `pool_restrictions`.
852868
pub fn authorizing_commitment(
853869
&self,
854870
pool_restrictions: BundlePoolRestrictions,
855871
tx_version: TxVersion,
856-
) -> BundleAuthorizingCommitment {
857-
BundleAuthorizingCommitment(hash_bundle_auth_data(self, pool_restrictions, tx_version))
872+
) -> Result<BundleAuthorizingCommitment, CommitmentError> {
873+
hash_bundle_auth_data(self, pool_restrictions, tx_version).map(BundleAuthorizingCommitment)
858874
}
859875

860876
/// Verifies the proof for this bundle.
@@ -1325,15 +1341,24 @@ pub(crate) mod tests {
13251341
let (pi, ti) = formats[i];
13261342
let (pj, tj) = formats[j];
13271343
assert_ne!(
1328-
hash_bundle_txid_empty(pi, ti).as_bytes(),
1329-
hash_bundle_txid_empty(pj, tj).as_bytes()
1344+
hash_bundle_txid_empty(pi, ti).unwrap().as_bytes(),
1345+
hash_bundle_txid_empty(pj, tj).unwrap().as_bytes()
13301346
);
13311347
assert_ne!(
1332-
hash_bundle_auth_empty(pi, ti).as_bytes(),
1333-
hash_bundle_auth_empty(pj, tj).as_bytes()
1348+
hash_bundle_auth_empty(pi, ti).unwrap().as_bytes(),
1349+
hash_bundle_auth_empty(pj, tj).unwrap().as_bytes()
13341350
);
13351351
}
13361352
}
1353+
1354+
assert!(matches!(
1355+
hash_bundle_txid_empty(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5),
1356+
Err(CommitmentError::InvalidTransactionVersion)
1357+
));
1358+
assert!(matches!(
1359+
hash_bundle_auth_empty(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5),
1360+
Err(CommitmentError::InvalidTransactionVersion)
1361+
));
13371362
}
13381363

13391364
proptest! {
@@ -1420,6 +1445,29 @@ pub(crate) mod tests {
14201445
));
14211446
}
14221447

1448+
#[test]
1449+
fn ironwood_rejects_v5_commitment_version(bundle in arb_bundle(3)) {
1450+
let bundle_i64 = Bundle::from_parts_unchecked(
1451+
bundle.actions().clone(),
1452+
*bundle.flags(),
1453+
0i64,
1454+
*bundle.anchor(),
1455+
bundle.authorization().clone(),
1456+
);
1457+
1458+
prop_assert!(matches!(
1459+
bundle_i64.commitment(BundlePoolRestrictions::IronwoodNu6_3Onward, TxVersion::V5),
1460+
Err(CommitmentError::InvalidTransactionVersion)
1461+
));
1462+
prop_assert!(matches!(
1463+
bundle.authorizing_commitment(
1464+
BundlePoolRestrictions::IronwoodNu6_3Onward,
1465+
TxVersion::V5
1466+
),
1467+
Err(CommitmentError::InvalidTransactionVersion)
1468+
));
1469+
}
1470+
14231471
/// The anchor bytes are included in the transaction-ID digest for the v5 format and in
14241472
/// the authorizing digest for the v6 format, so changing only the anchor moves exactly
14251473
/// one of the two digests. The v5 and v6 Orchard formats are also domain-separated, so
@@ -1450,8 +1498,8 @@ pub(crate) mod tests {
14501498
] {
14511499
let txid_a: [u8; 32] = a.commitment(pool_restrictions, tx).unwrap().into();
14521500
let txid_b: [u8; 32] = b.commitment(pool_restrictions, tx).unwrap().into();
1453-
let auth_a = a.authorizing_commitment(pool_restrictions, tx).0;
1454-
let auth_b = b.authorizing_commitment(pool_restrictions, tx).0;
1501+
let auth_a = a.authorizing_commitment(pool_restrictions, tx).unwrap().0;
1502+
let auth_b = b.authorizing_commitment(pool_restrictions, tx).unwrap().0;
14551503
if anchor_in_txid_digest {
14561504
prop_assert_ne!(txid_a, txid_b);
14571505
prop_assert_eq!(auth_a.as_bytes(), auth_b.as_bytes());

src/bundle/commitments.rs

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
33
use blake2b_simd::{Hash as Blake2bHash, Params, State};
44

5-
use crate::bundle::{Authorization, Authorized, Bundle, BundlePoolRestrictions, TxVersion};
5+
use crate::bundle::{
6+
Authorization, Authorized, Bundle, BundlePoolRestrictions, CommitmentError, TxVersion,
7+
};
68

79
const ZCASH_ORCHARD_V5_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOrchardHash";
810
const ZCASH_ORCHARD_V6_HASH_PERSONALIZATION: &[u8; 16] = b"ZTxIdOrchardH_v6";
@@ -70,17 +72,22 @@ enum BundleCommitmentFormat {
7072
}
7173

7274
impl BundlePoolRestrictions {
73-
fn commitment_format(self, tx_version: TxVersion) -> BundleCommitmentFormat {
74-
match self {
75+
fn commitment_format(
76+
self,
77+
tx_version: TxVersion,
78+
) -> Result<BundleCommitmentFormat, CommitmentError> {
79+
Ok(match self {
7580
BundlePoolRestrictions::OrchardPreNu6_2
7681
| BundlePoolRestrictions::OrchardNu6_2Only
7782
| BundlePoolRestrictions::OrchardNu6_3Onward => match tx_version {
7883
TxVersion::V5 => BundleCommitmentFormat::OrchardV5,
7984
TxVersion::V6 => BundleCommitmentFormat::OrchardV6,
8085
},
81-
// Ironwood exists only in v6 transactions, so `tx_version` is irrelevant here.
82-
BundlePoolRestrictions::IronwoodNu6_3Onward => BundleCommitmentFormat::IronwoodV6,
83-
}
86+
BundlePoolRestrictions::IronwoodNu6_3Onward => match tx_version {
87+
TxVersion::V5 => return Err(CommitmentError::InvalidTransactionVersion),
88+
TxVersion::V6 => BundleCommitmentFormat::IronwoodV6,
89+
},
90+
})
8491
}
8592
}
8693

@@ -123,15 +130,16 @@ fn hasher(personal: &[u8; 16]) -> State {
123130
/// personalization string. In the v6 format the anchor is included by
124131
/// `hash_bundle_auth_data` instead.
125132
///
126-
/// Returns `None` if the bundle flags cannot be encoded in the domain's bundle format.
133+
/// Returns an error if the bundle flags cannot be encoded in the domain's bundle format,
134+
/// or if `tx_version` is not valid for `pool_restrictions`.
127135
///
128136
/// [zip244]: https://zips.z.cash/zip-0244
129137
pub(crate) fn hash_bundle_txid_data<A: Authorization, V: Copy + Into<i64>>(
130138
bundle: &Bundle<A, V>,
131139
pool_restrictions: BundlePoolRestrictions,
132140
tx_version: TxVersion,
133-
) -> Option<Blake2bHash> {
134-
let format = pool_restrictions.commitment_format(tx_version);
141+
) -> Result<Blake2bHash, CommitmentError> {
142+
let format = pool_restrictions.commitment_format(tx_version)?;
135143
let personalizations = format.personalizations();
136144
let mut h = hasher(personalizations.bundle);
137145
let mut ch = hasher(personalizations.actions_compact);
@@ -155,12 +163,15 @@ pub(crate) fn hash_bundle_txid_data<A: Authorization, V: Copy + Into<i64>>(
155163
h.update(ch.finalize().as_bytes());
156164
h.update(mh.finalize().as_bytes());
157165
h.update(nh.finalize().as_bytes());
158-
h.update(&[bundle.flags().to_byte(pool_restrictions)?]);
166+
h.update(&[bundle
167+
.flags()
168+
.to_byte(pool_restrictions)
169+
.ok_or(CommitmentError::UnrepresentableFlags)?]);
159170
h.update(&(*bundle.value_balance()).into().to_le_bytes());
160171
if format.includes_anchor_in_txid_digest() {
161172
h.update(&bundle.anchor().to_bytes());
162173
}
163-
Some(h.finalize())
174+
Ok(h.finalize())
164175
}
165176

166177
/// Construct the commitment for the absent bundle as defined in
@@ -170,14 +181,14 @@ pub(crate) fn hash_bundle_txid_data<A: Authorization, V: Copy + Into<i64>>(
170181
pub fn hash_bundle_txid_empty(
171182
pool_restrictions: BundlePoolRestrictions,
172183
tx_version: TxVersion,
173-
) -> Blake2bHash {
174-
hasher(
184+
) -> Result<Blake2bHash, CommitmentError> {
185+
Ok(hasher(
175186
pool_restrictions
176-
.commitment_format(tx_version)
187+
.commitment_format(tx_version)?
177188
.personalizations()
178189
.bundle,
179190
)
180-
.finalize()
191+
.finalize())
181192
}
182193

183194
/// Construct the commitment to the authorizing data of an
@@ -189,8 +200,8 @@ pub(crate) fn hash_bundle_auth_data<V>(
189200
bundle: &Bundle<Authorized, V>,
190201
pool_restrictions: BundlePoolRestrictions,
191202
tx_version: TxVersion,
192-
) -> Blake2bHash {
193-
let format = pool_restrictions.commitment_format(tx_version);
203+
) -> Result<Blake2bHash, CommitmentError> {
204+
let format = pool_restrictions.commitment_format(tx_version)?;
194205
let mut h = hasher(format.personalizations().auth);
195206
h.update(bundle.authorization().proof().as_ref());
196207
for action in bundle.actions().iter() {
@@ -202,7 +213,7 @@ pub(crate) fn hash_bundle_auth_data<V>(
202213
if format.includes_anchor_in_authorizing_digest() {
203214
h.update(&bundle.anchor().to_bytes());
204215
}
205-
h.finalize()
216+
Ok(h.finalize())
206217
}
207218

208219
/// Construct the commitment for an absent bundle as defined in
@@ -212,12 +223,12 @@ pub(crate) fn hash_bundle_auth_data<V>(
212223
pub fn hash_bundle_auth_empty(
213224
pool_restrictions: BundlePoolRestrictions,
214225
tx_version: TxVersion,
215-
) -> Blake2bHash {
216-
hasher(
226+
) -> Result<Blake2bHash, CommitmentError> {
227+
Ok(hasher(
217228
pool_restrictions
218-
.commitment_format(tx_version)
229+
.commitment_format(tx_version)?
219230
.personalizations()
220231
.auth,
221232
)
222-
.finalize()
233+
.finalize())
223234
}

0 commit comments

Comments
 (0)