diff --git a/lib/state/block.rs b/lib/state/block.rs index 266fed5..e6c9f14 100644 --- a/lib/state/block.rs +++ b/lib/state/block.rs @@ -10,10 +10,19 @@ use crate::{ AmountOverflowError, Authorization, Body, FilledOutput, FilledOutputContent, FilledTransaction, GetAddress as _, GetValue as _, Header, InPoint, MerkleRoot, OutPoint, OutPointKey, OutputContent, - SpentOutput, TxData, Verify as _, + SpentOutput, TxData, Verify as _, constants, + transaction::WithdrawalValueRule, }, }; +fn withdrawal_rule_for_block(header: &Header) -> WithdrawalValueRule { + if constants::LEGACY_WITHDRAWAL_ACCOUNTING_BLOCKS.contains(&header.hash()) { + WithdrawalValueRule::PayoutOnly + } else { + WithdrawalValueRule::PayoutAndMainchainFee + } +} + /// Calculate total number of inputs across all transactions in a block body fn calculate_total_inputs(body: &Body) -> usize { body.transactions.iter().map(|t| t.inputs.len()).sum() @@ -71,17 +80,25 @@ pub fn validate( } // Process transactions for fee validation + let withdrawal_rule = withdrawal_rule_for_block(header); for filled_tx in &filled_txs { total_fees = total_fees - .checked_add(state.validate_filled_transaction(rotxn, filled_tx)?) + .checked_add( + state.validate_filled_transaction_with_withdrawal_rule( + rotxn, + filled_tx, + withdrawal_rule, + )?, + ) .ok_or(AmountOverflowError)?; } if coinbase_value > total_fees { return Err(Error::NotEnoughFees); } - let merkle_root = Body::compute_merkle_root( + let merkle_root = Body::compute_merkle_root_with_withdrawal_rule( body.coinbase.as_slice(), filled_txs.as_slice(), + withdrawal_rule, )?; if merkle_root != header.merkle_root { let err = Error::InvalidBody { @@ -175,19 +192,25 @@ pub fn prevalidate( } // Process transactions for fee validation + let withdrawal_rule = withdrawal_rule_for_block(header); for filled_transaction in &filled_transactions { total_fees = total_fees .checked_add( - state.validate_filled_transaction(rotxn, filled_transaction)?, + state.validate_filled_transaction_with_withdrawal_rule( + rotxn, + filled_transaction, + withdrawal_rule, + )?, ) .ok_or(AmountOverflowError)?; } if coinbase_value > total_fees { return Err(Error::NotEnoughFees); } - let computed_merkle_root = Body::compute_merkle_root( + let computed_merkle_root = Body::compute_merkle_root_with_withdrawal_rule( body.coinbase.as_slice(), filled_transactions.as_slice(), + withdrawal_rule, )?; if computed_merkle_root != header.merkle_root { let err = Error::InvalidBody { diff --git a/lib/state/mod.rs b/lib/state/mod.rs index 2b55e09..fecf9e2 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -14,7 +14,7 @@ use crate::{ GetValue as _, Header, InPoint, M6id, MerkleRoot, OutPoint, OutPointKey, SpentOutput, Transaction, VERSION, Verify as _, Version, WithdrawalBundle, WithdrawalBundleStatus, constants, hashes, - proto::mainchain::TwoWayPegData, + proto::mainchain::TwoWayPegData, transaction::WithdrawalValueRule, }, util::Watchable, }; @@ -493,6 +493,19 @@ impl State { &self, rotxn: &RoTxn, tx: &FilledTransaction, + ) -> Result { + self.validate_filled_transaction_with_withdrawal_rule( + rotxn, + tx, + WithdrawalValueRule::PayoutAndMainchainFee, + ) + } + + pub(crate) fn validate_filled_transaction_with_withdrawal_rule( + &self, + rotxn: &RoTxn, + tx: &FilledTransaction, + withdrawal_rule: WithdrawalValueRule, ) -> Result { let () = self.validate_reservations(tx)?; let () = self.validate_bitnames(rotxn, tx)?; @@ -506,7 +519,7 @@ impl State { }); } } - let fee = tx.fee()?; + let fee = tx.fee_with_withdrawal_rule(withdrawal_rule)?; Ok(fee) } @@ -709,6 +722,7 @@ mod test { MutableBitNameData, OutPoint, OutPointKey, Output, OutputContent, SpentOutput, Transaction, TxData, Txid, VerifyingKey, WithdrawalOutputContent, + transaction::{ComputeFeeError, WithdrawalValueRule}, }, }; @@ -935,6 +949,68 @@ mod test { Ok(()) } + #[test] + fn consensus_validator_reproduces_withdrawal_activation_transition() + -> anyhow::Result<()> { + let (_temp_dir, env, state) = fresh_state("withdrawal-activation")?; + let input = FilledOutput::new_bitcoin_value( + Address::ALL_ZEROS, + bitcoin::Amount::from_sat(5_000_000), + ); + let withdrawal = Output::new( + Address::ALL_ZEROS, + OutputContent::Withdrawal(WithdrawalOutputContent { + value: bitcoin::Amount::from_sat(2_000_000), + main_fee: bitcoin::Amount::from_sat(10_000), + main_address: "tb1qg2muwvd42czzxnh2ewrgt67rfmudzcatz9lmh4" + .parse()?, + }), + ); + let historical = FilledTransaction { + transaction: Transaction::new( + vec![OutPoint::Regular { + txid: [1; 32].into(), + vout: 0, + }], + vec![ + withdrawal.clone(), + bitcoin_filled_output(Address::ALL_ZEROS, 2_999_990).into(), + ], + ), + spent_utxos: vec![input.clone()], + }; + let rotxn = env.read_txn()?; + + assert_eq!( + state.validate_filled_transaction_with_withdrawal_rule( + &rotxn, + &historical, + WithdrawalValueRule::PayoutOnly, + )?, + bitcoin::Amount::from_sat(10) + ); + assert!(matches!( + state.validate_filled_transaction(&rotxn, &historical), + Err(Error::ComputeFee(ComputeFeeError::Underfunded)) + )); + + let corrected = FilledTransaction { + transaction: Transaction::new( + historical.transaction.inputs, + vec![ + withdrawal, + bitcoin_filled_output(Address::ALL_ZEROS, 2_989_990).into(), + ], + ), + spent_utxos: vec![input], + }; + assert_eq!( + state.validate_filled_transaction(&rotxn, &corrected)?, + bitcoin::Amount::from_sat(10) + ); + Ok(()) + } + #[test] fn sidechain_wealth() -> anyhow::Result<()> { use std::str::FromStr; diff --git a/types/constants.rs b/types/constants.rs index 6c7f412..da42308 100644 --- a/types/constants.rs +++ b/types/constants.rs @@ -3,7 +3,21 @@ use std::sync::LazyLock; use ed25519_dalek::PUBLIC_KEY_LENGTH; use hex_literal::hex; -use crate::VerifyingKey; +use crate::{BlockHash, VerifyingKey}; + +/// Drivenet blocks mined with the historical withdrawal accounting rule. +/// +/// These blocks predate the rule that includes the mainchain fee in a +/// withdrawal output's sidechain value. Restricting compatibility to this +/// exact set preserves history without weakening validation for new blocks. +pub const LEGACY_WITHDRAWAL_ACCOUNTING_BLOCKS: [BlockHash; 2] = [ + BlockHash(hex!( + "f9a7a9117bec4ed6c4fffbf3b651b60f9459a5fc881f267b271778874ad55d0f" + )), + BlockHash(hex!( + "208e6bb567efd46d073bab68d9cff041279faada60c7f27a011a56bd1f7f86b7" + )), +]; /// authorized pubkey that can make batch icann registration txs const BATCH_ICANN_VERIFYING_KEY_BYTES: [u8; PUBLIC_KEY_LENGTH] = diff --git a/types/lib.rs b/types/lib.rs index 87a59e5..9499781 100644 --- a/types/lib.rs +++ b/types/lib.rs @@ -597,6 +597,21 @@ impl Body { coinbase: &[Output], txs: &[FilledTx], ) -> Result + where + FilledTx: Borrow + Sync, + { + Self::compute_merkle_root_with_withdrawal_rule( + coinbase, + txs, + transaction::WithdrawalValueRule::PayoutAndMainchainFee, + ) + } + + pub fn compute_merkle_root_with_withdrawal_rule( + coinbase: &[Output], + txs: &[FilledTx], + withdrawal_rule: transaction::WithdrawalValueRule, + ) -> Result where FilledTx: Borrow + Sync, { @@ -619,12 +634,20 @@ impl Body { .enumerate() .map(|(idx, tx)| { let tx = tx.borrow(); - let fees = tx.get_fee().map_err(|err| { - ComputeMerkleRootError::FeeComputation { + let fees = tx + .fee_with_withdrawal_rule(withdrawal_rule) + .map_err(|err| ComputeMerkleRootError::FeeComputation { txid: tx.transaction.txid(), - source: err, - } - })?; + source: match err { + transaction::ComputeFeeError::Underfunded => { + GetFeeError::AmountUnderflow + } + transaction::ComputeFeeError::ValueInOverflow(_) + | transaction::ComputeFeeError::ValueOutOverflow(_) => { + GetFeeError::AmountOverflow + } + }, + })?; let canonical_size = tx.transaction.canonical_size(); let leaf_pre_commitment = CbmtLeafPreCommitment { fee: fees, diff --git a/types/transaction/mod.rs b/types/transaction/mod.rs index 90aeb8d..2942d08 100644 --- a/types/transaction/mod.rs +++ b/types/transaction/mod.rs @@ -224,7 +224,7 @@ mod test { transaction::{ Content, FilledContent, FilledOutput, FilledTransaction, GetValue as _, OUTPOINT_KEY_SIZE, OutPoint, OutPointKey, Output, - Transaction, output_content, + Transaction, WithdrawalValueRule, output_content, }, }; @@ -305,6 +305,73 @@ mod test { bitcoin::Amount::ZERO ); } + + #[test] + fn historical_withdrawal_changes_validity_at_activation() { + let withdrawal = Output { + address: Address::ALL_ZEROS, + content: Content::Withdrawal(output_content::WithdrawalContent { + value: bitcoin::Amount::from_sat(2_000_000), + main_fee: bitcoin::Amount::from_sat(10_000), + main_address: "tb1qg2muwvd42czzxnh2ewrgt67rfmudzcatz9lmh4" + .parse() + .unwrap(), + }), + memo: Vec::new(), + }; + let change = Output { + address: Address::ALL_ZEROS, + content: Content::Bitcoin(output_content::BitcoinContent( + bitcoin::Amount::from_sat(2_999_990), + )), + memo: Vec::new(), + }; + let funding = FilledOutput { + address: Address::ALL_ZEROS, + content: FilledContent::Bitcoin(output_content::BitcoinContent( + bitcoin::Amount::from_sat(5_000_000), + )), + memo: Vec::new(), + }; + let historical = FilledTransaction { + transaction: Transaction { + outputs: vec![withdrawal.clone(), change], + ..Default::default() + }, + spent_utxos: vec![funding.clone()], + }; + + assert_eq!( + historical + .fee_with_withdrawal_rule(WithdrawalValueRule::PayoutOnly,) + .unwrap(), + bitcoin::Amount::from_sat(10) + ); + assert!(matches!( + historical.fee(), + Err(super::ComputeFeeError::Underfunded) + )); + + let corrected = FilledTransaction { + transaction: Transaction { + outputs: vec![ + withdrawal, + Output { + address: Address::ALL_ZEROS, + content: Content::Bitcoin( + output_content::BitcoinContent( + bitcoin::Amount::from_sat(2_989_990), + ), + ), + memo: Vec::new(), + }, + ], + ..Default::default() + }, + spent_utxos: vec![funding], + }; + assert_eq!(corrected.fee().unwrap(), bitcoin::Amount::from_sat(10)); + } } /// Reference to a tx input. @@ -673,6 +740,17 @@ pub enum ComputeFeeError { ValueOutOverflow(#[source] AmountOverflowError), } +/// Consensus rule used to value withdrawal outputs. +/// +/// Drivenet history created before the mainchain-fee accounting change valued +/// a withdrawal at its payout only. Current transactions must also fund the +/// mainchain fee paid from the sidechain treasury. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WithdrawalValueRule { + PayoutOnly, + PayoutAndMainchainFee, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct FilledTransaction { pub transaction: Transaction, @@ -804,9 +882,25 @@ impl FilledTransaction { /// returns the total value in the outputs pub fn value_out(&self) -> Result { + self.value_out_with_withdrawal_rule( + WithdrawalValueRule::PayoutAndMainchainFee, + ) + } + + /// Returns total output value under an explicit withdrawal accounting rule. + pub fn value_out_with_withdrawal_rule( + &self, + rule: WithdrawalValueRule, + ) -> Result { self.outputs() .iter() - .map(GetValue::get_value) + .map(|output| match (&output.content, rule) { + ( + Content::Withdrawal(withdrawal), + WithdrawalValueRule::PayoutOnly, + ) => withdrawal.value, + _ => output.get_value(), + }) .checked_sum() .ok_or(AmountOverflowError) } @@ -814,11 +908,23 @@ impl FilledTransaction { /// returns the difference between the value spent and value out, if it is /// non-negative. pub fn fee(&self) -> Result { + self.fee_with_withdrawal_rule( + WithdrawalValueRule::PayoutAndMainchainFee, + ) + } + + /// Returns the transaction fee under an explicit withdrawal accounting + /// rule. This exists to make historical consensus activation testable; new + /// mempool transactions always use [`Self::fee`]. + pub fn fee_with_withdrawal_rule( + &self, + rule: WithdrawalValueRule, + ) -> Result { let spent_value = self .spent_value() .map_err(ComputeFeeError::ValueInOverflow)?; let value_out = self - .value_out() + .value_out_with_withdrawal_rule(rule) .map_err(ComputeFeeError::ValueOutOverflow)?; if spent_value < value_out { Err(ComputeFeeError::Underfunded)