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 }