iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

transaction_v2.rs (45157B)


      1 use std::collections::BTreeSet;
      2 
      3 use anyhow::{Context, Result, bail};
      4 use sha2::{Digest, Sha256};
      5 
      6 use super::{
      7     AddressVersion, ProtocolPublicKey, ProtocolSignature, SignatureScheme, VersionedAddress,
      8     hex_encode, verify_ed25519, verify_ml_dsa44,
      9 };
     10 
     11 const TRANSACTION_V2_TAG: &[u8] = b"IUNA-TX-V2";
     12 const ADDRESS_V1_COMMITMENT_TAG: &[u8] = b"IUNA-ADDRESS-V1";
     13 const MAX_CHAIN_ID_BYTES: usize = 64;
     14 const MAX_V2_INPUTS: usize = 1_000;
     15 const MAX_V2_OUTPUTS: usize = 1_000;
     16 const STRATUM_PROOF_HEADER_BYTES: usize = 80;
     17 
     18 /// Reserved wire version. It is deliberately separate from the live `Transaction` JSON type.
     19 pub const TRANSACTION_V2_WIRE_VERSION: u16 = 2;
     20 
     21 /// Fixed consensus activation height for the `iuna-mainnet-candidate` migration.
     22 ///
     23 /// This is deliberately compiled into the protocol rather than exposed as an
     24 /// operator-controlled feature flag. Live consensus must not route v2
     25 /// transactions through this gate until the complete integration is present.
     26 pub const TRANSACTION_V2_ACTIVATION_HEIGHT: Option<u64> = Some(3_000);
     27 
     28 #[derive(Clone, Debug, Eq, PartialEq)]
     29 pub struct TransactionV2Domain {
     30     chain_id: String,
     31     genesis_hash: [u8; 32],
     32 }
     33 
     34 #[derive(Clone, Debug, Eq, PartialEq)]
     35 pub struct TransactionV2Input {
     36     pub outpoint_txid: [u8; 32],
     37     pub outpoint_index: u32,
     38     pub owner: VersionedAddress,
     39 }
     40 
     41 #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
     42 pub enum LegacyTransactionId {
     43     Hash([u8; 32]),
     44     Signature([u8; 64]),
     45 }
     46 
     47 #[derive(Clone, Debug, Eq, PartialEq)]
     48 pub struct TransactionV2LegacyInput {
     49     pub outpoint_id: LegacyTransactionId,
     50     pub outpoint_index: u32,
     51     pub owner: VersionedAddress,
     52 }
     53 
     54 #[derive(Clone, Debug, Eq, PartialEq)]
     55 pub struct TransactionV2Output {
     56     pub address: VersionedAddress,
     57     pub amount: u64,
     58 }
     59 
     60 #[derive(Clone, Debug, Eq, PartialEq)]
     61 pub struct V2SpendingAuthorization {
     62     public_key: ProtocolPublicKey,
     63     signature: ProtocolSignature,
     64 }
     65 
     66 #[derive(Clone, Debug, Eq, PartialEq)]
     67 pub enum TransactionV2 {
     68     Migration {
     69         inputs: Vec<TransactionV2LegacyInput>,
     70         outputs: Vec<TransactionV2Output>,
     71         fee: u64,
     72         authorizations: Vec<V2SpendingAuthorization>,
     73     },
     74     Transfer {
     75         inputs: Vec<TransactionV2Input>,
     76         outputs: Vec<TransactionV2Output>,
     77         fee: u64,
     78         authorizations: Vec<V2SpendingAuthorization>,
     79     },
     80     Burn {
     81         inputs: Vec<TransactionV2Input>,
     82         change: Vec<TransactionV2Output>,
     83         amount: u64,
     84         fee: u64,
     85         anchor: Option<[u8; 32]>,
     86         authorizations: Vec<V2SpendingAuthorization>,
     87     },
     88     Mine {
     89         recipient: VersionedAddress,
     90         anchor: [u8; 32],
     91         salt: u64,
     92         nonce: u64,
     93         difficulty_bits: u32,
     94         proof_header: Option<[u8; STRATUM_PROOF_HEADER_BYTES]>,
     95         proof_hash: [u8; 32],
     96     },
     97 }
     98 
     99 impl TransactionV2Domain {
    100     pub fn new(chain_id: impl Into<String>, genesis_hash: [u8; 32]) -> Result<Self> {
    101         let chain_id = chain_id.into();
    102         if chain_id.is_empty() || chain_id.len() > MAX_CHAIN_ID_BYTES || !chain_id.is_ascii() {
    103             bail!("transaction v2 chain ID must contain 1..={MAX_CHAIN_ID_BYTES} ASCII bytes");
    104         }
    105         Ok(Self {
    106             chain_id,
    107             genesis_hash,
    108         })
    109     }
    110 
    111     pub fn chain_id(&self) -> &str {
    112         &self.chain_id
    113     }
    114 
    115     pub fn genesis_hash(&self) -> &[u8; 32] {
    116         &self.genesis_hash
    117     }
    118 }
    119 
    120 impl V2SpendingAuthorization {
    121     pub fn new(public_key: ProtocolPublicKey, signature: ProtocolSignature) -> Result<Self> {
    122         if public_key.scheme() != signature.scheme() {
    123             bail!("transaction v2 public key and signature schemes differ");
    124         }
    125         Ok(Self {
    126             public_key,
    127             signature,
    128         })
    129     }
    130 
    131     pub fn scheme(&self) -> SignatureScheme {
    132         self.public_key.scheme()
    133     }
    134 
    135     pub fn public_key(&self) -> &ProtocolPublicKey {
    136         &self.public_key
    137     }
    138 
    139     pub fn signature(&self) -> &ProtocolSignature {
    140         &self.signature
    141     }
    142 
    143     /// Computes the address authorized by this public key.
    144     pub fn authorized_address(&self) -> Result<VersionedAddress> {
    145         match self.public_key.scheme() {
    146             SignatureScheme::Ed25519 => Ok(VersionedAddress {
    147                 version: AddressVersion::Ed25519PublicKey,
    148                 payload: self
    149                     .public_key
    150                     .as_bytes()
    151                     .try_into()
    152                     .expect("validated Ed25519 public key length"),
    153             }),
    154             SignatureScheme::HybridEd25519MlDsa44 => {
    155                 hybrid_key_commitment_address(&self.public_key)
    156             }
    157             SignatureScheme::MlDsa44 => {
    158                 bail!("ML-DSA-only transaction v2 authorizations are not supported")
    159             }
    160         }
    161     }
    162 
    163     /// Computes the address-v1 commitment for a hybrid authorization.
    164     pub fn committed_address(&self) -> Result<VersionedAddress> {
    165         hybrid_key_commitment_address(&self.public_key)
    166     }
    167 }
    168 
    169 impl TransactionV2 {
    170     pub fn is_burn(&self) -> bool {
    171         matches!(self, Self::Burn { .. })
    172     }
    173 
    174     pub fn amount(&self) -> u64 {
    175         match self {
    176             Self::Burn { amount, .. } => *amount,
    177             Self::Migration { .. } | Self::Transfer { .. } | Self::Mine { .. } => 0,
    178         }
    179     }
    180 
    181     pub fn burn_anchor(&self) -> Option<[u8; 32]> {
    182         match self {
    183             Self::Burn { anchor, .. } => *anchor,
    184             Self::Migration { .. } | Self::Transfer { .. } | Self::Mine { .. } => None,
    185         }
    186     }
    187 
    188     pub fn burn_legacy_owner(&self) -> Result<Option<String>> {
    189         let Self::Burn {
    190             inputs,
    191             authorizations,
    192             ..
    193         } = self
    194         else {
    195             return Ok(None);
    196         };
    197         let owner = inputs
    198             .first()
    199             .context("transaction v2 burn has no owner")?
    200             .owner;
    201         if inputs.iter().any(|input| input.owner != owner) {
    202             bail!("transaction v2 burn inputs must have one owner");
    203         }
    204         let authorization = authorizations
    205             .first()
    206             .context("transaction v2 burn has no authorization")?;
    207         if authorization.scheme() != SignatureScheme::HybridEd25519MlDsa44
    208             || authorizations.iter().any(|candidate| {
    209                 candidate.public_key().as_bytes() != authorization.public_key().as_bytes()
    210             })
    211         {
    212             bail!("transaction v2 burn must use one hybrid wallet identity");
    213         }
    214         if authorization.committed_address()? != owner {
    215             bail!("transaction v2 burn authorization does not match its owner");
    216         }
    217         Ok(Some(hex_encode(
    218             &authorization.public_key().as_bytes()[..32],
    219         )))
    220     }
    221 
    222     pub fn fee(&self) -> u64 {
    223         match self {
    224             Self::Migration { fee, .. } | Self::Transfer { fee, .. } | Self::Burn { fee, .. } => {
    225                 *fee
    226             }
    227             Self::Mine { .. } => 0,
    228         }
    229     }
    230 
    231     /// Canonical bytes signed by every spending authorization. Signatures are excluded.
    232     pub fn signing_bytes(&self, domain: &TransactionV2Domain) -> Result<Vec<u8>> {
    233         self.validate_unsigned_shape()?;
    234         let mut bytes = encode_prefix(domain)?;
    235         self.encode_unsigned_body(&mut bytes)?;
    236         Ok(bytes)
    237     }
    238 
    239     /// Canonical, length-delimited wire encoding. This is not used by live gossip or blocks.
    240     pub fn encode(&self, domain: &TransactionV2Domain) -> Result<Vec<u8>> {
    241         self.validate_shape()?;
    242         let mut bytes = self.signing_bytes(domain)?;
    243         let authorizations = self.authorizations();
    244         encode_count(&mut bytes, authorizations.len(), "authorization count")?;
    245         for authorization in authorizations {
    246             bytes.push(authorization.scheme().wire_id());
    247             encode_bytes(
    248                 &mut bytes,
    249                 authorization.public_key().as_bytes(),
    250                 "authorization public key",
    251             )?;
    252             encode_bytes(
    253                 &mut bytes,
    254                 authorization.signature().as_bytes(),
    255                 "authorization signature",
    256             )?;
    257         }
    258         Ok(bytes)
    259     }
    260 
    261     pub fn decode(encoded: &[u8]) -> Result<(TransactionV2Domain, Self)> {
    262         let mut reader = Reader::new(encoded);
    263         if reader.take(TRANSACTION_V2_TAG.len(), "transaction v2 tag")? != TRANSACTION_V2_TAG {
    264             bail!("transaction v2 tag is invalid");
    265         }
    266         let version = reader.u16("transaction version")?;
    267         if version != TRANSACTION_V2_WIRE_VERSION {
    268             bail!("unsupported transaction version {version}");
    269         }
    270         let chain_id = reader.length_prefixed(MAX_CHAIN_ID_BYTES, "chain ID")?;
    271         let chain_id = std::str::from_utf8(chain_id)
    272             .context("transaction v2 chain ID is not UTF-8")?
    273             .to_string();
    274         let genesis_hash = reader.array::<32>("genesis hash")?;
    275         let domain = TransactionV2Domain::new(chain_id, genesis_hash)?;
    276         let kind = reader.u8("transaction kind")?;
    277 
    278         let unsigned = match kind {
    279             1 => UnsignedDecoded::Transfer {
    280                 inputs: decode_inputs(&mut reader)?,
    281                 outputs: decode_outputs(&mut reader)?,
    282                 fee: reader.u64("transfer fee")?,
    283             },
    284             2 => {
    285                 let inputs = decode_inputs(&mut reader)?;
    286                 let change = decode_outputs(&mut reader)?;
    287                 let amount = reader.u64("burn amount")?;
    288                 let fee = reader.u64("burn fee")?;
    289                 let anchor = decode_optional_array::<32>(&mut reader, "burn anchor")?;
    290                 UnsignedDecoded::Burn {
    291                     inputs,
    292                     change,
    293                     amount,
    294                     fee,
    295                     anchor,
    296                 }
    297             }
    298             3 => {
    299                 let recipient = decode_address(&mut reader, "mine recipient")?;
    300                 let anchor = reader.array::<32>("mine anchor")?;
    301                 let salt = reader.u64("mine salt")?;
    302                 let nonce = reader.u64("mine nonce")?;
    303                 let difficulty_bits = reader.u32("mine difficulty")?;
    304                 let proof_header = decode_optional_array::<STRATUM_PROOF_HEADER_BYTES>(
    305                     &mut reader,
    306                     "proof header",
    307                 )?;
    308                 let proof_hash = reader.array::<32>("proof hash")?;
    309                 UnsignedDecoded::Mine {
    310                     recipient,
    311                     anchor,
    312                     salt,
    313                     nonce,
    314                     difficulty_bits,
    315                     proof_header,
    316                     proof_hash,
    317                 }
    318             }
    319             4 => UnsignedDecoded::Migration {
    320                 inputs: decode_legacy_inputs(&mut reader)?,
    321                 outputs: decode_outputs(&mut reader)?,
    322                 fee: reader.u64("migration fee")?,
    323             },
    324             _ => bail!("unsupported transaction v2 kind {kind}"),
    325         };
    326 
    327         let authorization_count = reader.count(MAX_V2_INPUTS, "authorization count")?;
    328         let mut authorizations = Vec::with_capacity(authorization_count);
    329         for _ in 0..authorization_count {
    330             let scheme_id = reader.u8("authorization scheme")?;
    331             let scheme = SignatureScheme::from_wire_id(scheme_id)
    332                 .with_context(|| format!("unknown signature scheme {scheme_id}"))?;
    333             let public_key =
    334                 reader.length_prefixed(scheme.public_key_bytes(), "authorization public key")?;
    335             if public_key.len() != scheme.public_key_bytes() {
    336                 bail!("authorization public key has the wrong scheme-specific length");
    337             }
    338             let signature =
    339                 reader.length_prefixed(scheme.signature_bytes(), "authorization signature")?;
    340             if signature.len() != scheme.signature_bytes() {
    341                 bail!("authorization signature has the wrong scheme-specific length");
    342             }
    343             authorizations.push(V2SpendingAuthorization::new(
    344                 ProtocolPublicKey::new(scheme, public_key.to_vec())?,
    345                 ProtocolSignature::new(scheme, signature.to_vec())?,
    346             )?);
    347         }
    348         reader.finish()?;
    349 
    350         let transaction = unsigned.with_authorizations(authorizations)?;
    351         transaction.validate_shape()?;
    352         Ok((domain, transaction))
    353     }
    354 
    355     /// A v2 outpoint uses this fixed-size ID instead of a potentially variable signature.
    356     pub fn transaction_id(&self, domain: &TransactionV2Domain) -> Result<[u8; 32]> {
    357         Ok(Sha256::digest(self.encode(domain)?).into())
    358     }
    359 
    360     pub fn encoded_size_bytes(&self, domain: &TransactionV2Domain) -> Result<usize> {
    361         Ok(self.encode(domain)?.len())
    362     }
    363 
    364     pub fn validate_authorization_commitments(&self) -> Result<()> {
    365         self.validate_shape()?;
    366         match self {
    367             Self::Migration {
    368                 inputs,
    369                 authorizations,
    370                 ..
    371             } => {
    372                 for (input, authorization) in inputs.iter().zip(authorizations) {
    373                     if input.owner.version != AddressVersion::Ed25519PublicKey
    374                         || authorization.scheme() != SignatureScheme::Ed25519
    375                         || input.owner != authorization.authorized_address()?
    376                     {
    377                         bail!(
    378                             "transaction v2 migration authorization does not match its legacy owner"
    379                         );
    380                     }
    381                 }
    382             }
    383             Self::Transfer {
    384                 inputs,
    385                 authorizations,
    386                 ..
    387             }
    388             | Self::Burn {
    389                 inputs,
    390                 authorizations,
    391                 ..
    392             } => {
    393                 for (input, authorization) in inputs.iter().zip(authorizations) {
    394                     if input.owner.version != AddressVersion::HybridKeyCommitment
    395                         || authorization.scheme() != SignatureScheme::HybridEd25519MlDsa44
    396                         || input.owner != authorization.authorized_address()?
    397                     {
    398                         bail!(
    399                             "transaction v2 authorization does not match its hybrid owner commitment"
    400                         );
    401                     }
    402                 }
    403             }
    404             Self::Mine { .. } => {}
    405         }
    406         Ok(())
    407     }
    408 
    409     /// Verifies the authorization required by each input version. Version-0 inputs retain their
    410     /// Ed25519 rule so existing value can migrate; version-1 inputs require both signature
    411     /// components. Live consensus must call `ensure_transaction_v2_active` before acceptance.
    412     pub fn verify_authorizations(&self, domain: &TransactionV2Domain) -> Result<()> {
    413         self.validate_authorization_commitments()?;
    414         let payload = self.signing_bytes(domain)?;
    415         for authorization in self.authorizations() {
    416             match authorization.scheme() {
    417                 SignatureScheme::Ed25519 => {
    418                     let public_key = authorization
    419                         .public_key()
    420                         .as_bytes()
    421                         .try_into()
    422                         .expect("validated Ed25519 public key length");
    423                     let signature = authorization
    424                         .signature()
    425                         .as_bytes()
    426                         .try_into()
    427                         .expect("validated Ed25519 signature length");
    428                     verify_ed25519(public_key, &payload, signature, "transaction v2 input")?;
    429                 }
    430                 SignatureScheme::HybridEd25519MlDsa44 => {
    431                     let (ed25519_public_key, ml_dsa_public_key) =
    432                         authorization.public_key().as_bytes().split_at(32);
    433                     let (ed25519_signature, ml_dsa_signature) =
    434                         authorization.signature().as_bytes().split_at(64);
    435                     verify_ed25519(
    436                         ed25519_public_key
    437                             .try_into()
    438                             .expect("validated hybrid key length"),
    439                         &payload,
    440                         ed25519_signature
    441                             .try_into()
    442                             .expect("validated hybrid signature length"),
    443                         "transaction v2 classical component",
    444                     )?;
    445                     verify_ml_dsa44(
    446                         ml_dsa_public_key
    447                             .try_into()
    448                             .expect("validated hybrid key length"),
    449                         &payload,
    450                         ml_dsa_signature
    451                             .try_into()
    452                             .expect("validated hybrid signature length"),
    453                         "transaction v2 post-quantum component",
    454                     )?;
    455                 }
    456                 SignatureScheme::MlDsa44 => {
    457                     bail!("ML-DSA-only transaction v2 authorizations are not supported")
    458                 }
    459             }
    460         }
    461         Ok(())
    462     }
    463 
    464     fn validate_shape(&self) -> Result<()> {
    465         self.validate_unsigned_shape()?;
    466         if self.authorizations().len() != self.input_count() {
    467             bail!("transaction v2 requires exactly one authorization per input");
    468         }
    469         Ok(())
    470     }
    471 
    472     fn validate_unsigned_shape(&self) -> Result<()> {
    473         if self.input_count() > MAX_V2_INPUTS {
    474             bail!("transaction v2 has too many inputs");
    475         }
    476         if self.outputs().len() > MAX_V2_OUTPUTS {
    477             bail!("transaction v2 has too many outputs");
    478         }
    479         if self.outputs().iter().any(|output| output.amount == 0) {
    480             bail!("transaction v2 outputs must be greater than zero");
    481         }
    482         match self {
    483             Self::Migration {
    484                 inputs, outputs, ..
    485             } => {
    486                 if inputs.is_empty() {
    487                     bail!("transaction v2 migration requires at least one input");
    488                 }
    489                 if outputs.len() != 1
    490                     || outputs[0].address.version != AddressVersion::HybridKeyCommitment
    491                 {
    492                     bail!("transaction v2 migration requires exactly one address-v1 output");
    493                 }
    494                 let unique = inputs
    495                     .iter()
    496                     .map(|input| (&input.outpoint_id, input.outpoint_index))
    497                     .collect::<BTreeSet<_>>();
    498                 if unique.len() != inputs.len() {
    499                     bail!("transaction v2 migration contains a duplicate input");
    500                 }
    501             }
    502             Self::Transfer {
    503                 inputs, outputs, ..
    504             } => {
    505                 if inputs.is_empty() || outputs.is_empty() {
    506                     bail!("transaction v2 transfer requires inputs and outputs");
    507                 }
    508                 if outputs
    509                     .iter()
    510                     .any(|output| output.address.version != AddressVersion::HybridKeyCommitment)
    511                 {
    512                     bail!("transaction v2 transfer outputs must use address v1");
    513                 }
    514                 let unique = inputs
    515                     .iter()
    516                     .map(|input| (input.outpoint_txid, input.outpoint_index))
    517                     .collect::<BTreeSet<_>>();
    518                 if unique.len() != inputs.len() {
    519                     bail!("transaction v2 transfer contains a duplicate input");
    520                 }
    521             }
    522             Self::Burn {
    523                 inputs,
    524                 change,
    525                 amount,
    526                 ..
    527             } => {
    528                 if inputs.is_empty() || *amount == 0 {
    529                     bail!("transaction v2 burn requires inputs and a positive amount");
    530                 }
    531                 if change
    532                     .iter()
    533                     .any(|output| output.address.version != AddressVersion::HybridKeyCommitment)
    534                 {
    535                     bail!("transaction v2 burn change must use address v1");
    536                 }
    537                 let unique = inputs
    538                     .iter()
    539                     .map(|input| (input.outpoint_txid, input.outpoint_index))
    540                     .collect::<BTreeSet<_>>();
    541                 if unique.len() != inputs.len() {
    542                     bail!("transaction v2 burn contains a duplicate input");
    543                 }
    544             }
    545             Self::Mine { .. } => {}
    546         }
    547         Ok(())
    548     }
    549 
    550     fn input_count(&self) -> usize {
    551         match self {
    552             Self::Migration { inputs, .. } => inputs.len(),
    553             Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs.len(),
    554             Self::Mine { .. } => 0,
    555         }
    556     }
    557 
    558     fn outputs(&self) -> &[TransactionV2Output] {
    559         match self {
    560             Self::Migration { outputs, .. } | Self::Transfer { outputs, .. } => outputs,
    561             Self::Burn { change, .. } => change,
    562             Self::Mine { .. } => &[],
    563         }
    564     }
    565 
    566     fn authorizations(&self) -> &[V2SpendingAuthorization] {
    567         match self {
    568             Self::Migration { authorizations, .. }
    569             | Self::Transfer { authorizations, .. }
    570             | Self::Burn { authorizations, .. } => authorizations,
    571             Self::Mine { .. } => &[],
    572         }
    573     }
    574 
    575     fn encode_unsigned_body(&self, bytes: &mut Vec<u8>) -> Result<()> {
    576         match self {
    577             Self::Migration {
    578                 inputs,
    579                 outputs,
    580                 fee,
    581                 ..
    582             } => {
    583                 bytes.push(4);
    584                 encode_legacy_inputs(bytes, inputs)?;
    585                 encode_outputs(bytes, outputs)?;
    586                 bytes.extend_from_slice(&fee.to_be_bytes());
    587             }
    588             Self::Transfer {
    589                 inputs,
    590                 outputs,
    591                 fee,
    592                 ..
    593             } => {
    594                 bytes.push(1);
    595                 encode_inputs(bytes, inputs)?;
    596                 encode_outputs(bytes, outputs)?;
    597                 bytes.extend_from_slice(&fee.to_be_bytes());
    598             }
    599             Self::Burn {
    600                 inputs,
    601                 change,
    602                 amount,
    603                 fee,
    604                 anchor,
    605                 ..
    606             } => {
    607                 bytes.push(2);
    608                 encode_inputs(bytes, inputs)?;
    609                 encode_outputs(bytes, change)?;
    610                 bytes.extend_from_slice(&amount.to_be_bytes());
    611                 bytes.extend_from_slice(&fee.to_be_bytes());
    612                 encode_optional_array(bytes, anchor);
    613             }
    614             Self::Mine {
    615                 recipient,
    616                 anchor,
    617                 salt,
    618                 nonce,
    619                 difficulty_bits,
    620                 proof_header,
    621                 proof_hash,
    622             } => {
    623                 bytes.push(3);
    624                 encode_address(bytes, recipient);
    625                 bytes.extend_from_slice(anchor);
    626                 bytes.extend_from_slice(&salt.to_be_bytes());
    627                 bytes.extend_from_slice(&nonce.to_be_bytes());
    628                 bytes.extend_from_slice(&difficulty_bits.to_be_bytes());
    629                 encode_optional_array(bytes, proof_header);
    630                 bytes.extend_from_slice(proof_hash);
    631             }
    632         }
    633         Ok(())
    634     }
    635 }
    636 
    637 pub const fn transaction_v2_is_active(height: u64) -> bool {
    638     match TRANSACTION_V2_ACTIVATION_HEIGHT {
    639         Some(activation_height) => height >= activation_height,
    640         None => false,
    641     }
    642 }
    643 
    644 pub fn ensure_transaction_v2_active(height: u64) -> Result<()> {
    645     if !transaction_v2_is_active(height) {
    646         bail!("transaction v2 is recognized but not consensus-active");
    647     }
    648     Ok(())
    649 }
    650 
    651 pub fn hybrid_key_commitment_address(public_key: &ProtocolPublicKey) -> Result<VersionedAddress> {
    652     if public_key.scheme() != SignatureScheme::HybridEd25519MlDsa44 {
    653         bail!("address v1 requires an Ed25519 + ML-DSA-44 public key");
    654     }
    655     Ok(VersionedAddress {
    656         version: AddressVersion::HybridKeyCommitment,
    657         payload: public_key_commitment(public_key),
    658     })
    659 }
    660 
    661 fn public_key_commitment(public_key: &ProtocolPublicKey) -> [u8; 32] {
    662     let mut hasher = Sha256::new();
    663     hasher.update(ADDRESS_V1_COMMITMENT_TAG);
    664     hasher.update([public_key.scheme().wire_id()]);
    665     match public_key.scheme() {
    666         SignatureScheme::HybridEd25519MlDsa44 => {
    667             let (ed25519, ml_dsa) = public_key.as_bytes().split_at(32);
    668             hash_length_prefixed(&mut hasher, ed25519);
    669             hash_length_prefixed(&mut hasher, ml_dsa);
    670         }
    671         SignatureScheme::Ed25519 | SignatureScheme::MlDsa44 => {
    672             hash_length_prefixed(&mut hasher, public_key.as_bytes());
    673         }
    674     }
    675     hasher.finalize().into()
    676 }
    677 
    678 fn hash_length_prefixed(hasher: &mut Sha256, bytes: &[u8]) {
    679     hasher.update((bytes.len() as u32).to_be_bytes());
    680     hasher.update(bytes);
    681 }
    682 
    683 fn encode_prefix(domain: &TransactionV2Domain) -> Result<Vec<u8>> {
    684     let mut bytes = Vec::new();
    685     bytes.extend_from_slice(TRANSACTION_V2_TAG);
    686     bytes.extend_from_slice(&TRANSACTION_V2_WIRE_VERSION.to_be_bytes());
    687     encode_bytes(&mut bytes, domain.chain_id.as_bytes(), "chain ID")?;
    688     bytes.extend_from_slice(&domain.genesis_hash);
    689     Ok(bytes)
    690 }
    691 
    692 fn encode_inputs(bytes: &mut Vec<u8>, inputs: &[TransactionV2Input]) -> Result<()> {
    693     encode_count(bytes, inputs.len(), "input count")?;
    694     for input in inputs {
    695         bytes.extend_from_slice(&input.outpoint_txid);
    696         bytes.extend_from_slice(&input.outpoint_index.to_be_bytes());
    697         encode_address(bytes, &input.owner);
    698     }
    699     Ok(())
    700 }
    701 
    702 fn decode_inputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2Input>> {
    703     let count = reader.count(MAX_V2_INPUTS, "input count")?;
    704     let mut inputs = Vec::with_capacity(count);
    705     for _ in 0..count {
    706         inputs.push(TransactionV2Input {
    707             outpoint_txid: reader.array::<32>("input transaction ID")?,
    708             outpoint_index: reader.u32("input output index")?,
    709             owner: decode_address(reader, "input owner")?,
    710         });
    711     }
    712     Ok(inputs)
    713 }
    714 
    715 fn encode_legacy_inputs(bytes: &mut Vec<u8>, inputs: &[TransactionV2LegacyInput]) -> Result<()> {
    716     encode_count(bytes, inputs.len(), "legacy input count")?;
    717     for input in inputs {
    718         match input.outpoint_id {
    719             LegacyTransactionId::Hash(hash) => {
    720                 bytes.push(0);
    721                 bytes.extend_from_slice(&hash);
    722             }
    723             LegacyTransactionId::Signature(signature) => {
    724                 bytes.push(1);
    725                 bytes.extend_from_slice(&signature);
    726             }
    727         }
    728         bytes.extend_from_slice(&input.outpoint_index.to_be_bytes());
    729         encode_address(bytes, &input.owner);
    730     }
    731     Ok(())
    732 }
    733 
    734 fn decode_legacy_inputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2LegacyInput>> {
    735     let count = reader.count(MAX_V2_INPUTS, "legacy input count")?;
    736     let mut inputs = Vec::with_capacity(count);
    737     for _ in 0..count {
    738         let outpoint_id = match reader.u8("legacy input ID kind")? {
    739             0 => LegacyTransactionId::Hash(reader.array::<32>("legacy input hash")?),
    740             1 => LegacyTransactionId::Signature(
    741                 reader.array::<64>("legacy input transaction signature")?,
    742             ),
    743             kind => bail!("unsupported legacy input ID kind {kind}"),
    744         };
    745         inputs.push(TransactionV2LegacyInput {
    746             outpoint_id,
    747             outpoint_index: reader.u32("legacy input output index")?,
    748             owner: decode_address(reader, "legacy input owner")?,
    749         });
    750     }
    751     Ok(inputs)
    752 }
    753 
    754 fn encode_outputs(bytes: &mut Vec<u8>, outputs: &[TransactionV2Output]) -> Result<()> {
    755     encode_count(bytes, outputs.len(), "output count")?;
    756     for output in outputs {
    757         encode_address(bytes, &output.address);
    758         bytes.extend_from_slice(&output.amount.to_be_bytes());
    759     }
    760     Ok(())
    761 }
    762 
    763 fn decode_outputs(reader: &mut Reader<'_>) -> Result<Vec<TransactionV2Output>> {
    764     let count = reader.count(MAX_V2_OUTPUTS, "output count")?;
    765     let mut outputs = Vec::with_capacity(count);
    766     for _ in 0..count {
    767         outputs.push(TransactionV2Output {
    768             address: decode_address(reader, "output address")?,
    769             amount: reader.u64("output amount")?,
    770         });
    771     }
    772     Ok(outputs)
    773 }
    774 
    775 fn encode_address(bytes: &mut Vec<u8>, address: &VersionedAddress) {
    776     bytes.push(address.version.wire_id());
    777     bytes.extend_from_slice(&address.payload);
    778 }
    779 
    780 fn decode_address(reader: &mut Reader<'_>, label: &str) -> Result<VersionedAddress> {
    781     let version_id = reader.u8(label)?;
    782     let version = AddressVersion::from_wire_id(version_id)
    783         .with_context(|| format!("unsupported address version {version_id}"))?;
    784     Ok(VersionedAddress {
    785         version,
    786         payload: reader.array::<32>(label)?,
    787     })
    788 }
    789 
    790 fn encode_optional_array<const N: usize>(bytes: &mut Vec<u8>, value: &Option<[u8; N]>) {
    791     match value {
    792         Some(value) => {
    793             bytes.push(1);
    794             bytes.extend_from_slice(value);
    795         }
    796         None => bytes.push(0),
    797     }
    798 }
    799 
    800 fn decode_optional_array<const N: usize>(
    801     reader: &mut Reader<'_>,
    802     label: &str,
    803 ) -> Result<Option<[u8; N]>> {
    804     match reader.u8(label)? {
    805         0 => Ok(None),
    806         1 => Ok(Some(reader.array::<N>(label)?)),
    807         marker => bail!("{label} has invalid presence marker {marker}"),
    808     }
    809 }
    810 
    811 fn encode_count(bytes: &mut Vec<u8>, count: usize, label: &str) -> Result<()> {
    812     let count = u32::try_from(count).with_context(|| format!("{label} exceeds u32"))?;
    813     bytes.extend_from_slice(&count.to_be_bytes());
    814     Ok(())
    815 }
    816 
    817 fn encode_bytes(bytes: &mut Vec<u8>, value: &[u8], label: &str) -> Result<()> {
    818     encode_count(bytes, value.len(), label)?;
    819     bytes.extend_from_slice(value);
    820     Ok(())
    821 }
    822 
    823 enum UnsignedDecoded {
    824     Migration {
    825         inputs: Vec<TransactionV2LegacyInput>,
    826         outputs: Vec<TransactionV2Output>,
    827         fee: u64,
    828     },
    829     Transfer {
    830         inputs: Vec<TransactionV2Input>,
    831         outputs: Vec<TransactionV2Output>,
    832         fee: u64,
    833     },
    834     Burn {
    835         inputs: Vec<TransactionV2Input>,
    836         change: Vec<TransactionV2Output>,
    837         amount: u64,
    838         fee: u64,
    839         anchor: Option<[u8; 32]>,
    840     },
    841     Mine {
    842         recipient: VersionedAddress,
    843         anchor: [u8; 32],
    844         salt: u64,
    845         nonce: u64,
    846         difficulty_bits: u32,
    847         proof_header: Option<[u8; STRATUM_PROOF_HEADER_BYTES]>,
    848         proof_hash: [u8; 32],
    849     },
    850 }
    851 
    852 impl UnsignedDecoded {
    853     fn with_authorizations(
    854         self,
    855         authorizations: Vec<V2SpendingAuthorization>,
    856     ) -> Result<TransactionV2> {
    857         Ok(match self {
    858             Self::Migration {
    859                 inputs,
    860                 outputs,
    861                 fee,
    862             } => TransactionV2::Migration {
    863                 inputs,
    864                 outputs,
    865                 fee,
    866                 authorizations,
    867             },
    868             Self::Transfer {
    869                 inputs,
    870                 outputs,
    871                 fee,
    872             } => TransactionV2::Transfer {
    873                 inputs,
    874                 outputs,
    875                 fee,
    876                 authorizations,
    877             },
    878             Self::Burn {
    879                 inputs,
    880                 change,
    881                 amount,
    882                 fee,
    883                 anchor,
    884             } => TransactionV2::Burn {
    885                 inputs,
    886                 change,
    887                 amount,
    888                 fee,
    889                 anchor,
    890                 authorizations,
    891             },
    892             Self::Mine {
    893                 recipient,
    894                 anchor,
    895                 salt,
    896                 nonce,
    897                 difficulty_bits,
    898                 proof_header,
    899                 proof_hash,
    900             } => {
    901                 if !authorizations.is_empty() {
    902                     bail!("mine transaction v2 cannot contain spending authorizations");
    903                 }
    904                 TransactionV2::Mine {
    905                     recipient,
    906                     anchor,
    907                     salt,
    908                     nonce,
    909                     difficulty_bits,
    910                     proof_header,
    911                     proof_hash,
    912                 }
    913             }
    914         })
    915     }
    916 }
    917 
    918 struct Reader<'a> {
    919     remaining: &'a [u8],
    920 }
    921 
    922 impl<'a> Reader<'a> {
    923     fn new(bytes: &'a [u8]) -> Self {
    924         Self { remaining: bytes }
    925     }
    926 
    927     fn take(&mut self, length: usize, label: &str) -> Result<&'a [u8]> {
    928         if self.remaining.len() < length {
    929             bail!("transaction v2 {label} is truncated");
    930         }
    931         let (value, remaining) = self.remaining.split_at(length);
    932         self.remaining = remaining;
    933         Ok(value)
    934     }
    935 
    936     fn u8(&mut self, label: &str) -> Result<u8> {
    937         Ok(self.take(1, label)?[0])
    938     }
    939 
    940     fn u16(&mut self, label: &str) -> Result<u16> {
    941         Ok(u16::from_be_bytes(self.array(label)?))
    942     }
    943 
    944     fn u32(&mut self, label: &str) -> Result<u32> {
    945         Ok(u32::from_be_bytes(self.array(label)?))
    946     }
    947 
    948     fn u64(&mut self, label: &str) -> Result<u64> {
    949         Ok(u64::from_be_bytes(self.array(label)?))
    950     }
    951 
    952     fn array<const N: usize>(&mut self, label: &str) -> Result<[u8; N]> {
    953         Ok(self
    954             .take(N, label)?
    955             .try_into()
    956             .expect("reader returned requested fixed length"))
    957     }
    958 
    959     fn count(&mut self, maximum: usize, label: &str) -> Result<usize> {
    960         let count = self.u32(label)? as usize;
    961         if count > maximum {
    962             bail!("transaction v2 {label} exceeds {maximum}");
    963         }
    964         Ok(count)
    965     }
    966 
    967     fn length_prefixed(&mut self, maximum: usize, label: &str) -> Result<&'a [u8]> {
    968         let length = self.u32(label)? as usize;
    969         if length > maximum {
    970             bail!("transaction v2 {label} exceeds {maximum} bytes");
    971         }
    972         self.take(length, label)
    973     }
    974 
    975     fn finish(self) -> Result<()> {
    976         if !self.remaining.is_empty() {
    977             bail!("transaction v2 contains trailing bytes");
    978         }
    979         Ok(())
    980     }
    981 }
    982 
    983 #[cfg(test)]
    984 mod tests {
    985     use ed25519_dalek::{Signer, SigningKey};
    986     use ml_dsa::{Keypair, MlDsa44, Seed, SigningKey as MlDsaSigningKey};
    987 
    988     use super::*;
    989     use crate::domain::hex::{decode_hex, hex_encode};
    990 
    991     fn domain() -> TransactionV2Domain {
    992         TransactionV2Domain::new("iuna-v2-test", [0x22; 32]).unwrap()
    993     }
    994 
    995     fn hybrid_public_key() -> ProtocolPublicKey {
    996         let signing_key = SigningKey::from_bytes(&[7; 32]);
    997         let mut public_key = signing_key.verifying_key().to_bytes().to_vec();
    998         let ml_dsa_signing_key = MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from([9; 32]));
    999         public_key.extend_from_slice(&ml_dsa_signing_key.verifying_key().encode());
   1000         ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, public_key).unwrap()
   1001     }
   1002 
   1003     fn hybrid_authorization(payload: &[u8]) -> V2SpendingAuthorization {
   1004         let signing_key = SigningKey::from_bytes(&[7; 32]);
   1005         let public_key = hybrid_public_key();
   1006         let mut signature = signing_key.sign(payload).to_bytes().to_vec();
   1007         let ml_dsa_signing_key = MlDsaSigningKey::<MlDsa44>::from_seed(&Seed::from([9; 32]));
   1008         signature.extend_from_slice(
   1009             &ml_dsa_signing_key
   1010                 .expanded_key()
   1011                 .sign_deterministic(payload, &[])
   1012                 .unwrap()
   1013                 .encode(),
   1014         );
   1015         V2SpendingAuthorization::new(
   1016             public_key,
   1017             ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature).unwrap(),
   1018         )
   1019         .unwrap()
   1020     }
   1021 
   1022     fn ed25519_authorization(payload: &[u8]) -> V2SpendingAuthorization {
   1023         let signing_key = SigningKey::from_bytes(&[7; 32]);
   1024         V2SpendingAuthorization::new(
   1025             ProtocolPublicKey::new(
   1026                 SignatureScheme::Ed25519,
   1027                 signing_key.verifying_key().to_bytes().to_vec(),
   1028             )
   1029             .unwrap(),
   1030             ProtocolSignature::new(
   1031                 SignatureScheme::Ed25519,
   1032                 signing_key.sign(payload).to_bytes().to_vec(),
   1033             )
   1034             .unwrap(),
   1035         )
   1036         .unwrap()
   1037     }
   1038 
   1039     fn unsigned_transfer(owner: VersionedAddress) -> TransactionV2 {
   1040         TransactionV2::Transfer {
   1041             inputs: vec![TransactionV2Input {
   1042                 outpoint_txid: [0x11; 32],
   1043                 outpoint_index: 7,
   1044                 owner,
   1045             }],
   1046             outputs: vec![TransactionV2Output {
   1047                 address: VersionedAddress {
   1048                     version: AddressVersion::HybridKeyCommitment,
   1049                     payload: [0x33; 32],
   1050                 },
   1051                 amount: 5,
   1052             }],
   1053             fee: 1,
   1054             authorizations: Vec::new(),
   1055         }
   1056     }
   1057 
   1058     #[test]
   1059     fn v2_activates_at_the_fixed_consensus_height() {
   1060         assert_eq!(TRANSACTION_V2_ACTIVATION_HEIGHT, Some(3_000));
   1061         assert!(!transaction_v2_is_active(0));
   1062         assert!(!transaction_v2_is_active(2_999));
   1063         assert!(ensure_transaction_v2_active(2_999).is_err());
   1064         assert!(transaction_v2_is_active(3_000));
   1065         assert!(transaction_v2_is_active(u64::MAX));
   1066         ensure_transaction_v2_active(3_000).unwrap();
   1067     }
   1068 
   1069     #[test]
   1070     fn hybrid_transfer_roundtrips_and_has_a_hash_id() {
   1071         let public_key = hybrid_public_key();
   1072         let owner = hybrid_key_commitment_address(&public_key).unwrap();
   1073         let mut transaction = unsigned_transfer(owner);
   1074         let signing_bytes = transaction.signing_bytes(&domain()).unwrap();
   1075         let authorization = hybrid_authorization(&signing_bytes);
   1076         if let TransactionV2::Transfer { authorizations, .. } = &mut transaction {
   1077             authorizations.push(authorization);
   1078         }
   1079 
   1080         assert_eq!(
   1081             transaction.authorizations()[0].committed_address().unwrap(),
   1082             owner
   1083         );
   1084         transaction.validate_authorization_commitments().unwrap();
   1085         transaction.verify_authorizations(&domain()).unwrap();
   1086         let encoded = transaction.encode(&domain()).unwrap();
   1087         let (decoded_domain, decoded) = TransactionV2::decode(&encoded).unwrap();
   1088         assert_eq!(decoded_domain, domain());
   1089         assert_eq!(decoded, transaction);
   1090         assert_eq!(decoded.transaction_id(&domain()).unwrap().len(), 32);
   1091 
   1092         let mut missing_authorization = transaction.clone();
   1093         if let TransactionV2::Transfer { authorizations, .. } = &mut missing_authorization {
   1094             authorizations.clear();
   1095         }
   1096         assert!(
   1097             missing_authorization
   1098                 .validate_authorization_commitments()
   1099                 .is_err()
   1100         );
   1101 
   1102         let mut wrong_owner = transaction.clone();
   1103         if let TransactionV2::Transfer { inputs, .. } = &mut wrong_owner {
   1104             inputs[0].owner.payload[0] ^= 1;
   1105         }
   1106         assert!(wrong_owner.validate_authorization_commitments().is_err());
   1107 
   1108         let mut invalid_post_quantum_signature = transaction.clone();
   1109         if let TransactionV2::Transfer { authorizations, .. } = &mut invalid_post_quantum_signature
   1110         {
   1111             let public_key = authorizations[0].public_key().clone();
   1112             let mut signature = authorizations[0].signature().as_bytes().to_vec();
   1113             signature[64] ^= 1;
   1114             authorizations[0] = V2SpendingAuthorization::new(
   1115                 public_key,
   1116                 ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature).unwrap(),
   1117             )
   1118             .unwrap();
   1119         }
   1120         assert!(
   1121             invalid_post_quantum_signature
   1122                 .verify_authorizations(&domain())
   1123                 .is_err()
   1124         );
   1125     }
   1126 
   1127     #[test]
   1128     fn version_zero_input_can_migrate_to_a_hybrid_output() {
   1129         let signing_key = SigningKey::from_bytes(&[7; 32]);
   1130         let legacy_owner = VersionedAddress {
   1131             version: AddressVersion::Ed25519PublicKey,
   1132             payload: signing_key.verifying_key().to_bytes(),
   1133         };
   1134         let hybrid_recipient = hybrid_key_commitment_address(&hybrid_public_key()).unwrap();
   1135         let mut transaction = TransactionV2::Migration {
   1136             inputs: vec![TransactionV2LegacyInput {
   1137                 outpoint_id: LegacyTransactionId::Signature([0x11; 64]),
   1138                 outpoint_index: 7,
   1139                 owner: legacy_owner,
   1140             }],
   1141             outputs: vec![TransactionV2Output {
   1142                 address: hybrid_recipient,
   1143                 amount: 5,
   1144             }],
   1145             fee: 1,
   1146             authorizations: Vec::new(),
   1147         };
   1148         let authorization = ed25519_authorization(&transaction.signing_bytes(&domain()).unwrap());
   1149         if let TransactionV2::Migration { authorizations, .. } = &mut transaction {
   1150             authorizations.push(authorization);
   1151         }
   1152 
   1153         transaction.verify_authorizations(&domain()).unwrap();
   1154         let encoded = transaction.encode(&domain()).unwrap();
   1155         let (decoded_domain, decoded) = TransactionV2::decode(&encoded).unwrap();
   1156         assert_eq!(decoded_domain, domain());
   1157         assert_eq!(decoded, transaction);
   1158         assert_eq!(
   1159             transaction.authorizations()[0]
   1160                 .authorized_address()
   1161                 .unwrap(),
   1162             legacy_owner
   1163         );
   1164 
   1165         let mut wrong_scheme = transaction;
   1166         let wrong_scheme_payload = wrong_scheme.signing_bytes(&domain()).unwrap();
   1167         if let TransactionV2::Migration { authorizations, .. } = &mut wrong_scheme {
   1168             *authorizations = vec![hybrid_authorization(&wrong_scheme_payload)];
   1169         }
   1170         assert!(wrong_scheme.verify_authorizations(&domain()).is_err());
   1171     }
   1172 
   1173     #[test]
   1174     fn decoder_fails_closed_for_versions_lengths_and_trailing_bytes() {
   1175         let transaction = TransactionV2::Mine {
   1176             recipient: VersionedAddress {
   1177                 version: AddressVersion::Ed25519PublicKey,
   1178                 payload: [3; 32],
   1179             },
   1180             anchor: [4; 32],
   1181             salt: 5,
   1182             nonce: 6,
   1183             difficulty_bits: 7,
   1184             proof_header: None,
   1185             proof_hash: [8; 32],
   1186         };
   1187         let encoded = transaction.encode(&domain()).unwrap();
   1188         assert_eq!(TransactionV2::decode(&encoded).unwrap().1, transaction);
   1189         assert_eq!(
   1190             hex_encode(&encoded),
   1191             concat!(
   1192                 "49554e412d54582d5632", // IUNA-TX-V2
   1193                 "0002",                 // wire version
   1194                 "0000000c",
   1195                 "69756e612d76322d74657374", // iuna-v2-test
   1196                 "2222222222222222222222222222222222222222222222222222222222222222",
   1197                 "03", // mine
   1198                 "00", // address version 0
   1199                 "0303030303030303030303030303030303030303030303030303030303030303",
   1200                 "0404040404040404040404040404040404040404040404040404040404040404",
   1201                 "0000000000000005", // salt
   1202                 "0000000000000006", // nonce
   1203                 "00000007",         // difficulty
   1204                 "00",               // no proof header
   1205                 "0808080808080808080808080808080808080808080808080808080808080808",
   1206                 "00000000", // no spending authorizations
   1207             )
   1208         );
   1209         assert_eq!(
   1210             hex_encode(transaction.transaction_id(&domain()).unwrap()),
   1211             "cd611549a0d156e10f9ffae00058ddd2f372f1d46564158a5ccf1621b79beaef"
   1212         );
   1213 
   1214         let mut unknown_version = encoded.clone();
   1215         unknown_version[TRANSACTION_V2_TAG.len() + 1] = 3;
   1216         assert!(TransactionV2::decode(&unknown_version).is_err());
   1217 
   1218         let mut trailing = encoded.clone();
   1219         trailing.push(0);
   1220         assert!(TransactionV2::decode(&trailing).is_err());
   1221         assert!(TransactionV2::decode(&encoded[..encoded.len() - 1]).is_err());
   1222     }
   1223 
   1224     #[test]
   1225     fn transaction_v2_fuzz_corpus_contains_valid_decoder_and_verifier_seeds() {
   1226         for seed in [
   1227             include_str!("../../fuzz/corpus/transaction_v2/valid_mine.hex"),
   1228             include_str!("../../fuzz/corpus/transaction_v2/valid_hybrid_transfer.hex"),
   1229         ] {
   1230             let encoded = decode_hex(seed.trim().strip_prefix("hex:").unwrap()).unwrap();
   1231             let (domain, transaction) = TransactionV2::decode(&encoded).unwrap();
   1232             transaction.verify_authorizations(&domain).unwrap();
   1233         }
   1234     }
   1235 
   1236     #[test]
   1237     fn transaction_id_commits_to_authorization_bytes() {
   1238         let first = TransactionV2::Mine {
   1239             recipient: VersionedAddress {
   1240                 version: AddressVersion::Ed25519PublicKey,
   1241                 payload: [3; 32],
   1242             },
   1243             anchor: [4; 32],
   1244             salt: 5,
   1245             nonce: 6,
   1246             difficulty_bits: 7,
   1247             proof_header: None,
   1248             proof_hash: [8; 32],
   1249         };
   1250         let mut second = first.clone();
   1251         if let TransactionV2::Mine { proof_hash, .. } = &mut second {
   1252             proof_hash[0] ^= 1;
   1253         }
   1254         assert_ne!(
   1255             first.transaction_id(&domain()).unwrap(),
   1256             second.transaction_id(&domain()).unwrap()
   1257         );
   1258     }
   1259 }