iuna

iuna

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

commit 4b128569026a1c51ec4c18e3dee131e5d3f961b3
parent c55bb70ba47377a2763cb1bffdf78c2ad515f73b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 15 Sep 2026 06:38:00 +0200

feat(protocol): validate v2 transactions in mempool

Diffstat:
MCHANGELOG.md | 3+++
Mdocs/quantum-migration.md | 6++++++
Msrc/domain.rs | 1+
Msrc/domain/ledger_apply.rs | 1+
Msrc/domain/ledger_chain.rs | 4++++
Msrc/domain/ledger_mempool.rs | 3+++
Msrc/domain/ledger_queries.rs | 8+++++++-
Msrc/domain/ledger_state.rs | 4+++-
Asrc/domain/ledger_v2.rs | 564+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/transaction_v2.rs | 32+++++++++++++++++++++++++++++++-
10 files changed, 623 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -7,6 +7,9 @@ from the Git history and Conventional Commit titles by `deployment.sh`. ### Added +- validate height-gated v2 migrations and hybrid transfers against the live UTXO model +- admit v2 transactions to a separate byte-limited mempool without legacy double-spends + - expose complete peer handshake details in the P2P interface - add dormant transaction-v2 encoding and activation gating - verify both components of dormant hybrid transaction authorizations diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -141,6 +141,12 @@ This key capability alone does not create spendable address-v1 outputs. The wall offer the address until transaction-v2 submission, mempool, block, gossip, persistence, and fee accounting are connected and activated together on the candidate network. +The domain layer now has a separate v2 pending pool. It checks the fixed height boundary, the +ledger-derived chain domain, canonical encoded byte limits, UTXO ownership, value conservation, +legacy/v2 double-spends, and dependent v2 transactions. It is not reachable from the public API or +P2P layer and is not selected into blocks yet, so this is still an integration stage rather than an +activation-ready release. + ## Other trust boundaries - P2P node IDs need versioned, algorithm-tagged proofs independent of wallet activation. diff --git a/src/domain.rs b/src/domain.rs @@ -22,6 +22,7 @@ mod ledger_prepare; mod ledger_queries; mod ledger_reveal; mod ledger_state; +mod ledger_v2; mod mine_policy; mod mining; mod profile; diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -153,6 +153,7 @@ impl Ledger { .collect(); self.refresh_pending_pool_byte_counters()?; self.promote_orphan_transactions()?; + self.revalidate_pending_v2()?; self.vdf_rounds = self.next_vdf_rounds_after_tip(); Ok(()) } diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -94,6 +94,8 @@ impl Ledger { orphans: Vec::new(), pending_bytes: 0, orphan_bytes: 0, + pending_v2: Vec::new(), + pending_v2_bytes: 0, mine_reward: MINE_REWARD, mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], initial_vdf_rounds: vdf_rounds, @@ -210,6 +212,8 @@ impl Ledger { orphans: Vec::new(), pending_bytes: 0, orphan_bytes: 0, + pending_v2: Vec::new(), + pending_v2_bytes: 0, mine_reward: MINE_REWARD, mine_difficulty_windows: vec![launch_profile.mine_difficulty_bits], initial_vdf_rounds: vdf_rounds, diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -85,6 +85,9 @@ impl Ledger { if transaction_inputs_spent_by(&transaction, &self.orphans) { return Ok(TransactionSubmitOutcome::ConflictsWithPending); } + if self.transaction_conflicts_with_pending_v2(&transaction) { + return Ok(TransactionSubmitOutcome::ConflictsWithPending); + } let mut utxos = self.utxos_after_valid_pending()?; if transaction_has_missing_inputs(&transaction, &utxos) { diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -118,7 +118,7 @@ impl Ledger { } else { Default::default() }, - pending_transactions: self.pending.len(), + pending_transactions: self.pending.len().saturating_add(self.pending_v2.len()), } } @@ -526,9 +526,15 @@ impl Ledger { &self.pending } + pub fn pending_v2(&self) -> &[super::TransactionV2] { + &self.pending_v2 + } + pub(crate) fn clear_pending_transactions(&mut self) { self.pending.clear(); self.pending_bytes = 0; + self.pending_v2.clear(); + self.pending_v2_bytes = 0; } pub fn orphan_transactions(&self) -> &[Transaction] { diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs @@ -5,7 +5,7 @@ use std::{ use super::{ Amount, Block, BurnTicket, FinalityCheckpoint, LaunchProfile, LineageOwnerValues, OutPoint, - Transaction, TxOutput, UtxoLineageRoot, + Transaction, TransactionV2, TxOutput, UtxoLineageRoot, }; use crate::compact::CompactBlockContext; @@ -23,6 +23,8 @@ pub struct Ledger { pub(super) orphans: Vec<Transaction>, pub(super) pending_bytes: usize, pub(super) orphan_bytes: usize, + pub(super) pending_v2: Vec<TransactionV2>, + pub(super) pending_v2_bytes: usize, pub(super) mine_reward: Amount, /// PoW difficulty after each completed retarget window. Index zero is the /// launch difficulty; index `n` is the difficulty at anchor height diff --git a/src/domain/ledger_v2.rs b/src/domain/ledger_v2.rs @@ -0,0 +1,564 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; + +use super::{ + AddressNetwork, AddressVersion, Ledger, LegacyTransactionId, MAX_PENDING_POOL_BYTES, OutPoint, + Transaction, TransactionSubmitOutcome, TransactionV2, TransactionV2Domain, TransactionV2Input, + TransactionV2LegacyInput, TransactionV2Output, TxOutput, decode_hex_array, + encode_versioned_address, ensure_transaction_v2_active, hex_encode, +}; + +impl Ledger { + pub fn transaction_v2_domain(&self) -> Result<TransactionV2Domain> { + TransactionV2Domain::new( + self.launch_profile.profile_id.clone(), + decode_hex_array::<32>(self.genesis_hash()) + .context("ledger genesis hash is not a 32-byte hexadecimal value")?, + ) + } + + /// Decodes a v2 envelope and rejects a chain ID or genesis hash chosen by the sender. + pub fn decode_transaction_v2(&self, encoded: &[u8]) -> Result<TransactionV2> { + let (domain, transaction) = TransactionV2::decode(encoded)?; + if domain != self.transaction_v2_domain()? { + bail!("transaction v2 belongs to a different chain domain"); + } + Ok(transaction) + } + + /// Validates the v2 rules against confirmed state at an explicit candidate block height. + /// Mempool and block code can share this method without consulting wall-clock or local config. + pub fn validate_transaction_v2_at_height( + &self, + transaction: &TransactionV2, + height: u64, + ) -> Result<()> { + self.validated_v2_utxos_at_height(transaction, height)?; + Ok(()) + } + + pub fn submit_transaction_v2( + &mut self, + transaction: TransactionV2, + ) -> Result<TransactionSubmitOutcome> { + self.submit_transaction_v2_at_height(transaction, self.height().saturating_add(1)) + } + + fn submit_transaction_v2_at_height( + &mut self, + transaction: TransactionV2, + height: u64, + ) -> Result<TransactionSubmitOutcome> { + ensure_transaction_v2_active(height)?; + let domain = self.transaction_v2_domain()?; + let transaction_id = transaction.transaction_id(&domain)?; + if self + .pending_v2 + .iter() + .any(|pending| pending.transaction_id(&domain).ok() == Some(transaction_id)) + { + return Ok(TransactionSubmitOutcome::AlreadyKnown); + } + if transaction.fee() == 0 { + bail!("public transaction v2 fee must be greater than zero"); + } + let transaction_bytes = transaction.encoded_size_bytes(&domain)?; + if transaction_bytes > self.launch_profile.max_block_bytes { + bail!("transaction v2 exceeds the maximum block byte budget"); + } + if self.pending.len().saturating_add(self.pending_v2.len()) + >= self.launch_profile.max_pending_transactions + { + bail!("mempool is full"); + } + if self + .pending_bytes + .saturating_add(self.pending_v2_bytes) + .checked_add(transaction_bytes) + .is_none_or(|bytes| bytes > MAX_PENDING_POOL_BYTES) + { + bail!("mempool byte limit exceeded"); + } + + let mut utxos = self.utxos_after_spendable_pending()?; + for pending in &self.pending_v2 { + apply_prevalidated_transaction_v2_to_utxos( + pending, + &domain, + AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + &mut utxos, + )?; + } + apply_transaction_v2_to_utxos( + &transaction, + &domain, + AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + &mut utxos, + )?; + self.pending_v2.push(transaction); + self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(transaction_bytes); + Ok(TransactionSubmitOutcome::Added) + } + + pub(super) fn transaction_conflicts_with_pending_v2(&self, transaction: &Transaction) -> bool { + transaction.inputs().iter().any(|legacy_input| { + self.pending_v2.iter().any(|pending| { + let TransactionV2::Migration { inputs, .. } = pending else { + return false; + }; + inputs.iter().any(|v2_input| { + v2_input.outpoint_index == legacy_input.outpoint.index + && legacy_transaction_id_hex(&v2_input.outpoint_id) + == legacy_input.outpoint.txid + }) + }) + }) + } + + pub(super) fn revalidate_pending_v2(&mut self) -> Result<()> { + self.revalidate_pending_v2_at_height(self.height().saturating_add(1)) + } + + fn revalidate_pending_v2_at_height(&mut self, height: u64) -> Result<()> { + if ensure_transaction_v2_active(height).is_err() { + self.pending_v2.clear(); + self.pending_v2_bytes = 0; + return Ok(()); + } + let domain = self.transaction_v2_domain()?; + let network = AddressNetwork::from_profile_id(&self.launch_profile.profile_id); + let mut utxos = self.utxos_after_spendable_pending()?; + let pending = std::mem::take(&mut self.pending_v2); + self.pending_v2_bytes = 0; + for transaction in pending { + let bytes = transaction.encoded_size_bytes(&domain)?; + let mut candidate_utxos = utxos.clone(); + if apply_prevalidated_transaction_v2_to_utxos( + &transaction, + &domain, + network, + &mut candidate_utxos, + ) + .is_ok() + { + utxos = candidate_utxos; + self.pending_v2.push(transaction); + self.pending_v2_bytes = self.pending_v2_bytes.saturating_add(bytes); + } + } + Ok(()) + } + + pub(super) fn validated_v2_utxos_at_height( + &self, + transaction: &TransactionV2, + height: u64, + ) -> Result<BTreeMap<OutPoint, TxOutput>> { + ensure_transaction_v2_active(height)?; + let domain = self.transaction_v2_domain()?; + let mut utxos = self.utxos.clone(); + apply_transaction_v2_to_utxos( + transaction, + &domain, + AddressNetwork::from_profile_id(&self.launch_profile.profile_id), + &mut utxos, + )?; + Ok(utxos) + } +} + +pub(super) fn apply_transaction_v2_to_utxos( + transaction: &TransactionV2, + domain: &TransactionV2Domain, + network: AddressNetwork, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<()> { + apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, true) +} + +fn apply_prevalidated_transaction_v2_to_utxos( + transaction: &TransactionV2, + domain: &TransactionV2Domain, + network: AddressNetwork, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<()> { + apply_transaction_v2_to_utxos_with_policy(transaction, domain, network, utxos, false) +} + +fn apply_transaction_v2_to_utxos_with_policy( + transaction: &TransactionV2, + domain: &TransactionV2Domain, + network: AddressNetwork, + utxos: &mut BTreeMap<OutPoint, TxOutput>, + verify_authorizations: bool, +) -> Result<()> { + let (spent, outputs, fee) = match transaction { + TransactionV2::Migration { + inputs, + outputs, + fee, + .. + } => ( + spend_legacy_inputs(inputs, utxos)?, + outputs.as_slice(), + *fee, + ), + TransactionV2::Transfer { + inputs, + outputs, + fee, + .. + } => ( + spend_v2_inputs(inputs, network, utxos)?, + outputs.as_slice(), + *fee, + ), + TransactionV2::Burn { .. } => { + bail!("transaction v2 burns are not integrated into live burn consensus") + } + TransactionV2::Mine { .. } => { + bail!("transaction v2 mining is not integrated into live proof consensus") + } + }; + + let credited = sum_outputs(outputs)?; + let required = credited + .checked_add(fee) + .context("transaction v2 output value plus fee overflows")?; + if spent != required { + bail!("transaction v2 input value does not equal outputs plus fee"); + } + + // Verify expensive signatures only for an untrusted candidate and only after cheap state and + // conservation checks. Pending entries are immutable and were verified on admission. + if verify_authorizations { + transaction.verify_authorizations(domain)?; + } + let transaction_id = hex_encode(transaction.transaction_id(domain)?); + for (index, output) in outputs.iter().enumerate() { + let index = u32::try_from(index).context("transaction v2 output index exceeds u32")?; + let outpoint = OutPoint { + txid: transaction_id.clone(), + index, + }; + if utxos + .insert( + outpoint, + TxOutput { + address: internal_address(output.address, network)?, + amount: output.amount, + }, + ) + .is_some() + { + bail!("transaction v2 recreates an existing outpoint"); + } + } + Ok(()) +} + +fn spend_legacy_inputs( + inputs: &[TransactionV2LegacyInput], + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<u64> { + let mut spent = 0_u64; + for input in inputs { + let outpoint = OutPoint { + txid: legacy_transaction_id_hex(&input.outpoint_id), + index: input.outpoint_index, + }; + let output = utxos + .remove(&outpoint) + .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?; + let expected_owner = internal_address(input.owner, AddressNetwork::Mainnet)?; + if output.address != expected_owner { + bail!("transaction v2 legacy input owner does not match the referenced output"); + } + spent = spent + .checked_add(output.amount) + .context("transaction v2 input value overflows")?; + } + Ok(spent) +} + +fn legacy_transaction_id_hex(transaction_id: &LegacyTransactionId) -> String { + match transaction_id { + LegacyTransactionId::Hash(value) => hex_encode(value), + LegacyTransactionId::Signature(value) => hex_encode(value), + } +} + +fn spend_v2_inputs( + inputs: &[TransactionV2Input], + network: AddressNetwork, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<u64> { + let mut spent = 0_u64; + for input in inputs { + let outpoint = OutPoint { + txid: hex_encode(input.outpoint_txid), + index: input.outpoint_index, + }; + let output = utxos + .remove(&outpoint) + .with_context(|| format!("transaction v2 input {} is not spendable", outpoint.id()))?; + if output.address != internal_address(input.owner, network)? { + bail!("transaction v2 input owner does not match the referenced output"); + } + spent = spent + .checked_add(output.amount) + .context("transaction v2 input value overflows")?; + } + Ok(spent) +} + +fn sum_outputs(outputs: &[TransactionV2Output]) -> Result<u64> { + outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction v2 output value overflows") + }) +} + +fn internal_address(address: super::VersionedAddress, network: AddressNetwork) -> Result<String> { + match address.version { + AddressVersion::Ed25519PublicKey => Ok(hex_encode(address.payload)), + AddressVersion::HybridKeyCommitment => encode_versioned_address(address, network), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use crate::domain::{SignatureScheme, TransactionV2Output, Wallet}; + + #[test] + fn migration_validation_switches_at_3000_and_creates_a_hybrid_utxo() { + let wallet = Wallet::from_seed("v2-ledger-migration-wallet"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let transaction = ledger.build_v2_migration(&wallet, 3).unwrap(); + + assert!( + ledger + .validate_transaction_v2_at_height(&transaction, 2_999) + .is_err() + ); + ledger + .validate_transaction_v2_at_height(&transaction, 3_000) + .unwrap(); + + let domain = ledger.transaction_v2_domain().unwrap(); + let transaction_id = hex_encode(transaction.transaction_id(&domain).unwrap()); + let utxos = ledger + .validated_v2_utxos_at_height(&transaction, 3_000) + .unwrap(); + assert_eq!( + utxos.get(&OutPoint { + txid: transaction_id, + index: 0, + }), + Some(&TxOutput { + address: wallet.hybrid_address(AddressNetwork::Mainnet), + amount: 97, + }) + ); + assert_eq!(utxos.len(), 1); + } + + #[test] + fn hybrid_output_requires_both_signatures_when_spent() { + let wallet = Wallet::from_seed("v2-ledger-hybrid-spend-wallet"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); + let domain = ledger.transaction_v2_domain().unwrap(); + let migration_id = migration.transaction_id(&domain).unwrap(); + let migrated_utxos = ledger + .validated_v2_utxos_at_height(&migration, 3_000) + .unwrap(); + let mut migrated_ledger = ledger.clone(); + migrated_ledger.utxos = migrated_utxos; + + let mut transfer = TransactionV2::Transfer { + inputs: vec![TransactionV2Input { + outpoint_txid: migration_id, + outpoint_index: 0, + owner: wallet.hybrid_versioned_address(), + }], + outputs: vec![TransactionV2Output { + address: wallet.hybrid_versioned_address(), + amount: 96, + }], + fee: 1, + authorizations: Vec::new(), + }; + let payload = transfer.signing_bytes(&domain).unwrap(); + let authorization = wallet + .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload) + .unwrap(); + assert_eq!( + authorization.scheme(), + SignatureScheme::HybridEd25519MlDsa44 + ); + if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { + authorizations.push(authorization); + } + migrated_ledger + .validate_transaction_v2_at_height(&transfer, 3_001) + .unwrap(); + + if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { + let public_key = authorizations[0].public_key().clone(); + let classical_signature = authorizations[0].signature().as_bytes()[..64].to_vec(); + authorizations[0] = super::super::V2SpendingAuthorization::new( + super::super::ProtocolPublicKey::new( + SignatureScheme::Ed25519, + public_key.as_bytes()[..32].to_vec(), + ) + .unwrap(), + super::super::ProtocolSignature::new(SignatureScheme::Ed25519, classical_signature) + .unwrap(), + ) + .unwrap(); + } + assert!( + migrated_ledger + .validate_transaction_v2_at_height(&transfer, 3_001) + .is_err() + ); + } + + #[test] + fn decoder_rejects_an_attacker_selected_chain_domain() { + let wallet = Wallet::from_seed("v2-ledger-domain-wallet"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let transaction = ledger.build_v2_migration(&wallet, 1).unwrap(); + let foreign = TransactionV2Domain::new("foreign-chain", [0x44; 32]).unwrap(); + let encoded = transaction.encode(&foreign).unwrap(); + + assert!(ledger.decode_transaction_v2(&encoded).is_err()); + } + + #[test] + fn v2_mempool_enforces_activation_deduplication_and_legacy_conflicts() { + let wallet = Wallet::from_seed("v2-ledger-mempool-wallet"); + let recipient = Wallet::from_seed("v2-ledger-mempool-recipient"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let transaction = ledger.build_v2_migration(&wallet, 3).unwrap(); + let conflicting_legacy = ledger + .build_transfer(&wallet, recipient.address(), 90, 1) + .unwrap(); + + assert!( + ledger + .submit_transaction_v2_at_height(transaction.clone(), 2_999) + .is_err() + ); + assert!(ledger.pending_v2().is_empty()); + assert_eq!( + ledger + .submit_transaction_v2_at_height(transaction.clone(), 3_000) + .unwrap(), + TransactionSubmitOutcome::Added + ); + assert_eq!(ledger.pending_v2().len(), 1); + assert!(ledger.pending_v2_bytes > 0); + assert_eq!(ledger.status().pending_transactions, 1); + assert_eq!( + ledger + .submit_transaction_v2_at_height(transaction, 3_000) + .unwrap(), + TransactionSubmitOutcome::AlreadyKnown + ); + assert_eq!( + ledger + .submit_transaction_with_outcome(conflicting_legacy) + .unwrap(), + TransactionSubmitOutcome::ConflictsWithPending + ); + } + + #[test] + fn v2_mempool_accepts_a_transfer_spending_a_pending_migration() { + let wallet = Wallet::from_seed("v2-ledger-dependent-mempool-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); + let domain = ledger.transaction_v2_domain().unwrap(); + let migration_id = migration.transaction_id(&domain).unwrap(); + ledger + .submit_transaction_v2_at_height(migration, 3_000) + .unwrap(); + + let mut transfer = TransactionV2::Transfer { + inputs: vec![TransactionV2Input { + outpoint_txid: migration_id, + outpoint_index: 0, + owner: wallet.hybrid_versioned_address(), + }], + outputs: vec![TransactionV2Output { + address: wallet.hybrid_versioned_address(), + amount: 96, + }], + fee: 1, + authorizations: Vec::new(), + }; + let payload = transfer.signing_bytes(&domain).unwrap(); + let authorization = wallet + .sign_v2_authorization(wallet.hybrid_versioned_address(), &payload) + .unwrap(); + if let TransactionV2::Transfer { authorizations, .. } = &mut transfer { + authorizations.push(authorization); + } + + assert_eq!( + ledger + .submit_transaction_v2_at_height(transfer, 3_000) + .unwrap(), + TransactionSubmitOutcome::Added + ); + assert_eq!(ledger.pending_v2().len(), 2); + } + + #[test] + fn v2_mempool_revalidation_drops_a_migration_spent_by_new_chain_state() { + let wallet = Wallet::from_seed("v2-ledger-revalidation-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let migration = ledger.build_v2_migration(&wallet, 3).unwrap(); + ledger + .submit_transaction_v2_at_height(migration, 3_000) + .unwrap(); + assert_eq!(ledger.pending_v2().len(), 1); + + ledger.utxos.clear(); + ledger.revalidate_pending_v2_at_height(3_000).unwrap(); + assert!(ledger.pending_v2().is_empty()); + assert_eq!(ledger.pending_v2_bytes, 0); + } + + #[test] + fn invalid_v2_candidate_does_not_mutate_the_mempool() { + let wallet = Wallet::from_seed("v2-ledger-invalid-candidate-wallet"); + let mut ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); + let mut migration = ledger.build_v2_migration(&wallet, 3).unwrap(); + if let TransactionV2::Migration { authorizations, .. } = &mut migration { + let public_key = authorizations[0].public_key().clone(); + let mut signature = authorizations[0].signature().as_bytes().to_vec(); + signature[0] ^= 1; + authorizations[0] = super::super::V2SpendingAuthorization::new( + public_key, + super::super::ProtocolSignature::new(SignatureScheme::Ed25519, signature).unwrap(), + ) + .unwrap(); + } + + assert!( + ledger + .submit_transaction_v2_at_height(migration, 3_000) + .is_err() + ); + assert!(ledger.pending_v2().is_empty()); + assert_eq!(ledger.pending_v2_bytes, 0); + } +} diff --git a/src/domain/transaction_v2.rs b/src/domain/transaction_v2.rs @@ -167,6 +167,15 @@ impl V2SpendingAuthorization { } impl TransactionV2 { + pub fn fee(&self) -> u64 { + match self { + Self::Migration { fee, .. } | Self::Transfer { fee, .. } | Self::Burn { fee, .. } => { + *fee + } + Self::Mine { .. } => 0, + } + } + /// Canonical bytes signed by every spending authorization. Signatures are excluded. pub fn signing_bytes(&self, domain: &TransactionV2Domain) -> Result<Vec<u8>> { self.validate_unsigned_shape()?; @@ -296,6 +305,10 @@ impl TransactionV2 { Ok(Sha256::digest(self.encode(domain)?).into()) } + pub fn encoded_size_bytes(&self, domain: &TransactionV2Domain) -> Result<usize> { + Ok(self.encode(domain)?.len()) + } + pub fn validate_authorization_commitments(&self) -> Result<()> { self.validate_shape()?; match self { @@ -440,6 +453,12 @@ impl TransactionV2 { if inputs.is_empty() || outputs.is_empty() { bail!("transaction v2 transfer requires inputs and outputs"); } + if outputs + .iter() + .any(|output| output.address.version != AddressVersion::HybridKeyCommitment) + { + bail!("transaction v2 transfer outputs must use address v1"); + } let unique = inputs .iter() .map(|input| (input.outpoint_txid, input.outpoint_index)) @@ -448,10 +467,21 @@ impl TransactionV2 { bail!("transaction v2 transfer contains a duplicate input"); } } - Self::Burn { inputs, amount, .. } => { + Self::Burn { + inputs, + change, + amount, + .. + } => { if inputs.is_empty() || *amount == 0 { bail!("transaction v2 burn requires inputs and a positive amount"); } + if change + .iter() + .any(|output| output.address.version != AddressVersion::HybridKeyCommitment) + { + bail!("transaction v2 burn change must use address v1"); + } let unique = inputs .iter() .map(|input| (input.outpoint_txid, input.outpoint_index))