iuna

iuna

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

wallet.rs (25189B)


      1 use std::{
      2     collections::BTreeMap,
      3     fmt,
      4     sync::{
      5         Arc, OnceLock, RwLock,
      6         atomic::{AtomicBool, AtomicU32, Ordering},
      7     },
      8 };
      9 
     10 use secrecy::{ExposeSecret, SecretBox, zeroize::Zeroize};
     11 use sha2::{Digest, Sha256};
     12 
     13 use super::block::LeaderProofPayload;
     14 use super::{
     15     AddressNetwork, BurnBundle, BurnBundlePayload, LeaderProof, ProtocolPublicKey,
     16     ProtocolSignature, SignatureScheme, V2SpendingAuthorization, VersionedAddress,
     17     ed25519_public_key, encode_versioned_address, hex_encode, hybrid_key_commitment_address,
     18     ml_dsa44_public_key, sign_ed25519, sign_ml_dsa44,
     19 };
     20 
     21 const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed";
     22 const WALLET_ML_DSA44_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-seed-v1";
     23 const WALLET_ML_DSA44_CHILD_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-child-seed-v1";
     24 
     25 #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
     26 pub enum HybridAddressBranch {
     27     External,
     28     Reward,
     29 }
     30 
     31 impl HybridAddressBranch {
     32     fn domain_label(self) -> &'static str {
     33         match self {
     34             Self::External => "external",
     35             Self::Reward => "reward",
     36         }
     37     }
     38 }
     39 
     40 struct HybridChildKey {
     41     signing_seed: SecretBox<[u8; 32]>,
     42     public_key: ProtocolPublicKey,
     43     address: VersionedAddress,
     44 }
     45 
     46 type HybridChildKeyCache = BTreeMap<(HybridAddressBranch, u32), Arc<HybridChildKey>>;
     47 
     48 #[derive(Clone)]
     49 pub struct Wallet {
     50     address: String,
     51     signing_seed: Arc<SecretBox<[u8; 32]>>,
     52     ml_dsa44_signing_seed: Arc<SecretBox<[u8; 32]>>,
     53     hybrid_public_key: Arc<OnceLock<ProtocolPublicKey>>,
     54     hybrid_child_keys: Arc<RwLock<HybridChildKeyCache>>,
     55     external_address_cursor: Arc<AtomicU32>,
     56     external_discovery_tip: Arc<RwLock<Option<String>>>,
     57     reward_address_cursor: Arc<AtomicU32>,
     58     reward_discovery_tip: Arc<RwLock<Option<String>>>,
     59     historical_address_recovery_complete: Arc<AtomicBool>,
     60 }
     61 
     62 impl Wallet {
     63     pub fn from_seed(seed: &str) -> Self {
     64         let signing_seed = derive_signing_seed(WALLET_SEED_DOMAIN, seed);
     65         let ml_dsa44_signing_seed = derive_signing_seed(WALLET_ML_DSA44_SEED_DOMAIN, seed);
     66         let address = hex_encode(ed25519_public_key(signing_seed.expose_secret()));
     67         Self {
     68             address,
     69             signing_seed: Arc::new(signing_seed),
     70             ml_dsa44_signing_seed: Arc::new(ml_dsa44_signing_seed),
     71             hybrid_public_key: Arc::new(OnceLock::new()),
     72             hybrid_child_keys: Arc::new(RwLock::new(BTreeMap::new())),
     73             external_address_cursor: Arc::new(AtomicU32::new(0)),
     74             external_discovery_tip: Arc::new(RwLock::new(None)),
     75             reward_address_cursor: Arc::new(AtomicU32::new(0)),
     76             reward_discovery_tip: Arc::new(RwLock::new(None)),
     77             historical_address_recovery_complete: Arc::new(AtomicBool::new(false)),
     78         }
     79     }
     80 
     81     pub fn address(&self) -> &str {
     82         &self.address
     83     }
     84 
     85     pub fn legacy_versioned_address(&self) -> VersionedAddress {
     86         VersionedAddress {
     87             version: super::AddressVersion::Ed25519PublicKey,
     88             payload: ed25519_public_key(self.signing_seed.expose_secret()),
     89         }
     90     }
     91 
     92     /// Returns the committed hybrid address derived from the existing wallet seed phrase.
     93     /// This does not make transaction v2 consensus-active.
     94     pub fn hybrid_versioned_address(&self) -> VersionedAddress {
     95         hybrid_key_commitment_address(self.hybrid_public_key())
     96             .expect("wallet always constructs a valid hybrid public key")
     97     }
     98 
     99     pub fn hybrid_address(&self, network: AddressNetwork) -> String {
    100         encode_versioned_address(self.hybrid_versioned_address(), network)
    101             .expect("wallet hybrid address has a valid fixed-size commitment")
    102     }
    103 
    104     pub fn hybrid_versioned_address_at(
    105         &self,
    106         branch: HybridAddressBranch,
    107         index: u32,
    108     ) -> VersionedAddress {
    109         if branch == HybridAddressBranch::External && index == 0 {
    110             return self.hybrid_versioned_address();
    111         }
    112         self.hybrid_child_key(branch, index).address
    113     }
    114 
    115     pub fn hybrid_address_at(
    116         &self,
    117         branch: HybridAddressBranch,
    118         index: u32,
    119         network: AddressNetwork,
    120     ) -> String {
    121         encode_versioned_address(self.hybrid_versioned_address_at(branch, index), network)
    122             .expect("wallet hybrid address has a valid fixed-size commitment")
    123     }
    124 
    125     pub fn hybrid_public_key(&self) -> &ProtocolPublicKey {
    126         self.hybrid_public_key.get_or_init(|| {
    127             let mut public_key =
    128                 Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.public_key_bytes());
    129             public_key.extend_from_slice(&ed25519_public_key(self.signing_seed.expose_secret()));
    130             public_key.extend_from_slice(&ml_dsa44_public_key(
    131                 self.ml_dsa44_signing_seed.expose_secret(),
    132             ));
    133             ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, public_key)
    134                 .expect("wallet hybrid public key has the scheme-defined length")
    135         })
    136     }
    137 
    138     pub(crate) fn external_address_cursor(&self) -> u32 {
    139         self.external_address_cursor.load(Ordering::Relaxed)
    140     }
    141 
    142     pub(crate) fn advance_external_address_cursor(&self, index: u32) {
    143         self.external_address_cursor
    144             .fetch_max(index, Ordering::Relaxed);
    145     }
    146 
    147     pub(crate) fn external_discovery_tip_matches(&self, tip: &str) -> bool {
    148         self.external_discovery_tip
    149             .read()
    150             .expect("wallet external discovery lock is not poisoned")
    151             .as_deref()
    152             == Some(tip)
    153     }
    154 
    155     pub(crate) fn mark_external_discovery_tip(&self, tip: &str) {
    156         *self
    157             .external_discovery_tip
    158             .write()
    159             .expect("wallet external discovery lock is not poisoned") = Some(tip.to_string());
    160     }
    161 
    162     pub(crate) fn reward_address_cursor(&self) -> u32 {
    163         self.reward_address_cursor.load(Ordering::Relaxed)
    164     }
    165 
    166     pub(crate) fn advance_reward_address_cursor(&self, index: u32) {
    167         self.reward_address_cursor
    168             .fetch_max(index, Ordering::Relaxed);
    169     }
    170 
    171     pub(crate) fn reward_discovery_tip_matches(&self, tip: &str) -> bool {
    172         self.reward_discovery_tip
    173             .read()
    174             .expect("wallet reward discovery lock is not poisoned")
    175             .as_deref()
    176             == Some(tip)
    177     }
    178 
    179     pub(crate) fn mark_reward_discovery_tip(&self, tip: &str) {
    180         *self
    181             .reward_discovery_tip
    182             .write()
    183             .expect("wallet reward discovery lock is not poisoned") = Some(tip.to_string());
    184     }
    185 
    186     pub(crate) fn historical_address_recovery_complete(&self) -> bool {
    187         self.historical_address_recovery_complete
    188             .load(Ordering::Relaxed)
    189     }
    190 
    191     pub(crate) fn mark_historical_address_recovery_complete(&self) {
    192         self.historical_address_recovery_complete
    193             .store(true, Ordering::Relaxed);
    194     }
    195 
    196     /// Creates both signatures over the same canonical transaction-v2 payload.
    197     pub fn sign_hybrid_authorization(
    198         &self,
    199         payload: &[u8],
    200     ) -> anyhow::Result<V2SpendingAuthorization> {
    201         let mut signature =
    202             Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.signature_bytes());
    203         signature.extend_from_slice(&sign_ed25519(self.signing_seed.expose_secret(), payload));
    204         signature.extend_from_slice(&sign_ml_dsa44(
    205             self.ml_dsa44_signing_seed.expose_secret(),
    206             payload,
    207         )?);
    208         V2SpendingAuthorization::new(
    209             self.hybrid_public_key().clone(),
    210             ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature)?,
    211         )
    212     }
    213 
    214     /// Signs a transaction-v2 input owned by this wallet. Existing version-0 value uses its
    215     /// original Ed25519 key; migrated version-1 value uses the hybrid key.
    216     pub fn sign_v2_authorization(
    217         &self,
    218         owner: VersionedAddress,
    219         payload: &[u8],
    220     ) -> anyhow::Result<V2SpendingAuthorization> {
    221         if owner == self.legacy_versioned_address() {
    222             return V2SpendingAuthorization::new(
    223                 ProtocolPublicKey::new(SignatureScheme::Ed25519, owner.payload.to_vec())?,
    224                 ProtocolSignature::new(
    225                     SignatureScheme::Ed25519,
    226                     sign_ed25519(self.signing_seed.expose_secret(), payload).to_vec(),
    227                 )?,
    228             );
    229         }
    230         if owner == self.hybrid_versioned_address() {
    231             return self.sign_hybrid_authorization(payload);
    232         }
    233         let child = self
    234             .hybrid_child_keys
    235             .read()
    236             .expect("wallet hybrid child-key cache lock is not poisoned")
    237             .values()
    238             .find(|child| child.address == owner)
    239             .cloned();
    240         if let Some(child) = child {
    241             return sign_hybrid_authorization_with_key(
    242                 self.signing_seed.expose_secret(),
    243                 child.signing_seed.expose_secret(),
    244                 &child.public_key,
    245                 payload,
    246             );
    247         }
    248         anyhow::bail!("transaction v2 input is not owned by this wallet")
    249     }
    250 
    251     fn hybrid_child_key(&self, branch: HybridAddressBranch, index: u32) -> Arc<HybridChildKey> {
    252         let descriptor = (branch, index);
    253         if let Some(key) = self
    254             .hybrid_child_keys
    255             .read()
    256             .expect("wallet hybrid child-key cache lock is not poisoned")
    257             .get(&descriptor)
    258         {
    259             return Arc::clone(key);
    260         }
    261         let signing_seed =
    262             derive_child_signing_seed(self.ml_dsa44_signing_seed.expose_secret(), branch, index);
    263         let mut bytes =
    264             Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.public_key_bytes());
    265         bytes.extend_from_slice(&ed25519_public_key(self.signing_seed.expose_secret()));
    266         bytes.extend_from_slice(&ml_dsa44_public_key(signing_seed.expose_secret()));
    267         let public_key = ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, bytes)
    268             .expect("wallet hybrid child public key has the scheme-defined length");
    269         let address = hybrid_key_commitment_address(&public_key)
    270             .expect("wallet always constructs a valid hybrid child public key");
    271         let key = Arc::new(HybridChildKey {
    272             signing_seed,
    273             public_key,
    274             address,
    275         });
    276         self.hybrid_child_keys
    277             .write()
    278             .expect("wallet hybrid child-key cache lock is not poisoned")
    279             .insert(descriptor, Arc::clone(&key));
    280         key
    281     }
    282 
    283     pub(super) fn sign_payload(&self, payload: &str) -> String {
    284         self.sign_bytes(payload.as_bytes())
    285     }
    286 
    287     pub(super) fn sign_bytes(&self, payload: &[u8]) -> String {
    288         hex_encode(sign_ed25519(self.signing_seed.expose_secret(), payload))
    289     }
    290 
    291     pub(super) fn leader_proof(&self, payload: &LeaderProofPayload) -> LeaderProof {
    292         let signature = self.sign_payload(&payload.canonical());
    293         LeaderProof {
    294             ticket_id: payload.ticket_id.clone(),
    295             public_key: self.address.clone(),
    296             signature,
    297         }
    298     }
    299 
    300     pub(super) fn burn_bundle(&self, payload: BurnBundlePayload) -> BurnBundle {
    301         let signature = self.sign_payload(&payload.canonical());
    302         BurnBundle {
    303             height: payload.height,
    304             prev_hash: payload.prev_hash,
    305             slot: payload.slot,
    306             member: self.address.clone(),
    307             reward_address: payload.reward_address,
    308             burns: payload.burns,
    309             burns_v2: payload.burns_v2,
    310             signature,
    311         }
    312     }
    313 }
    314 
    315 impl fmt::Debug for Wallet {
    316     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    317         formatter
    318             .debug_struct("Wallet")
    319             .field("address", &self.address)
    320             .field("signing_seed", &"[REDACTED]")
    321             .field("ml_dsa44_signing_seed", &"[REDACTED]")
    322             .finish()
    323     }
    324 }
    325 
    326 impl PartialEq for Wallet {
    327     fn eq(&self, other: &Self) -> bool {
    328         self.address == other.address
    329     }
    330 }
    331 
    332 impl Eq for Wallet {}
    333 
    334 fn derive_signing_seed(domain: &str, seed: &str) -> SecretBox<[u8; 32]> {
    335     let mut hasher = Sha256::new();
    336     hasher.update(domain.as_bytes());
    337     hasher.update(b":");
    338     hasher.update(seed.as_bytes());
    339     let mut seed_hash = hasher.finalize();
    340     let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| {
    341         signing_seed.copy_from_slice(&seed_hash);
    342     });
    343     seed_hash.zeroize();
    344     signing_seed
    345 }
    346 
    347 fn derive_child_signing_seed(
    348     parent: &[u8; 32],
    349     branch: HybridAddressBranch,
    350     index: u32,
    351 ) -> SecretBox<[u8; 32]> {
    352     let mut hasher = Sha256::new();
    353     hasher.update(WALLET_ML_DSA44_CHILD_SEED_DOMAIN.as_bytes());
    354     hasher.update(b":");
    355     hasher.update(branch.domain_label().as_bytes());
    356     hasher.update(b":");
    357     hasher.update(index.to_be_bytes());
    358     hasher.update(b":");
    359     hasher.update(parent);
    360     let mut seed_hash = hasher.finalize();
    361     let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| {
    362         signing_seed.copy_from_slice(&seed_hash);
    363     });
    364     seed_hash.zeroize();
    365     signing_seed
    366 }
    367 
    368 fn sign_hybrid_authorization_with_key(
    369     ed25519_signing_seed: &[u8; 32],
    370     ml_dsa44_signing_seed: &[u8; 32],
    371     public_key: &ProtocolPublicKey,
    372     payload: &[u8],
    373 ) -> anyhow::Result<V2SpendingAuthorization> {
    374     let mut signature = Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.signature_bytes());
    375     signature.extend_from_slice(&sign_ed25519(ed25519_signing_seed, payload));
    376     signature.extend_from_slice(&sign_ml_dsa44(ml_dsa44_signing_seed, payload)?);
    377     V2SpendingAuthorization::new(
    378         public_key.clone(),
    379         ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature)?,
    380     )
    381 }
    382 
    383 #[cfg(test)]
    384 mod tests {
    385     use secrecy::ExposeSecret;
    386 
    387     use super::{HybridAddressBranch, Wallet};
    388     use crate::domain::{
    389         AddressNetwork, LegacyTransactionId, SignatureScheme, TransactionV2, TransactionV2Domain,
    390         TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output, hex_encode,
    391         verify_ed25519, verify_ml_dsa44,
    392     };
    393 
    394     #[test]
    395     fn debug_output_redacts_the_wallet_signing_seed() {
    396         let wallet = Wallet::from_seed("debug-redaction-wallet-seed");
    397         let signing_seed = hex_encode(wallet.signing_seed.expose_secret());
    398         let ml_dsa44_signing_seed = hex_encode(wallet.ml_dsa44_signing_seed.expose_secret());
    399         let debug = format!("{wallet:?}");
    400 
    401         assert!(debug.contains("[REDACTED]"));
    402         assert!(!debug.contains(&signing_seed));
    403         assert!(!debug.contains(&ml_dsa44_signing_seed));
    404     }
    405 
    406     #[test]
    407     fn existing_seed_deterministically_derives_a_hybrid_address() {
    408         let first = Wallet::from_seed("hybrid-wallet-seed");
    409         let second = Wallet::from_seed("hybrid-wallet-seed");
    410         let other = Wallet::from_seed("other-hybrid-wallet-seed");
    411 
    412         assert!(first.hybrid_public_key.get().is_none());
    413         assert_eq!(first.address(), second.address());
    414         assert_eq!(first.hybrid_public_key(), second.hybrid_public_key());
    415         assert_eq!(
    416             first.hybrid_address(AddressNetwork::Mainnet),
    417             "iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s"
    418         );
    419         assert_eq!(
    420             first.hybrid_address_at(HybridAddressBranch::External, 1, AddressNetwork::Mainnet),
    421             "iuna1pkxdcktlzf5tc2p59xns2nccq7rg5zjhwg2zn5r9u73c5j9jxgk8s0e5l4e"
    422         );
    423         assert!(first.hybrid_public_key.get().is_some());
    424         assert_eq!(
    425             first.hybrid_address(AddressNetwork::Mainnet),
    426             second.hybrid_address(AddressNetwork::Mainnet)
    427         );
    428         assert_ne!(
    429             first.hybrid_address(AddressNetwork::Mainnet),
    430             other.hybrid_address(AddressNetwork::Mainnet)
    431         );
    432         assert_ne!(
    433             first.hybrid_address(AddressNetwork::Mainnet),
    434             first.hybrid_address(AddressNetwork::Testnet)
    435         );
    436         assert_eq!(
    437             first.hybrid_public_key().scheme(),
    438             SignatureScheme::HybridEd25519MlDsa44
    439         );
    440         assert_eq!(
    441             first.hybrid_address(AddressNetwork::Mainnet),
    442             "iuna1py9qlrnw6cm3mpz26hwwkww9spaa96zrw2g34gu0p4y3ea5cqhg0qa82x9s"
    443         );
    444     }
    445 
    446     #[test]
    447     fn browser_v2_transfer_vector_matches_node_encoding_and_signatures() {
    448         let wallet = Wallet::from_seed("hybrid-wallet-seed");
    449         let owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 0);
    450         let recipient = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1);
    451         let change = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2);
    452         let domain = TransactionV2Domain::new("iuna-mainnet-candidate", [0x11; 32]).unwrap();
    453         let mut transaction = TransactionV2::Transfer {
    454             inputs: vec![TransactionV2Input {
    455                 outpoint_txid: [0x22; 32],
    456                 outpoint_index: 7,
    457                 owner,
    458             }],
    459             outputs: vec![
    460                 TransactionV2Output {
    461                     address: recipient,
    462                     amount: 1_000_000,
    463                 },
    464                 TransactionV2Output {
    465                     address: change,
    466                     amount: 995_921,
    467                 },
    468             ],
    469             fee: 4_079,
    470             authorizations: Vec::new(),
    471         };
    472         let payload = transaction.signing_bytes(&domain).unwrap();
    473         let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap();
    474         let TransactionV2::Transfer { authorizations, .. } = &mut transaction else {
    475             unreachable!();
    476         };
    477         authorizations.push(authorization);
    478 
    479         transaction.verify_authorizations(&domain).unwrap();
    480         assert_eq!(transaction.encoded_size_bytes(&domain).unwrap(), 4_079);
    481         assert_eq!(
    482             hex_encode(transaction.transaction_id(&domain).unwrap()),
    483             "bda748b6ba9fd6550ca47d580f980a1c00d0050a20992750265a62d23b0b590d"
    484         );
    485     }
    486 
    487     #[test]
    488     fn browser_reward_transfer_vector_matches_node_encoding_and_signatures() {
    489         let wallet = Wallet::from_seed("hybrid-wallet-seed");
    490         let owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0);
    491         let recipient = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1);
    492         let change = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2);
    493         let domain = TransactionV2Domain::new("iuna-mainnet-candidate", [0x11; 32]).unwrap();
    494         let mut transaction = TransactionV2::Transfer {
    495             inputs: vec![TransactionV2Input {
    496                 outpoint_txid: [0x44; 32],
    497                 outpoint_index: 8,
    498                 owner,
    499             }],
    500             outputs: vec![
    501                 TransactionV2Output {
    502                     address: recipient,
    503                     amount: 1_000_000,
    504                 },
    505                 TransactionV2Output {
    506                     address: change,
    507                     amount: 995_921,
    508                 },
    509             ],
    510             fee: 4_079,
    511             authorizations: Vec::new(),
    512         };
    513         let payload = transaction.signing_bytes(&domain).unwrap();
    514         let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap();
    515         let TransactionV2::Transfer { authorizations, .. } = &mut transaction else {
    516             unreachable!();
    517         };
    518         authorizations.push(authorization);
    519 
    520         transaction.verify_authorizations(&domain).unwrap();
    521         assert_eq!(transaction.encoded_size_bytes(&domain).unwrap(), 4_079);
    522         assert_eq!(
    523             hex_encode(transaction.transaction_id(&domain).unwrap()),
    524             "558cee1dfa1425e0b214681445e3d0641a898e15245f80d4524d2b98d762df88"
    525         );
    526     }
    527 
    528     #[test]
    529     fn browser_v2_migration_vector_matches_node_encoding_and_signature() {
    530         let wallet = Wallet::from_seed("hybrid-wallet-seed");
    531         let owner = wallet.legacy_versioned_address();
    532         let destination = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2);
    533         let domain = TransactionV2Domain::new("iuna-mainnet-candidate", [0x11; 32]).unwrap();
    534         let mut transaction = TransactionV2::Migration {
    535             inputs: vec![TransactionV2LegacyInput {
    536                 outpoint_id: LegacyTransactionId::Hash([0x33; 32]),
    537                 outpoint_index: 4,
    538                 owner,
    539             }],
    540             outputs: vec![TransactionV2Output {
    541                 address: destination,
    542                 amount: 1_999_693,
    543             }],
    544             fee: 307,
    545             authorizations: Vec::new(),
    546         };
    547         let payload = transaction.signing_bytes(&domain).unwrap();
    548         let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap();
    549         let TransactionV2::Migration { authorizations, .. } = &mut transaction else {
    550             unreachable!();
    551         };
    552         authorizations.push(authorization);
    553 
    554         transaction.verify_authorizations(&domain).unwrap();
    555         assert_eq!(transaction.encoded_size_bytes(&domain).unwrap(), 307);
    556         assert_eq!(
    557             hex_encode(transaction.transaction_id(&domain).unwrap()),
    558             "37539c9d89a391c599b838c3f415e7adc93a36c22cd4fb5fe44a79433f336075"
    559         );
    560     }
    561 
    562     #[test]
    563     fn hybrid_authorization_signs_both_components_over_the_same_payload() {
    564         let wallet = Wallet::from_seed("hybrid-signing-wallet-seed");
    565         let payload = b"canonical transaction-v2 payload";
    566         let authorization = wallet.sign_hybrid_authorization(payload).unwrap();
    567         let signature = authorization.signature().as_bytes();
    568         let public_key = authorization.public_key().as_bytes();
    569         let ed25519_public_key: &[u8; 32] = public_key[..32].try_into().unwrap();
    570         let ed25519_signature: &[u8; 64] = signature[..64].try_into().unwrap();
    571         let ml_dsa44_public_key: &[u8; 1_312] = public_key[32..].try_into().unwrap();
    572         let ml_dsa44_signature: &[u8; 2_420] = signature[64..].try_into().unwrap();
    573 
    574         assert_eq!(
    575             authorization.committed_address().unwrap(),
    576             wallet.hybrid_versioned_address()
    577         );
    578         verify_ed25519(
    579             ed25519_public_key,
    580             payload,
    581             ed25519_signature,
    582             "wallet test",
    583         )
    584         .unwrap();
    585         verify_ml_dsa44(
    586             ml_dsa44_public_key,
    587             payload,
    588             ml_dsa44_signature,
    589             "wallet test",
    590         )
    591         .unwrap();
    592         assert!(
    593             verify_ml_dsa44(
    594                 ml_dsa44_public_key,
    595                 b"tampered",
    596                 ml_dsa44_signature,
    597                 "wallet test",
    598             )
    599             .is_err()
    600         );
    601     }
    602 
    603     #[test]
    604     fn v2_authorization_uses_the_scheme_required_by_the_owned_address() {
    605         let wallet = Wallet::from_seed("v2-migration-wallet-seed");
    606         let payload = b"migration payload";
    607 
    608         let legacy = wallet
    609             .sign_v2_authorization(wallet.legacy_versioned_address(), payload)
    610             .unwrap();
    611         assert_eq!(legacy.scheme(), SignatureScheme::Ed25519);
    612         assert_eq!(
    613             legacy.authorized_address().unwrap(),
    614             wallet.legacy_versioned_address()
    615         );
    616 
    617         let hybrid = wallet
    618             .sign_v2_authorization(wallet.hybrid_versioned_address(), payload)
    619             .unwrap();
    620         assert_eq!(hybrid.scheme(), SignatureScheme::HybridEd25519MlDsa44);
    621         assert!(
    622             wallet
    623                 .sign_v2_authorization(
    624                     Wallet::from_seed("another-wallet").legacy_versioned_address(),
    625                     payload,
    626                 )
    627                 .is_err()
    628         );
    629     }
    630 
    631     #[test]
    632     fn child_hybrid_addresses_are_deterministic_separated_and_spendable() {
    633         let first = Wallet::from_seed("rotating-hybrid-wallet-seed");
    634         let restored = Wallet::from_seed("rotating-hybrid-wallet-seed");
    635         let external_zero = first.hybrid_versioned_address_at(HybridAddressBranch::External, 0);
    636         let external_one = first.hybrid_versioned_address_at(HybridAddressBranch::External, 1);
    637         let reward_zero = first.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0);
    638 
    639         assert_eq!(external_zero, first.hybrid_versioned_address());
    640         assert_eq!(
    641             external_one,
    642             restored.hybrid_versioned_address_at(HybridAddressBranch::External, 1)
    643         );
    644         assert_ne!(external_one, external_zero);
    645         assert_ne!(reward_zero, external_one);
    646         assert_eq!(
    647             Wallet::from_seed("hybrid-wallet-seed").hybrid_address_at(
    648                 HybridAddressBranch::Reward,
    649                 0,
    650                 AddressNetwork::Mainnet,
    651             ),
    652             "iuna1pvxpc35gavamxw0tvqj3ms82gwtvchh7mdyzqdttx7ktx7pfkgz4qkaq24k"
    653         );
    654 
    655         let authorization = first
    656             .sign_v2_authorization(external_one, b"rotated child spend")
    657             .unwrap();
    658         assert_eq!(authorization.committed_address().unwrap(), external_one);
    659     }
    660 }