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 }