8 Commits 5d855eb621 ... 1dfd8b94a0

Author SHA1 Message Date
  brid 1dfd8b94a0 money: test burned fees excluded from reward 2 weeks ago
  brid 58ffd3756c darkfid: use claimable fees in reward reporting 2 weeks ago
  brid 060655d4ec validator: apply miner-claimable fee accounting 2 weeks ago
  brid b077af3e46 money: stop fee call accumulator updates 2 weeks ago
  brid cd9692bb90 fee: harden fee calldata parsing 2 weeks ago
  brid 72dbe9f135 sdk: add fallible money fee parsing 2 weeks ago
  brid a444e8737c fee: replace legacy fee calculation 2 weeks ago
  brid 0d4c2dbc44 sdk: add checked fee-burning helpers 2 weeks ago

+ 1 - 1
bin/app/src/app/schema/wallet/send.rs

@@ -104,7 +104,7 @@ pub async fn make(
             let mut fees: u64 = 0;
             for call in tx.calls.iter() {
                 if call.data.is_money_fee() {
-                    if let Ok(fee) = darkfi_serial::deserialize(&call.data.data[1..9]) {
+                    if let Ok(fee) = call.data.money_fee_value() {
                         fees = fees.saturating_add(fee);
                     }
                 }

+ 28 - 19
bin/darkfid/src/registry/model.rs

@@ -41,9 +41,11 @@ use darkfi::{
     Error, Result,
 };
 use darkfi_money_contract::{
-    client::pow_reward_v1::PoWRewardCallBuilder, MoneyFunction, MONEY_CONTRACT_ZKAS_MINT_NS_V1,
+    client::pow_reward_v1::PoWRewardCallBuilder, model::MoneyPoWRewardParamsV1, MoneyFunction,
+    MONEY_CONTRACT_ZKAS_MINT_NS_V1,
 };
 use darkfi_sdk::{
+    blockchain::expected_reward,
     crypto::{
         keypair::{Address, Keypair, Network, SecretKey},
         pasta_prelude::PrimeField,
@@ -195,28 +197,32 @@ impl BlockTemplate {
     /// Note: always check if block contains transactions before
     /// calling this function.
     pub async fn reward(&self) -> Result<u64> {
-        Ok(deserialize_async::<u64>(&self.block.txs.last().unwrap().calls[0].data.data[1..9])
-            .await?)
+        let Some(producer_tx) = self.block.txs.last() else {
+            return Err(Error::BlockContainsNoTransactions(
+                self.block.header.template_hash().as_string(),
+            ))
+        };
+
+        let Some(call) = producer_tx.calls.first() else {
+            return Err(Error::ParseFailed("producer transaction contains no calls"))
+        };
+
+        if !call.data.is_money_pow_reward() {
+            return Err(Error::ParseFailed("producer transaction is not Money::PoWRewardV1"))
+        }
+
+        let params: MoneyPoWRewardParamsV1 = deserialize_async(&call.data.data[1..]).await?;
+        Ok(params.input.value)
     }
 
-    /// Return block fees.
+    /// Return block miner-claimable fees.
     ///
     /// Note: always check if block contains transactions before
     /// calling this function.
     pub async fn fees(&self) -> Result<u64> {
-        let mut fees = 0;
-        'outer: for tx in &self.block.txs[..self.block.txs.len() - 1] {
-            for call in &tx.calls {
-                if !call.data.is_money_fee() {
-                    continue
-                }
-
-                fees += deserialize_async::<u64>(&call.data.data[1..9]).await?;
-                continue 'outer
-            }
-        }
-
-        Ok(fees)
+        let reward = self.reward().await?;
+        let expected = expected_reward(self.block.header.height);
+        reward.checked_sub(expected).ok_or(Error::SubtractionUnderflow)
     }
 
     /// Return block reward excluding fees and fees values.
@@ -227,8 +233,11 @@ impl BlockTemplate {
             ))
         }
 
-        let fees = self.fees().await?;
-        let reward = self.reward().await? - fees;
+        let reward = self.reward().await?;
+        let fees = reward
+            .checked_sub(expected_reward(self.block.header.height))
+            .ok_or(Error::SubtractionUnderflow)?;
+        let reward = reward.checked_sub(fees).ok_or(Error::SubtractionUnderflow)?;
 
         Ok((reward, fees))
     }

+ 2 - 2
bin/drk/src/common.rs

@@ -27,7 +27,7 @@ use darkfi_sdk::{
     },
     pasta::pallas,
 };
-use darkfi_serial::{deserialize, serialize};
+use darkfi_serial::serialize;
 use prettytable::{format, row, Table};
 
 use crate::money::BALANCE_BASE10_DECIMALS;
@@ -232,7 +232,7 @@ pub fn pretty_tx(tx: &Transaction) -> String {
 
     for (i, call) in tx.calls.iter().enumerate() {
         if call.data.is_money_fee() {
-            if let Ok(fee) = deserialize(&call.data.data[1..9]) {
+            if let Ok(fee) = call.data.money_fee_value() {
                 fees.push(format!("{} DRK", encode_base10(fee, BALANCE_BASE10_DECIMALS)));
                 fees_total = fees_total.checked_add(fee).unwrap_or_else(|| {
                     fees_overflow = true;

+ 27 - 7
bin/drk/src/money.rs

@@ -45,7 +45,6 @@ use darkfi_money_contract::{
     MoneyFunction, MONEY_CONTRACT_ZKAS_FEE_NS_V1,
 };
 use darkfi_sdk::{
-    blockchain::compute_fee,
     bridgetree::Position,
     crypto::{
         keypair::{Address, Keypair, PublicKey, SecretKey, StandardAddress},
@@ -54,6 +53,7 @@ use darkfi_sdk::{
         BaseBlind, FuncId, MerkleNode, MerkleTree, ScalarBlind, MONEY_CONTRACT_ID,
     },
     dark_tree::DarkLeaf,
+    fee::minimum_fee,
     pasta::pallas,
     ContractCall,
 };
@@ -123,6 +123,24 @@ pub const MONEY_ALIASES_COL_TOKEN_ID: &str = "token_id";
 
 pub const BALANCE_BASE10_DECIMALS: usize = 8;
 
+const MONEY_FEE_PREFIX_LEN: usize = 9;
+
+fn parse_money_function(data: &[u8]) -> Result<MoneyFunction> {
+    let Some(func) = data.first() else {
+        return Err(Error::ParseFailed("money call data is empty"))
+    };
+
+    Ok(MoneyFunction::try_from(*func)?)
+}
+
+async fn parse_money_fee_params(data: &[u8]) -> Result<MoneyFeeParamsV1> {
+    if data.len() < MONEY_FEE_PREFIX_LEN {
+        return Err(Error::ParseFailed("money fee call data is too short"))
+    }
+
+    Ok(deserialize_async(&data[MONEY_FEE_PREFIX_LEN..]).await?)
+}
+
 impl Drk {
     /// Initialize wallet with tables for the Money contract.
     pub async fn initialize_money(&self, output: &mut Vec<String>) -> WalletDbResult<()> {
@@ -770,10 +788,10 @@ impl Drk {
 
         let call = &calls[*call_idx];
         let data = &call.data.data;
-        match MoneyFunction::try_from(data[0])? {
+        match parse_money_function(data)? {
             MoneyFunction::FeeV1 => {
                 scan_cache_log!(scan_cache, "[parse_money_call] Found Money::FeeV1 call");
-                let params: MoneyFeeParamsV1 = deserialize_async(&data[9..]).await?;
+                let params = parse_money_fee_params(data).await?;
                 nullifiers.push(params.input.nullifier);
                 if !params.output.tx_local {
                     coins.push((params.output.coin, params.output.note, false));
@@ -1080,9 +1098,9 @@ impl Drk {
         let mut nullifiers: Vec<Nullifier> = vec![];
 
         let data = &call.data.data;
-        match MoneyFunction::try_from(data[0])? {
+        match parse_money_function(data)? {
             MoneyFunction::FeeV1 => {
-                let params: MoneyFeeParamsV1 = deserialize_async(&data[9..]).await?;
+                let params = parse_money_fee_params(data).await?;
                 nullifiers.push(params.input.nullifier);
             }
             MoneyFunction::TransferV1 => {
@@ -1291,7 +1309,9 @@ impl Drk {
     ) -> Result<(ContractCall, Vec<Proof>, Vec<SecretKey>)> {
         // First we verify the fee-less transaction to see how much fee it requires for execution
         // and verification.
-        let required_fee = compute_fee(&FEE_CALL_GAS) + self.get_tx_fee(tx, false).await?;
+        let fee_call_fee = minimum_fee(FEE_CALL_GAS)?;
+        let tx_fee = self.get_tx_fee(tx, false).await?;
+        let required_fee = fee_call_fee.checked_add(tx_fee).ok_or(Error::AdditionOverflow)?;
 
         // Knowing the total gas, we can now find an OwnCoin of enough value
         // so that we can create a valid Money::Fee call.
@@ -1400,7 +1420,7 @@ impl Drk {
                 continue
             }
 
-            match MoneyFunction::try_from(call.data.data[0])? {
+            match parse_money_function(&call.data.data)? {
                 MoneyFunction::FeeV1 => {
                     return Err(Error::Custom("Fee call already exists".to_string()))
                 }

+ 9 - 11
bin/explorer/src/rpc.rs

@@ -27,9 +27,7 @@ use darkfi::{
     tx::Transaction,
     util::{encoding::base64, parse::encode_base10},
 };
-use darkfi_money_contract::MoneyFunction;
-use darkfi_sdk::crypto::contract_id::MONEY_CONTRACT_ID;
-use darkfi_serial::{deserialize_async, serialize_async};
+use darkfi_serial::serialize_async;
 use monero::{consensus::encode::Encodable, VarInt};
 use tiny_keccak::{Hasher, Keccak};
 use tinyjson::JsonValue;
@@ -82,15 +80,15 @@ impl TransactionInfo {
         let mut fee = 0;
         let mut calls = Vec::with_capacity(tx.calls.len());
         for call in &tx.calls {
-            let func = call.data.data[0];
+            let func = call.data.data.first().copied();
 
-            if call.data.contract_id == *MONEY_CONTRACT_ID && func == MoneyFunction::FeeV1 as u8 {
-                fee = deserialize_async(&call.data.data[1..9]).await.unwrap();
+            if let Ok(parsed_fee) = call.data.money_fee_value() {
+                fee = parsed_fee;
             }
 
             calls.push(ContractCallInfo::new(
                 call.data.contract_id.to_string(),
-                format!("0x{:02x}", func),
+                func.map(|func| format!("0x{func:02x}")).unwrap_or_else(|| "empty".to_string()),
                 call.data.data.len() as u64,
             ));
         }
@@ -132,15 +130,15 @@ impl ExplTxInfo {
         let mut fee = 0;
         let mut calls = Vec::with_capacity(tx.calls.len());
         for call in &tx.calls {
-            let func = call.data.data[0];
+            let func = call.data.data.first().copied();
 
-            if call.data.contract_id == *MONEY_CONTRACT_ID && func == MoneyFunction::FeeV1 as u8 {
-                fee = deserialize_async(&call.data.data[1..9]).await.unwrap();
+            if let Ok(parsed_fee) = call.data.money_fee_value() {
+                fee = parsed_fee;
             }
 
             calls.push(ContractCallInfo::new(
                 call.data.contract_id.to_string(),
-                format!("0x{:02x}", func),
+                func.map(|func| format!("0x{func:02x}")).unwrap_or_else(|| "empty".to_string()),
                 call.data.data.len() as u64,
             ));
         }

+ 1 - 1
script/research/gg/src/main.rs

@@ -156,7 +156,7 @@ fn main() -> Result<()> {
                     let file = file?;
                     let bytes = base64::decode(read_to_string(file.path())?.trim()).unwrap();
                     let tx = deserialize_async(&bytes).await?;
-                    apply_transaction(&overlay, 0, pow_target, &tx, &mut tree).await?;
+                    apply_transaction(&overlay, 0, pow_target, &tx, &mut tree, false).await?;
                     genesis_block.txs.push(tx);
                 }
 

+ 10 - 10
script/research/tx-replayer/src/main.rs

@@ -35,10 +35,10 @@ use darkfi::{
     zk::VerifyingKey,
 };
 use darkfi_sdk::{
-    blockchain::compute_fee,
     crypto::{ContractId, MerkleTree, PublicKey},
     dark_tree::dark_forest_leaf_vec_integrity_check,
     deploy::DeployParamsV1,
+    fee::minimum_fee,
     pasta::pallas,
     tx::TransactionHash,
 };
@@ -301,14 +301,14 @@ async fn verify_transaction_wasm(
     let total_gas_used = gas_data.total_gas_used();
 
     if verify_fee {
-        // Deserialize the fee call to find the paid fee
-        let fee: u64 = match deserialize_async(&tx.calls[fee_call_idx].data.data[1..9]).await {
+        // Extract the paid fee from the fee call.
+        let fee = match tx.calls[fee_call_idx].data.money_fee_value() {
             Ok(v) => v,
             Err(_) => return Err(TxVerifyFailed::InvalidFee.into()),
         };
 
         // Compute the required fee for this transaction
-        let required_fee = compute_fee(&total_gas_used);
+        let required_fee = minimum_fee(total_gas_used)?;
 
         // Check that enough fee has been paid for the used gas in this transaction
         if required_fee > fee {
@@ -476,14 +476,14 @@ async fn verify_transaction_zkps(
     let total_gas_used = gas_data.total_gas_used();
 
     if verify_fee {
-        // Deserialize the fee call to find the paid fee
-        let fee: u64 = match deserialize_async(&tx.calls[fee_call_idx].data.data[1..9]).await {
+        // Extract the paid fee from the fee call.
+        let fee = match tx.calls[fee_call_idx].data.money_fee_value() {
             Ok(v) => v,
             Err(_) => return Err(TxVerifyFailed::InvalidFee.into()),
         };
 
         // Compute the required fee for this transaction
-        let required_fee = compute_fee(&total_gas_used);
+        let required_fee = minimum_fee(total_gas_used)?;
 
         // Check that enough fee has been paid for the used gas in this transaction
         if required_fee > fee {
@@ -630,14 +630,14 @@ async fn verify_transaction_signatures(
     let total_gas_used = gas_data.total_gas_used();
 
     if verify_fee {
-        // Deserialize the fee call to find the paid fee
-        let fee: u64 = match deserialize_async(&tx.calls[fee_call_idx].data.data[1..9]).await {
+        // Extract the paid fee from the fee call.
+        let fee = match tx.calls[fee_call_idx].data.money_fee_value() {
             Ok(v) => v,
             Err(_) => return Err(TxVerifyFailed::InvalidFee.into()),
         };
 
         // Compute the required fee for this transaction
-        let required_fee = compute_fee(&total_gas_used);
+        let required_fee = minimum_fee(total_gas_used)?;
 
         // Check that enough fee has been paid for the used gas in this transaction
         if required_fee > fee {

+ 7 - 4
src/contract/money/src/client/pow_reward_v1.rs

@@ -19,7 +19,7 @@
 use darkfi::{
     zk::{Proof, ProvingKey},
     zkas::ZkBinary,
-    Result,
+    Error, Result,
 };
 use darkfi_sdk::{
     blockchain::expected_reward,
@@ -65,7 +65,7 @@ pub struct PoWRewardCallBuilder {
     pub signature_keypair: Keypair,
     /// Rewarded block height
     pub block_height: u32,
-    /// Rewarded block transactions paid fees
+    /// Rewarded block transactions miner-claimable fees
     pub fees: u64,
     /// Optional recipient's public key, in case we want to mint to a different address
     pub recipient: Option<PublicKey>,
@@ -152,12 +152,15 @@ impl PoWRewardCallBuilder {
     }
 
     pub fn build(&self) -> Result<PoWRewardCallDebris> {
-        let reward = expected_reward(self.block_height) + self.fees;
+        let reward = expected_reward(self.block_height)
+            .checked_add(self.fees)
+            .ok_or(Error::AdditionOverflow)?;
         self._build(reward)
     }
 
     /// This function should only be used for testing, as PoW reward values are predefined
     pub fn build_with_custom_reward(&self, reward: u64) -> Result<PoWRewardCallDebris> {
-        self._build(reward + self.fees)
+        let reward = reward.checked_add(self.fees).ok_or(Error::AdditionOverflow)?;
+        self._build(reward)
     }
 }

+ 10 - 4
src/contract/money/src/entrypoint.rs

@@ -19,7 +19,7 @@
 use darkfi_sdk::{
     crypto::{pasta_prelude::Field, smt::EMPTY_NODES_FP, ContractId, MerkleNode, MerkleTree},
     dark_tree::DarkLeaf,
-    error::ContractResult,
+    error::{ContractError, ContractResult},
     msg,
     pasta::pallas,
     wasm, ContractCall,
@@ -40,6 +40,12 @@ use crate::{
     MONEY_CONTRACT_NULLIFIER_ROOTS_TREE, MONEY_CONTRACT_TOKEN_FREEZE_TREE,
 };
 
+fn parse_money_function(data: &[u8]) -> Result<MoneyFunction, ContractError> {
+    let Some(func) = data.first() else { return Err(ContractError::InvalidFunction) };
+
+    MoneyFunction::try_from(*func)
+}
+
 /// `Money::Fee` functions
 mod fee_v1;
 use fee_v1::{
@@ -230,7 +236,7 @@ fn get_metadata(cid: ContractId, ix: &[u8]) -> ContractResult {
     let call_idx = wasm::util::get_call_index()? as usize;
     let calls: Vec<DarkLeaf<ContractCall>> = deserialize(ix)?;
     let self_ = &calls[call_idx].data;
-    let func = MoneyFunction::try_from(self_.data[0])?;
+    let func = parse_money_function(&self_.data)?;
 
     let metadata = match func {
         MoneyFunction::FeeV1 => {
@@ -263,7 +269,7 @@ fn process_instruction(cid: ContractId, ix: &[u8]) -> ContractResult {
     let call_idx = wasm::util::get_call_index()? as usize;
     let calls: Vec<DarkLeaf<ContractCall>> = deserialize(ix)?;
     let self_ = &calls[call_idx].data;
-    let func = MoneyFunction::try_from(self_.data[0])?;
+    let func = parse_money_function(&self_.data)?;
 
     let update_data = match func {
         MoneyFunction::FeeV1 => {
@@ -302,7 +308,7 @@ fn process_instruction(cid: ContractId, ix: &[u8]) -> ContractResult {
 /// is the update data retrieved from `process_instruction()`, prefixed with the
 /// contract function.
 fn process_update(cid: ContractId, update_data: &[u8]) -> ContractResult {
-    match MoneyFunction::try_from(update_data[0])? {
+    match parse_money_function(update_data)? {
         MoneyFunction::FeeV1 => {
             let update: MoneyFeeUpdateV1 = deserialize(&update_data[1..])?;
             Ok(money_fee_process_update_v1(cid, update)?)

+ 18 - 26
src/contract/money/src/entrypoint/fee_v1.rs

@@ -33,7 +33,7 @@ use darkfi_sdk::{
     wasm::{
         self,
         db::{
-            db_contains_key, db_contains_key_local, db_get, db_lookup, db_lookup_local, db_set,
+            db_contains_key, db_contains_key_local, db_lookup, db_lookup_local, db_set,
             db_set_local,
         },
     },
@@ -45,11 +45,25 @@ use crate::{
     error::MoneyError,
     model::{MoneyFeeParamsV1, MoneyFeeUpdateV1, DARK_TOKEN_ID},
     MONEY_CONTRACT_COINS_TREE, MONEY_CONTRACT_COIN_MERKLE_TREE, MONEY_CONTRACT_COIN_ROOTS_TREE,
-    MONEY_CONTRACT_FEES_TREE, MONEY_CONTRACT_INFO_TREE, MONEY_CONTRACT_LATEST_COIN_ROOT,
+    MONEY_CONTRACT_INFO_TREE, MONEY_CONTRACT_LATEST_COIN_ROOT,
     MONEY_CONTRACT_LATEST_NULLIFIER_ROOT, MONEY_CONTRACT_NULLIFIERS_TREE,
     MONEY_CONTRACT_NULLIFIER_ROOTS_TREE, MONEY_CONTRACT_ZKAS_FEE_NS_V1,
 };
 
+const MONEY_FEE_PREFIX_LEN: usize = 9;
+
+fn parse_fee_call_data(data: &[u8]) -> Result<(u64, MoneyFeeParamsV1), ContractError> {
+    if data.len() < MONEY_FEE_PREFIX_LEN {
+        msg!("[FeeV1] Error: Fee call data is too short");
+        return Err(MoneyError::InvalidFeeCall.into())
+    }
+
+    let fee = deserialize(&data[1..MONEY_FEE_PREFIX_LEN])?;
+    let params = deserialize(&data[MONEY_FEE_PREFIX_LEN..])?;
+
+    Ok((fee, params))
+}
+
 /// `get_metadata` function for `Money::FeeV1`
 pub(crate) fn money_fee_get_metadata_v1(
     _cid: ContractId,
@@ -57,9 +71,7 @@ pub(crate) fn money_fee_get_metadata_v1(
     calls: Vec<DarkLeaf<ContractCall>>,
 ) -> Result<Vec<u8>, ContractError> {
     let self_ = &calls[call_idx].data;
-    // The first 8 bytes here is the u64 fee, so we get the params from that offset.
-    // (Plus 1, which is the function identifier byte)
-    let params: MoneyFeeParamsV1 = deserialize(&self_.data[9..])?;
+    let (_, params) = parse_fee_call_data(&self_.data)?;
 
     // Public inputs for the ZK proofs we have to verify
     let mut zk_public_inputs: Vec<(String, Vec<pallas::Base>)> = vec![];
@@ -103,8 +115,7 @@ pub(crate) fn money_fee_process_instruction_v1(
     calls: Vec<DarkLeaf<ContractCall>>,
 ) -> Result<Vec<u8>, ContractError> {
     let self_ = &calls[call_idx];
-    let fee: u64 = deserialize(&self_.data.data[1..9])?;
-    let params: MoneyFeeParamsV1 = deserialize(&self_.data.data[9..])?;
+    let (fee, params) = parse_fee_call_data(&self_.data.data)?;
 
     // We should have _some_ fee paid...
     if fee == 0 {
@@ -120,7 +131,6 @@ pub(crate) fn money_fee_process_instruction_v1(
     let coin_roots_db = db_lookup(cid, MONEY_CONTRACT_COIN_ROOTS_TREE)?;
     let coin_roots_db_local = db_lookup_local(cid, MONEY_CONTRACT_COIN_ROOTS_TREE)?;
 
-    let fees_db = db_lookup(cid, MONEY_CONTRACT_FEES_TREE)?;
     let nullifiers_db = db_lookup(cid, MONEY_CONTRACT_NULLIFIERS_TREE)?;
 
     // Fees can only be paid using the native token, so we'll compare
@@ -194,25 +204,11 @@ pub(crate) fn money_fee_process_instruction_v1(
         return Err(MoneyError::ValueMismatch.into())
     }
 
-    // Accumulate the height paid fee
-    let verifying_block_height = wasm::util::get_verifying_block_height()?;
-    let Some(paid_fee) = db_get(fees_db, &serialize(&verifying_block_height))? else {
-        msg!("[FeeV1] Error: Block height fees accumulator not found");
-        return Err(MoneyError::PoWRewardCallMissingFeesAccumulator.into())
-    };
-    let paid_fee: u64 = deserialize(&paid_fee)?;
-    let Some(paid_fee) = paid_fee.checked_add(fee) else {
-        msg!("[FeeV1] Error: Could not compute paid fee");
-        return Err(MoneyError::ValueMismatch.into())
-    };
-
     // At this point the state transition has passed, so we create a state update.
     let update = MoneyFeeUpdateV1 {
         nullifier: params.input.nullifier,
         coin: params.output.coin,
         tx_local: params.output.tx_local,
-        height: verifying_block_height,
-        fee: paid_fee,
     };
     // and return it
     Ok(serialize(&update))
@@ -236,10 +232,6 @@ pub(crate) fn money_fee_process_update_v1(
     let coin_roots_db = db_lookup(cid, MONEY_CONTRACT_COIN_ROOTS_TREE)?;
     let coin_roots_db_local = db_lookup_local(cid, MONEY_CONTRACT_COIN_ROOTS_TREE)?;
 
-    let fees_db = db_lookup(cid, MONEY_CONTRACT_FEES_TREE)?;
-
-    db_set(fees_db, &serialize(&update.height), &serialize(&update.fee))?;
-
     wasm::merkle::sparse_merkle_insert_batch(
         info_db,
         nullifiers_db,

+ 4 - 0
src/contract/money/src/error.rs

@@ -114,6 +114,9 @@ pub enum MoneyError {
 
     #[error("Invalid local output")]
     InvalidLocalOutput,
+
+    #[error("Invalid fee call")]
+    InvalidFeeCall,
 }
 
 impl From<MoneyError> for ContractError {
@@ -150,6 +153,7 @@ impl From<MoneyError> for ContractError {
             MoneyError::ChildrenIndexesLengthMismatch => Self::Custom(29),
             MoneyError::BurnMissingInputs => Self::Custom(30),
             MoneyError::InvalidLocalOutput => Self::Custom(31),
+            MoneyError::InvalidFeeCall => Self::Custom(32),
         }
     }
 }

+ 0 - 4
src/contract/money/src/model/mod.rs

@@ -192,10 +192,6 @@ pub struct MoneyFeeUpdateV1 {
     pub coin: Coin,
     /// Marker whether the output will be used tx-local
     pub tx_local: bool,
-    /// Block height the fee was verified against
-    pub height: u32,
-    /// Height accumulated fee paid
-    pub fee: u64,
 }
 
 #[derive(Clone, Debug, SerialEncodable, SerialDecodable)]

+ 4 - 2
src/contract/money/tests/dep8.rs

@@ -34,11 +34,12 @@ use darkfi_money_contract::{
     MONEY_CONTRACT_ZKAS_MINT_NS_V1,
 };
 use darkfi_sdk::{
-    blockchain::{compute_fee, expected_reward},
+    blockchain::expected_reward,
     crypto::{
         contract_id::MONEY_CONTRACT_ID, note::AeadEncryptedNote, BaseBlind, FuncId, MerkleNode,
         MerkleTree, ScalarBlind, SecretKey,
     },
+    fee::minimum_fee,
     pasta::pallas,
     ContractCall,
 };
@@ -180,7 +181,8 @@ fn dep8() -> Result<()> {
         drop(validator);
 
         // Compute the required fee
-        let required_fee = compute_fee(&(gas_used + FEE_CALL_GAS));
+        let fee_gas = gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
+        let required_fee = minimum_fee(fee_gas)?;
         let change_value = output_coin.note.value - required_fee;
 
         // Input and output setup.

+ 55 - 0
src/contract/money/tests/integration.rs

@@ -60,3 +60,58 @@ fn money_integration() -> Result<()> {
         Ok(())
     })
 }
+
+#[test]
+fn money_fee_burn_is_excluded_from_reward() -> Result<()> {
+    smol::block_on(async {
+        init_logger();
+
+        use Holder::{Alice, Bob};
+
+        let mut th = TestHarness::new(&[Alice, Bob], true).await?;
+
+        th.generate_block_all(&Alice).await?;
+        th.generate_block_all(&Alice).await?;
+
+        let block_height = 3;
+        let native_token = th.coins(&Alice)[0].note.token_id;
+        let owncoins = vec![th.coins_by_token(&Alice, native_token)[0].clone()];
+        let transfer_amount = 100_000_000;
+        let (tx, _, _) = th
+            .transfer(transfer_amount, &Alice, &Bob, &owncoins, native_token, block_height, false)
+            .await?;
+
+        let paid_fee = tx
+            .calls
+            .iter()
+            .find(|call| call.data.is_money_fee())
+            .unwrap()
+            .data
+            .money_fee_value()
+            .unwrap();
+
+        let validator = th.wallet(&Alice).validator().read().await;
+        let (_, miner_claimable_fee) = validator
+            .add_test_transactions(
+                std::slice::from_ref(&tx),
+                block_height,
+                validator.consensus.module.target,
+                false,
+                true,
+            )
+            .await?;
+        drop(validator);
+
+        assert!(miner_claimable_fee < paid_fee);
+
+        let reward_coins = th
+            .generate_block_with_txs(&Alice, &[Alice, Bob], vec![tx], miner_claimable_fee)
+            .await?;
+
+        assert_eq!(reward_coins.len(), 1);
+        assert_eq!(reward_coins[0].note.value, expected_reward(block_height) + miner_claimable_fee);
+        assert_ne!(reward_coins[0].note.value, expected_reward(block_height) + paid_fee);
+
+        Ok(())
+    })
+}

+ 4 - 3
src/contract/test-harness/src/money_fee.rs

@@ -33,11 +33,11 @@ use darkfi_money_contract::{
     MoneyFunction, MONEY_CONTRACT_ZKAS_FEE_NS_V1,
 };
 use darkfi_sdk::{
-    blockchain::compute_fee,
     crypto::{
         contract_id::MONEY_CONTRACT_ID, note::AeadEncryptedNote, BaseBlind, Blind, FuncId,
         ScalarBlind, SecretKey,
     },
+    fee::minimum_fee,
     pasta::pallas,
     ContractCall,
 };
@@ -58,7 +58,7 @@ impl TestHarness {
         let wallet = self.wallet(holder);
 
         // Compute fee call required fee
-        let required_fee = compute_fee(&FEE_CALL_GAS);
+        let required_fee = minimum_fee(FEE_CALL_GAS)?;
 
         // Find a compatible OwnCoin
         let coin = wallet
@@ -201,7 +201,8 @@ impl TestHarness {
             .0;
 
         // Compute the required fee
-        let required_fee = compute_fee(&(gas_used + FEE_CALL_GAS));
+        let fee_gas = gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
+        let required_fee = minimum_fee(fee_gas)?;
 
         // Knowing the total gas, we can now find an OwnCoin of enough
         // value so that we can create a valid Money::Fee call.

+ 69 - 1
src/contract/test-harness/src/money_pow_reward.rs

@@ -21,7 +21,9 @@ use std::slice;
 use darkfi::{
     blockchain::{BlockInfo, BlockchainOverlay, Header},
     tx::{ContractCallLeaf, Transaction, TransactionBuilder},
-    validator::verification::apply_producer_transaction,
+    validator::verification::{
+        apply_producer_transaction, verify_producer_transaction, verify_transactions,
+    },
     Result,
 };
 use darkfi_money_contract::{
@@ -157,4 +159,70 @@ impl TestHarness {
 
         Ok(found_owncoins)
     }
+
+    /// Generate and add a block containing non-producer transactions.
+    ///
+    /// The caller must provide the block's accumulated miner-claimable fees.
+    /// Returns any found miner reward [`OwnCoin`]s.
+    pub async fn generate_block_with_txs(
+        &mut self,
+        miner: &Holder,
+        holders: &[Holder],
+        txs: Vec<Transaction>,
+        fees: u64,
+    ) -> Result<Vec<OwnCoin>> {
+        info!("Building PoWReward transaction for {miner:?}");
+        let (producer_tx, params) = self.pow_reward(miner, None, None, Some(fees)).await?;
+
+        let wallet = self.wallet(miner);
+        let validator = wallet.validator.read().await;
+        let previous = validator.blockchain.last_block()?;
+        let timestamp = previous.header.timestamp.checked_add(1.into())?;
+
+        let header = Header::new(
+            previous.hash(),
+            previous.header.height + 1,
+            previous.header.nonce,
+            timestamp,
+        );
+        let mut block = BlockInfo::new_empty(header);
+
+        block.append_txs(txs);
+        block.append_txs(vec![producer_tx]);
+
+        let overlay = BlockchainOverlay::new(&validator.blockchain)?;
+        let mut tree = MerkleTree::new(1);
+        let non_producer_txs = &block.txs[..block.txs.len() - 1];
+        verify_transactions(
+            &overlay,
+            block.header.height,
+            validator.consensus.module.target,
+            non_producer_txs,
+            &mut tree,
+            validator.verify_fees,
+        )
+        .await?;
+        verify_producer_transaction(
+            &overlay,
+            block.header.height,
+            validator.consensus.module.target,
+            block.txs.last().unwrap(),
+            &mut tree,
+        )
+        .await?;
+        drop(validator);
+
+        let diff = overlay.lock().unwrap().overlay.lock().unwrap().diff(&[])?;
+        block.header.state_root = overlay.lock().unwrap().contracts.update_state_monotree(&diff)?;
+        block.sign(&wallet.keypair.secret);
+
+        let mut found_owncoins = vec![];
+        for holder in holders {
+            let wallet = self.wallet_mut(holder);
+            wallet.validator.write().await.add_test_blocks(&[block.clone()]).await?;
+            found_owncoins.extend(wallet.process_outputs(slice::from_ref(&params.output), holder));
+        }
+
+        Ok(found_owncoins)
+    }
 }

+ 11 - 0
src/error.rs

@@ -453,6 +453,10 @@ pub enum Error {
     #[error("Invalid DarkTree: {0}")]
     DarkTreeError(darkfi_sdk::error::DarkTreeError),
 
+    #[cfg(feature = "darkfi-sdk")]
+    #[error("Fee error: {0}")]
+    FeeError(darkfi_sdk::error::FeeError),
+
     #[cfg(feature = "blockchain")]
     #[error("contract wasm bincode not found")]
     WasmBincodeNotFound,
@@ -836,6 +840,13 @@ impl From<darkfi_sdk::error::DarkTreeError> for Error {
     }
 }
 
+#[cfg(feature = "darkfi-sdk")]
+impl From<darkfi_sdk::error::FeeError> for Error {
+    fn from(err: darkfi_sdk::error::FeeError) -> Self {
+        Self::FeeError(err)
+    }
+}
+
 #[cfg(feature = "util")]
 impl From<tracing_subscriber::util::TryInitError> for Error {
     fn from(err: tracing_subscriber::util::TryInitError) -> Self {

+ 0 - 8
src/sdk/src/blockchain.rs

@@ -67,11 +67,3 @@ pub fn expected_reward(height: u32) -> u64 {
         _ => 100_000_000,   // 1 DRK
     }
 }
-
-/// Auxiliary function to compute the corresponding fee value
-/// for the provided gas.
-///
-/// Currently we simply divide the gas value by 100.
-pub fn compute_fee(gas: &u64) -> u64 {
-    gas / 100
-}

+ 23 - 0
src/sdk/src/error.rs

@@ -21,6 +21,29 @@ use std::result::Result as ResultGeneric;
 pub type GenericResult<T> = ResultGeneric<T, ContractError>;
 pub type ContractResult = ResultGeneric<(), ContractError>;
 
+/// Result type for checked consensus fee arithmetic.
+pub type FeeResult<T> = ResultGeneric<T, FeeError>;
+
+/// Error returned by checked consensus fee arithmetic.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
+pub enum FeeError {
+    /// Fee arithmetic overflowed and must fail closed.
+    #[error("Fee arithmetic overflow")]
+    ArithmeticOverflow,
+
+    /// Paid fee cannot cover the requested fee split.
+    #[error("Insufficient fee payment")]
+    InsufficientFee,
+
+    /// Consensus fee constants are invalid.
+    #[error("Invalid fee constants")]
+    InvalidFeeConstants,
+
+    /// Fee call data is missing, malformed, or targets the wrong call type.
+    #[error("Invalid fee call")]
+    InvalidFeeCall,
+}
+
 /// Error codes available in the contract.
 #[derive(Debug, Clone, thiserror::Error)]
 pub enum ContractError {

+ 140 - 0
src/sdk/src/fee.rs

@@ -0,0 +1,140 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use crate::error::{FeeError, FeeResult};
+
+/// Fixed consensus fee charged per gas unit for the initial fee-burning testnet.
+pub const FEE_PER_GAS: u64 = 5;
+
+/// Numerator for the mandatory burn ratio applied to the minimum fee.
+pub const BURN_NUM: u64 = 3;
+
+/// Denominator for the mandatory burn ratio applied to the minimum fee.
+pub const BURN_DEN: u64 = 4;
+
+fn validate_fee_constants(fee_per_gas: u64, burn_num: u64, burn_den: u64) -> FeeResult<()> {
+    if fee_per_gas == 0 || burn_num == 0 || burn_den == 0 || burn_num >= burn_den {
+        return Err(FeeError::InvalidFeeConstants)
+    }
+
+    Ok(())
+}
+
+fn minimum_fee_with_constants(gas: u64, fee_per_gas: u64) -> FeeResult<u64> {
+    validate_fee_constants(fee_per_gas, BURN_NUM, BURN_DEN)?;
+    gas.checked_mul(fee_per_gas).ok_or(FeeError::ArithmeticOverflow)
+}
+
+fn burn_fee_with_constants(minimum_fee: u64, burn_num: u64, burn_den: u64) -> FeeResult<u64> {
+    validate_fee_constants(FEE_PER_GAS, burn_num, burn_den)?;
+
+    let burned =
+        (minimum_fee as u128).checked_mul(burn_num as u128).ok_or(FeeError::ArithmeticOverflow)? /
+            burn_den as u128;
+
+    u64::try_from(burned).map_err(|_| FeeError::ArithmeticOverflow)
+}
+
+/// Compute the minimum fee required for final measured gas.
+pub fn minimum_fee(gas: u64) -> FeeResult<u64> {
+    minimum_fee_with_constants(gas, FEE_PER_GAS)
+}
+
+/// Compute the mandatory burn for a minimum fee.
+pub fn burn_fee(minimum_fee: u64) -> FeeResult<u64> {
+    burn_fee_with_constants(minimum_fee, BURN_NUM, BURN_DEN)
+}
+
+/// Compute the miner-claimable fee from the paid fee and mandatory burn.
+pub fn miner_claimable_fee(paid_fee: u64, burned_fee: u64) -> FeeResult<u64> {
+    paid_fee.checked_sub(burned_fee).ok_or(FeeError::InsufficientFee)
+}
+
+/// Checked addition for accumulated fee values.
+pub fn accumulate_fee(total: u64, fee: u64) -> FeeResult<u64> {
+    total.checked_add(fee).ok_or(FeeError::ArithmeticOverflow)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn minimum_fee_uses_fixed_fee_per_gas() {
+        assert_eq!(minimum_fee(0).unwrap(), 0);
+        assert_eq!(minimum_fee(1).unwrap(), 5);
+        assert_eq!(minimum_fee(42).unwrap(), 210);
+    }
+
+    #[test]
+    fn fee_constant_validation_rejects_invalid_constants() {
+        assert_eq!(minimum_fee_with_constants(1, 0), Err(FeeError::InvalidFeeConstants));
+        assert_eq!(burn_fee_with_constants(1, 0, BURN_DEN), Err(FeeError::InvalidFeeConstants));
+        assert_eq!(burn_fee_with_constants(1, BURN_NUM, 0), Err(FeeError::InvalidFeeConstants));
+        assert_eq!(
+            burn_fee_with_constants(1, BURN_DEN, BURN_DEN),
+            Err(FeeError::InvalidFeeConstants)
+        );
+        assert_eq!(
+            burn_fee_with_constants(1, BURN_DEN + 1, BURN_DEN),
+            Err(FeeError::InvalidFeeConstants)
+        );
+    }
+
+    #[test]
+    fn minimum_fee_rejects_multiplication_overflow() {
+        assert_eq!(minimum_fee(u64::MAX), Err(FeeError::ArithmeticOverflow));
+        assert_eq!(minimum_fee(u64::MAX / FEE_PER_GAS + 1), Err(FeeError::ArithmeticOverflow));
+        assert_eq!(
+            minimum_fee(u64::MAX / FEE_PER_GAS).unwrap(),
+            u64::MAX / FEE_PER_GAS * FEE_PER_GAS
+        );
+    }
+
+    #[test]
+    fn burn_fee_rounds_down() {
+        assert_eq!(burn_fee(0).unwrap(), 0);
+        assert_eq!(burn_fee(1).unwrap(), 0);
+        assert_eq!(burn_fee(2).unwrap(), 1);
+        assert_eq!(burn_fee(3).unwrap(), 2);
+        assert_eq!(burn_fee(4).unwrap(), 3);
+        assert_eq!(burn_fee(5).unwrap(), 3);
+    }
+
+    #[test]
+    fn miner_claimable_includes_overpayment_tip() {
+        let minimum = minimum_fee(10).unwrap();
+        let burned = burn_fee(minimum).unwrap();
+
+        assert_eq!(minimum, 50);
+        assert_eq!(burned, 37);
+        assert_eq!(miner_claimable_fee(minimum, burned).unwrap(), 13);
+        assert_eq!(miner_claimable_fee(minimum + 7, burned).unwrap(), 20);
+    }
+
+    #[test]
+    fn miner_claimable_rejects_underflow() {
+        assert_eq!(miner_claimable_fee(2, 3), Err(FeeError::InsufficientFee));
+    }
+
+    #[test]
+    fn accumulated_fee_rejects_overflow() {
+        assert_eq!(accumulate_fee(7, 11).unwrap(), 18);
+        assert_eq!(accumulate_fee(u64::MAX, 1), Err(FeeError::ArithmeticOverflow));
+    }
+}

+ 4 - 1
src/sdk/src/lib.rs

@@ -36,7 +36,10 @@ pub mod deploy;
 
 /// Error handling
 pub mod error;
-pub use error::{ContractError, ContractResult, GenericResult};
+pub use error::{ContractError, ContractResult, FeeError, FeeResult, GenericResult};
+
+/// Fee calculation helpers
+pub mod fee;
 
 /// Hex encoding/decoding from bytes
 pub mod hex;

+ 58 - 2
src/sdk/src/tx.rs

@@ -23,11 +23,11 @@ use std::{
 
 #[cfg(feature = "async")]
 use darkfi_serial::async_trait;
-use darkfi_serial::{SerialDecodable, SerialEncodable};
+use darkfi_serial::{deserialize, SerialDecodable, SerialEncodable};
 
 use super::{
     crypto::{ContractId, SecretKey},
-    ContractError, GenericResult,
+    ContractError, FeeError, FeeResult, GenericResult,
 };
 use crate::crypto::{DAO_CONTRACT_ID, DEPLOYOOOR_CONTRACT_ID, MONEY_CONTRACT_ID};
 
@@ -89,6 +89,15 @@ impl ContractCall {
         self.matches_contract_call_type(*MONEY_CONTRACT_ID, 0x00)
     }
 
+    /// Returns the paid native token fee encoded in a `Money::FeeV1` call.
+    pub fn money_fee_value(&self) -> FeeResult<u64> {
+        if !self.is_money_fee() || self.data.len() < 9 {
+            return Err(FeeError::InvalidFeeCall)
+        }
+
+        deserialize(&self.data[1..9]).map_err(|_| FeeError::InvalidFeeCall)
+    }
+
     /// Returns true if call is a money genesis mint.
     pub fn is_money_genesis_mint(&self) -> bool {
         self.matches_contract_call_type(*MONEY_CONTRACT_ID, 0x01)
@@ -160,6 +169,53 @@ impl ContractCall {
     }
 }
 
+#[cfg(test)]
+mod tests {
+    use darkfi_serial::serialize;
+
+    use super::*;
+    use crate::crypto::DAO_CONTRACT_ID;
+
+    #[test]
+    fn money_fee_value_extracts_paid_fee() {
+        let fee = 42_u64;
+        let mut data = vec![0x00];
+        data.extend(serialize(&fee));
+        data.extend([0xab, 0xcd]);
+
+        let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data };
+
+        assert_eq!(call.money_fee_value().unwrap(), fee);
+    }
+
+    #[test]
+    fn money_fee_value_rejects_wrong_contract() {
+        let mut data = vec![0x00];
+        data.extend(serialize(&42_u64));
+
+        let call = ContractCall { contract_id: *DAO_CONTRACT_ID, data };
+
+        assert_eq!(call.money_fee_value(), Err(FeeError::InvalidFeeCall));
+    }
+
+    #[test]
+    fn money_fee_value_rejects_wrong_function() {
+        let mut data = vec![0x01];
+        data.extend(serialize(&42_u64));
+
+        let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data };
+
+        assert_eq!(call.money_fee_value(), Err(FeeError::InvalidFeeCall));
+    }
+
+    #[test]
+    fn money_fee_value_rejects_short_data() {
+        let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data: vec![0x00, 0x01] };
+
+        assert_eq!(call.money_fee_value(), Err(FeeError::InvalidFeeCall));
+    }
+}
+
 // Avoid showing the data in the debug output since often the calldata is very long.
 impl Debug for ContractCall {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {

+ 6 - 5
src/validator/consensus.rs

@@ -21,7 +21,7 @@ use std::{
     str::FromStr,
 };
 
-use darkfi_sdk::{crypto::MerkleTree, tx::TransactionHash};
+use darkfi_sdk::{crypto::MerkleTree, fee::accumulate_fee, tx::TransactionHash};
 use darkfi_serial::{async_trait, SerialDecodable, SerialEncodable};
 use kvdb_overlay::DatabaseOverlayStateDiff;
 use num_bigint::BigUint;
@@ -750,7 +750,7 @@ impl Fork {
     }
 
     /// Auxiliary function to retrieve unproposed valid transactions,
-    /// along with their total gas used and total paid fees. Erroneous
+    /// along with their total gas used and total miner-claimable fees. Erroneous
     /// transactions will be removed from the database.
     ///
     /// Note: Always remember to purge new trees from the database if
@@ -770,7 +770,7 @@ impl Fork {
 
         // Total gas accumulators
         let mut total_gas_used = 0_u64;
-        let mut total_gas_paid = 0_u64;
+        let mut total_miner_claimable = 0_u64;
 
         // Map of ZK proof verifying keys for the current transaction
         // batch.
@@ -834,7 +834,8 @@ impl Fork {
 
             // Update accumulated total gas
             total_gas_used = total_gas_used.saturating_add(tx_gas_used);
-            total_gas_paid = total_gas_paid.saturating_add(gas_data.paid);
+            total_miner_claimable =
+                accumulate_fee(total_miner_claimable, gas_data.miner_claimable)?;
 
             // Push the tx hash into the unproposed transactions vector
             unproposed_txs.push(tx);
@@ -843,7 +844,7 @@ impl Fork {
         // Remove erroneous transactions from mempool
         self.blockchain.remove_pending_txs_hashes(&erroneous_txs)?;
 
-        Ok((unproposed_txs, total_gas_used, total_gas_paid))
+        Ok((unproposed_txs, total_gas_used, total_miner_claimable))
     }
 
     /// Auxiliary function to create a full clone using

+ 40 - 2
src/validator/fees.rs

@@ -16,9 +16,12 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use darkfi_serial::{async_trait, SerialDecodable, SerialEncodable};
+use darkfi_sdk::{crypto::MONEY_CONTRACT_ID, fee::accumulate_fee};
+use darkfi_serial::{async_trait, deserialize, serialize, SerialDecodable, SerialEncodable};
 
-use crate::zkas::ZkBinary;
+use crate::{blockchain::BlockchainOverlayPtr, zkas::ZkBinary, Error, Result};
+
+const MONEY_CONTRACT_FEES_TREE: &str = "fees";
 
 /// Fixed fee for verifying a Schnorr signature over the Pallas curve.
 pub const PALLAS_SCHNORR_VERIFY_GAS: u64 = 1850;
@@ -57,6 +60,38 @@ pub fn circuit_gas_use(zkbin: &ZkBinary) -> u64 {
     VERIFY_GAS_PER_ROW.saturating_mul(rows)
 }
 
+/// Add miner-claimable fees to the existing money contract height accumulator.
+///
+/// This is intentionally narrow: it can only update the money contract's `fees`
+/// tree at the current block height key, and it requires that the accumulator
+/// entry already exists.
+pub fn add_miner_claimable_fee(
+    overlay: &BlockchainOverlayPtr,
+    verifying_block_height: u32,
+    miner_claimable_fee: u64,
+) -> Result<()> {
+    let (overlay, fees_tree) = {
+        let blockchain = overlay.lock().unwrap();
+        let fees_tree =
+            blockchain.contracts.lookup(&MONEY_CONTRACT_ID, MONEY_CONTRACT_FEES_TREE)?;
+        (blockchain.overlay.clone(), blake3::Hash::from(fees_tree).to_string())
+    };
+
+    let key = serialize(&verifying_block_height);
+    let mut overlay = overlay.lock().unwrap();
+    let Some(existing_value) = overlay.get(&fees_tree, &key)? else {
+        return Err(Error::DatabaseError(format!(
+            "Money fee accumulator for height {verifying_block_height} not found"
+        )))
+    };
+
+    let existing_value: u64 = deserialize(&existing_value)?;
+    let updated_value = accumulate_fee(existing_value, miner_claimable_fee)?;
+
+    overlay.insert(&fees_tree, &key, &serialize(&updated_value))?;
+    Ok(())
+}
+
 /// Auxiliary struct representing the full gas usage breakdown of a
 /// transaction.
 ///
@@ -74,6 +109,8 @@ pub struct GasData {
     pub deployments: u64,
     /// Transaction paid fee
     pub paid: u64,
+    /// Transaction miner-claimable fee after burn
+    pub miner_claimable: u64,
 }
 
 impl GasData {
@@ -98,6 +135,7 @@ impl std::fmt::Debug for GasData {
             .field("signatures", &self.signatures)
             .field("deployments", &self.deployments)
             .field("paid", &self.paid)
+            .field("miner_claimable", &self.miner_claimable)
             .finish()
     }
 }

+ 12 - 5
src/validator/mod.rs

@@ -18,7 +18,7 @@
 
 use std::{collections::HashMap, sync::Arc};
 
-use darkfi_sdk::{blockchain::compute_fee, crypto::MerkleTree};
+use darkfi_sdk::{crypto::MerkleTree, fee::minimum_fee};
 use kvdb_overlay::Database;
 use num_bigint::BigUint;
 use smol::lock::RwLock;
@@ -177,7 +177,7 @@ impl Validator {
         )
         .await?;
 
-        Ok(compute_fee(&verify_result.total_gas_used()))
+        Ok(minimum_fee(verify_result.total_gas_used())?)
     }
 
     /// The node retrieves a transaction, validates its state
@@ -354,8 +354,15 @@ impl Validator {
         // Validate and insert each block
         for (index, block) in blocks.iter().enumerate() {
             // Verify block
-            match verify_checkpoint_block(&overlay, &diffs, block, &headers[index], module.target)
-                .await
+            match verify_checkpoint_block(
+                &overlay,
+                &diffs,
+                block,
+                &headers[index],
+                module.target,
+                self.verify_fees,
+            )
+            .await
             {
                 Ok(()) => { /* Do nothing */ }
                 // Skip already existing block
@@ -545,7 +552,7 @@ impl Validator {
     /// to the database, and a boolean called `verify_fees` to
     /// overwrite the nodes configured `verify_fees` flag.
     ///
-    /// Returns the total gas used and total paid fees for the given
+    /// Returns the total gas used and total miner-claimable fees for the given
     /// transactions.
     ///
     /// Note: This function should only be used in tests and always

+ 375 - 87
src/validator/verification.rs

@@ -19,14 +19,16 @@
 use std::{collections::HashMap, sync::Arc};
 
 use darkfi_sdk::{
-    blockchain::{block_version, compute_fee},
+    blockchain::block_version,
     crypto::{
         schnorr::{SchnorrPublic, Signature},
         ContractId, MerkleTree, PublicKey,
     },
     dark_tree::dark_forest_leaf_vec_integrity_check,
     deploy::DeployParamsV1,
+    fee::{accumulate_fee, burn_fee, miner_claimable_fee, minimum_fee},
     pasta::pallas,
+    tx::TransactionHash,
 };
 use darkfi_serial::{deserialize_async, serialize_async, AsyncDecodable, AsyncEncodable};
 use kvdb_overlay::DatabaseOverlayStateDiff;
@@ -46,7 +48,7 @@ use crate::{
     util::time::Timestamp,
     validator::{
         consensus::{Consensus, Fork, Proposal, BLOCK_GAS_LIMIT},
-        fees::{circuit_gas_use, GasData, PALLAS_SCHNORR_VERIFY_GAS},
+        fees::{add_miner_claimable_fee, circuit_gas_use, GasData, PALLAS_SCHNORR_VERIFY_GAS},
         pow::PoWModule,
     },
     zk::VerifyingKey,
@@ -323,6 +325,7 @@ pub async fn verify_checkpoint_block(
     block: &BlockInfo,
     header: &HeaderHash,
     block_target: u32,
+    verify_fees: bool,
 ) -> Result<()> {
     let block_hash = block.hash();
     debug!(target: "validator::verification::verify_checkpoint_block", "Validating block {block_hash}");
@@ -348,7 +351,8 @@ pub async fn verify_checkpoint_block(
     let mut tree = MerkleTree::new(1);
     let txs = &block.txs[..block.txs.len() - 1];
     if let Err(e) =
-        apply_transactions(overlay, block.header.height, block_target, txs, &mut tree).await
+        apply_transactions(overlay, block.header.height, block_target, txs, &mut tree, verify_fees)
+            .await
     {
         warn!(
             target: "validator::verification::verify_checkpoint_block",
@@ -405,6 +409,122 @@ pub fn verify_producer_signature(block: &BlockInfo, public_key: &PublicKey) -> R
     Ok(())
 }
 
+fn extract_fee_call(
+    tx_hash: &TransactionHash,
+    tx: &Transaction,
+    verify_fee: bool,
+) -> Result<Option<(usize, u64)>> {
+    if !verify_fee {
+        return Ok(None)
+    }
+
+    let mut fee_call = None;
+
+    for (call_idx, call) in tx.calls.iter().enumerate() {
+        if !call.data.is_money_fee() {
+            continue
+        }
+
+        if fee_call.is_some() {
+            error!(
+                target: "validator::verification::extract_fee_call",
+                "[VALIDATOR] Transaction {tx_hash} contains multiple fee payment calls"
+            );
+            return Err(TxVerifyFailed::InvalidFee.into())
+        }
+
+        let fee = match call.data.money_fee_value() {
+            Ok(fee) => fee,
+            Err(e) => {
+                error!(
+                    target: "validator::verification::extract_fee_call",
+                    "[VALIDATOR] Failed parsing tx {tx_hash} fee call: {e}"
+                );
+                return Err(TxVerifyFailed::InvalidFee.into())
+            }
+        };
+
+        if fee == 0 {
+            error!(
+                target: "validator::verification::extract_fee_call",
+                "[VALIDATOR] Transaction {tx_hash} contains zero fee payment"
+            );
+            return Err(TxVerifyFailed::InvalidFee.into())
+        }
+
+        fee_call = Some((call_idx, fee));
+    }
+
+    if fee_call.is_none() {
+        error!(
+            target: "validator::verification::extract_fee_call",
+            "[VALIDATOR] Transaction {tx_hash} does not contain fee payment call"
+        );
+        return Err(TxVerifyFailed::InvalidFee.into())
+    }
+
+    Ok(fee_call)
+}
+
+fn calculate_miner_claimable_fee(
+    tx_hash: &TransactionHash,
+    total_gas_used: u64,
+    fee: u64,
+) -> Result<u64> {
+    let required_fee = match minimum_fee(total_gas_used) {
+        Ok(fee) => fee,
+        Err(e) => {
+            error!(
+                target: "validator::verification::calculate_miner_claimable_fee",
+                "[VALIDATOR] Failed calculating tx {tx_hash} fee: {e}"
+            );
+            return Err(TxVerifyFailed::InvalidFee.into())
+        }
+    };
+
+    if required_fee > fee {
+        error!(
+            target: "validator::verification::calculate_miner_claimable_fee",
+            "[VALIDATOR] Transaction {tx_hash} has insufficient fee. \
+             Required: {required_fee}, Paid: {fee}"
+        );
+        return Err(TxVerifyFailed::InsufficientFee.into())
+    }
+
+    let burned_fee = match burn_fee(required_fee) {
+        Ok(fee) => fee,
+        Err(e) => {
+            error!(
+                target: "validator::verification::calculate_miner_claimable_fee",
+                "[VALIDATOR] Failed calculating tx {tx_hash} burned fee: {e}"
+            );
+            return Err(TxVerifyFailed::InvalidFee.into())
+        }
+    };
+
+    let claimable_fee = match miner_claimable_fee(fee, burned_fee) {
+        Ok(fee) => fee,
+        Err(e) => {
+            error!(
+                target: "validator::verification::calculate_miner_claimable_fee",
+                "[VALIDATOR] Failed calculating tx {tx_hash} miner-claimable fee: {e}"
+            );
+            return Err(TxVerifyFailed::InvalidFee.into())
+        }
+    };
+
+    let tip = fee - required_fee;
+
+    debug!(target: "validator::verification::calculate_miner_claimable_fee", "The fee paid for transaction {tx_hash}: {fee}");
+    debug!(
+        target: "validator::verification::calculate_miner_claimable_fee",
+        "The fee split for transaction {tx_hash}: required={required_fee}, \
+         burned={burned_fee}, miner_claimable={claimable_fee}, tip={tip}"
+    );
+
+    Ok(claimable_fee)
+}
+
 /// Verify provided producer [`Transaction`].
 ///
 /// Verify WASM execution, signatures, and ZK proofs and apply it to
@@ -669,38 +789,7 @@ pub async fn verify_transaction(
     // Table of public keys used for signature verification
     let mut sig_table = vec![];
 
-    // Index of the Fee-paying call
-    let mut fee_call_idx = 0;
-
-    if verify_fee {
-        // Verify that there is a single money fee call in the
-        // transaction.
-        let mut found_fee = false;
-        for (call_idx, call) in tx.calls.iter().enumerate() {
-            if !call.data.is_money_fee() {
-                continue
-            }
-
-            if found_fee {
-                error!(
-                    target: "validator::verification::verify_transcation",
-                    "[VALIDATOR] Transaction {tx_hash} contains multiple fee payment calls"
-                );
-                return Err(TxVerifyFailed::InvalidFee.into())
-            }
-
-            found_fee = true;
-            fee_call_idx = call_idx;
-        }
-
-        if !found_fee {
-            error!(
-                target: "validator::verification::verify_transcation",
-                "[VALIDATOR] Transaction {tx_hash} does not contain fee payment call"
-            );
-            return Err(TxVerifyFailed::InvalidFee.into())
-        }
-    }
+    let fee_call = extract_fee_call(&tx_hash, tx, verify_fee)?;
 
     // Write the transaction calls payload data
     let mut payload = vec![];
@@ -791,14 +880,13 @@ pub async fn verify_transaction(
             // existing circuit. Might be a smart idea to do so in
             // order to have to care less about being able to verify
             // historical txs.
-            if inner_vk_map.contains_key(zkas_ns.as_str()) {
-                continue
-            }
-
             let (zkbin, vk) =
                 overlay.lock().unwrap().contracts.get_zkas(&call.data.contract_id, zkas_ns)?;
 
-            inner_vk_map.insert(zkas_ns.to_string(), vk);
+            if !inner_vk_map.contains_key(zkas_ns.as_str()) {
+                inner_vk_map.insert(zkas_ns.to_string(), vk);
+            }
+
             circuits_to_verify.push(zkbin);
         }
 
@@ -887,36 +975,14 @@ pub async fn verify_transaction(
         return Err(TxVerifyFailed::GasLimitExceeded.into())
     }
 
-    if verify_fee {
-        // Deserialize the fee call to find the paid fee
-        let fee: u64 = match deserialize_async(&tx.calls[fee_call_idx].data.data[1..9]).await {
-            Ok(v) => v,
-            Err(e) => {
-                error!(
-                    target: "validator::verification::verify_transaction",
-                    "[VALIDATOR] Failed deserializing tx {tx_hash} fee call: {e}"
-                );
-                return Err(TxVerifyFailed::InvalidFee.into())
-            }
-        };
-
-        // Compute the required fee for this transaction
-        let required_fee = compute_fee(&total_gas_used);
-
-        // Check that enough fee has been paid for the used gas in this
-        // transaction.
-        if required_fee > fee {
-            error!(
-                target: "validator::verification::verify_transaction",
-                "[VALIDATOR] Transaction {tx_hash} has insufficient fee. Required: {required_fee}, Paid: {fee}"
-            );
-            return Err(TxVerifyFailed::InsufficientFee.into())
-        }
-        debug!(target: "validator::verification::verify_transaction", "The gas paid for transaction {tx_hash}: {}", gas_data.paid);
-
-        // Store paid fee
+    let miner_claimable = if let Some((_, fee)) = fee_call {
+        let claimable_fee = calculate_miner_claimable_fee(&tx_hash, total_gas_used, fee)?;
         gas_data.paid = fee;
-    }
+        gas_data.miner_claimable = claimable_fee;
+        Some(claimable_fee)
+    } else {
+        None
+    };
 
     // When we're done looping and executing over the tx's contract
     // calls and (optionally) made sure that enough fee was paid, we
@@ -950,6 +1016,10 @@ pub async fn verify_transaction(
     }
     debug!(target: "validator::verification::verify_transaction", "ZK proof verification successful");
 
+    if let Some(claimable_fee) = miner_claimable {
+        add_miner_claimable_fee(overlay, verifying_block_height, claimable_fee)?;
+    }
+
     // Append hash to merkle tree
     append_tx_to_merkle_tree(tree, tx);
 
@@ -966,14 +1036,37 @@ pub async fn apply_transaction(
     block_target: u32,
     tx: &Transaction,
     tree: &mut MerkleTree,
-) -> Result<()> {
+    verify_fee: bool,
+) -> Result<GasData> {
     let tx_hash = tx.hash();
     debug!(target: "validator::verification::apply_transaction", "Applying transaction {tx_hash}");
 
+    if verify_fee {
+        dark_forest_leaf_vec_integrity_check(
+            &tx.calls,
+            Some(MIN_TX_CALLS + 1),
+            Some(MAX_TX_CALLS),
+        )?;
+    } else {
+        dark_forest_leaf_vec_integrity_check(&tx.calls, Some(MIN_TX_CALLS), Some(MAX_TX_CALLS))?;
+    }
+
+    let fee_call = extract_fee_call(&tx_hash, tx, verify_fee)?;
+
+    let mut gas_data = GasData::default();
+
     // Write the transaction calls payload data
     let mut payload = vec![];
     tx.calls.encode_async(&mut payload).await?;
 
+    // Define a buffer in case we want to use a different payload in a
+    // specific call.
+    let mut _call_payload = vec![];
+
+    // We'll also take note of all the circuits in a Vec so we can
+    // calculate their verification cost.
+    let mut circuits_to_verify = vec![];
+
     // Create tx-local state
     let tx_local_state = Arc::new(Mutex::new(TxLocalState::new()));
 
@@ -981,6 +1074,26 @@ pub async fn apply_transaction(
     for (idx, call) in tx.calls.iter().enumerate() {
         debug!(target: "validator::verification::apply_transaction", "Executing contract call {idx}");
 
+        // Transaction call must contain a function code.
+        let Some(func) = call.data.data.first() else {
+            error!(target: "validator::verification::apply_transaction", "Call contains no data");
+            return Err(TxVerifyFailed::ErroneousTxs(vec![tx.clone()]).into())
+        };
+
+        if call.data.is_money_pow_reward() {
+            error!(target: "validator::verification::apply_transaction", "Reward transaction detected");
+            return Err(TxVerifyFailed::ErroneousTxs(vec![tx.clone()]).into())
+        }
+
+        // Check if its the fee call so we only pass its payload.
+        let (call_idx, call_payload) = if call.data.is_money_fee() {
+            _call_payload = vec![];
+            vec![call.clone()].encode_async(&mut _call_payload).await?;
+            (0_u8, &_call_payload)
+        } else {
+            (idx as u8, &payload)
+        };
+
         debug!(target: "validator::verification::apply_transaction", "Instantiating WASM runtime");
         let wasm = overlay.lock().unwrap().contracts.get(call.data.contract_id)?;
 
@@ -992,15 +1105,38 @@ pub async fn apply_transaction(
             verifying_block_height,
             block_target,
             tx_hash,
-            idx as u8,
+            call_idx,
         )?;
 
+        debug!(target: "validator::verification::apply_transaction", "Executing \"metadata\" call");
+        let metadata = runtime.metadata(call_payload)?;
+
+        // Decode the metadata retrieved from the execution.
+        let mut decoder = Cursor::new(&metadata);
+        let zkp_pub: Vec<(String, Vec<pallas::Base>)> =
+            AsyncDecodable::decode_async(&mut decoder).await?;
+        let _: Vec<PublicKey> = AsyncDecodable::decode_async(&mut decoder).await?;
+
+        if decoder.position() != metadata.len() as u64 {
+            error!(
+                target: "validator::verification::apply_transaction",
+                "[VALIDATOR] Failed decoding entire metadata buffer for {tx_hash}:{idx}"
+            );
+            return Err(TxVerifyFailed::ErroneousTxs(vec![tx.clone()]).into())
+        }
+
+        for (zkas_ns, _) in &zkp_pub {
+            let (zkbin, _) =
+                overlay.lock().unwrap().contracts.get_zkas(&call.data.contract_id, zkas_ns)?;
+            circuits_to_verify.push(zkbin);
+        }
+
         // Run the "exec" function. We keep the returned state update
         // in a buffer, prefixed by the call function ID, enforcing the
         // state update function in the contract.
         debug!(target: "validator::verification::apply_transaction", "Executing \"exec\" call");
-        let mut state_update = vec![call.data.data[0]];
-        state_update.append(&mut runtime.exec(&payload)?);
+        let mut state_update = vec![*func];
+        state_update.append(&mut runtime.exec(call_payload)?);
         debug!(target: "validator::verification::apply_transaction", "Successfully executed \"exec\" call");
 
         // If that was successful, we apply the state update in the
@@ -1028,24 +1164,61 @@ pub async fn apply_transaction(
                 verifying_block_height,
                 block_target,
                 tx_hash,
-                idx as u8,
+                call_idx,
             )?;
 
             deploy_runtime.deploy(&deploy_params.ix)?;
+
+            let deploy_gas_used = deploy_runtime.gas_used();
+            debug!(target: "validator::verification::apply_transaction", "The gas used for deployment call {call:?} of transaction {tx_hash}: {deploy_gas_used}");
+            gas_data.deployments = gas_data.deployments.saturating_add(deploy_gas_used);
         }
+
+        let wasm_gas_used = runtime.gas_used();
+        debug!(target: "validator::verification::apply_transaction", "The gas used for WASM call {call:?} of transaction {tx_hash}: {wasm_gas_used}");
+        gas_data.wasm = gas_data.wasm.saturating_add(wasm_gas_used);
+    }
+
+    gas_data.signatures = PALLAS_SCHNORR_VERIFY_GAS
+        .saturating_mul(tx.signatures.len() as u64)
+        .saturating_add(serialize_async(tx).await.len() as u64);
+    debug!(target: "validator::verification::apply_transaction", "The gas used for signature of transaction {tx_hash}: {}", gas_data.signatures);
+
+    for zkbin in circuits_to_verify.iter() {
+        let zk_circuit_gas_used = circuit_gas_use(zkbin);
+        debug!(target: "validator::verification::apply_transaction", "The gas used for ZK circuit in namespace {} of transaction {tx_hash}: {zk_circuit_gas_used}", zkbin.namespace);
+        gas_data.zk_circuits = gas_data.zk_circuits.saturating_add(zk_circuit_gas_used);
+    }
+
+    let total_gas_used = gas_data.total_gas_used();
+
+    if total_gas_used > TX_GAS_LIMIT {
+        error!(
+            target: "validator::verification::apply_transaction",
+            "[VALIDATOR] Transaction {tx_hash} exceeds TX_GAS_LIMIT: \
+             {total_gas_used} > {TX_GAS_LIMIT}"
+        );
+        return Err(TxVerifyFailed::GasLimitExceeded.into())
+    }
+
+    if let Some((_, fee)) = fee_call {
+        let claimable_fee = calculate_miner_claimable_fee(&tx_hash, total_gas_used, fee)?;
+        gas_data.paid = fee;
+        gas_data.miner_claimable = claimable_fee;
+        add_miner_claimable_fee(overlay, verifying_block_height, claimable_fee)?;
     }
 
     // Append hash to merkle tree
     append_tx_to_merkle_tree(tree, tx);
 
     debug!(target: "validator::verification::apply_transaction", "Transaction {tx_hash} applied successfully");
-    Ok(())
+    Ok(gas_data)
 }
 
 /// Verify a set of [`Transaction`] in sequence and apply them if all
 /// are valid. In case any of the transactions fail, they will be
 /// returned to the caller as an error. If all transactions are valid,
-/// the function will return the total gas used and total paid fees
+/// the function will return the total gas used and total miner-claimable fees
 /// from all the transactions. Additionally, their hash is appended to
 /// the provided Merkle tree.
 ///
@@ -1069,7 +1242,7 @@ pub async fn verify_transactions(
 
     // Total gas accumulators
     let mut total_gas_used = 0_u64;
-    let mut total_gas_paid = 0_u64;
+    let mut total_miner_claimable = 0_u64;
 
     // Map of ZK proof verifying keys for the current transaction batch
     let mut vks: HashMap<[u8; 32], HashMap<String, VerifyingKey>> = HashMap::new();
@@ -1125,14 +1298,14 @@ pub async fn verify_transactions(
 
         // Update accumulated total gas
         total_gas_used = total_gas_used.saturating_add(tx_gas_used);
-        total_gas_paid = total_gas_paid.saturating_add(gas_data.paid);
+        total_miner_claimable = accumulate_fee(total_miner_claimable, gas_data.miner_claimable)?;
     }
 
     if !erroneous_txs.is_empty() {
         return Err(TxVerifyFailed::ErroneousTxs(erroneous_txs).into())
     }
 
-    Ok((total_gas_used, total_gas_paid))
+    Ok((total_gas_used, total_miner_claimable))
 }
 
 /// Apply given set of [`Transaction`] in sequence, without formal
@@ -1145,34 +1318,64 @@ async fn apply_transactions(
     block_target: u32,
     txs: &[Transaction],
     tree: &mut MerkleTree,
-) -> Result<()> {
+    verify_fees: bool,
+) -> Result<(u64, u64)> {
     debug!(target: "validator::verification::apply_transactions", "Applying {} transactions", txs.len());
     if txs.is_empty() {
-        return Ok(())
+        return Ok((0, 0))
     }
 
     // Tracker for failed txs
     let mut erroneous_txs = vec![];
+    let mut total_gas_used = 0_u64;
+    let mut total_miner_claimable = 0_u64;
 
     // Iterate over transactions and attempt to apply them
     for tx in txs {
         overlay.lock().unwrap().checkpoint();
-        if let Err(e) =
-            apply_transaction(overlay, verifying_block_height, block_target, tx, tree).await
+        let gas_data = match apply_transaction(
+            overlay,
+            verifying_block_height,
+            block_target,
+            tx,
+            tree,
+            verify_fees,
+        )
+        .await
         {
-            warn!(target: "validator::verification::apply_transactions", "Transaction apply failed: {e}");
+            Ok(gas_values) => gas_values,
+            Err(e) => {
+                warn!(target: "validator::verification::apply_transactions", "Transaction apply failed: {e}");
+                erroneous_txs.push(tx.clone());
+                overlay.lock().unwrap().revert_to_checkpoint();
+                continue
+            }
+        };
+
+        let tx_gas_used = gas_data.total_gas_used();
+        let accumulated_gas_usage = total_gas_used.saturating_add(tx_gas_used);
+        if accumulated_gas_usage > BLOCK_GAS_LIMIT {
+            warn!(
+                target: "validator::verification::apply_transactions",
+                "Transaction {} exceeds configured transaction gas limit: \
+                 {accumulated_gas_usage} - {BLOCK_GAS_LIMIT}",
+                tx.hash()
+            );
             erroneous_txs.push(tx.clone());
             overlay.lock().unwrap().revert_to_checkpoint();
-        };
+            break
+        }
+
+        total_gas_used = total_gas_used.saturating_add(tx_gas_used);
+        total_miner_claimable = accumulate_fee(total_miner_claimable, gas_data.miner_claimable)?;
     }
 
     if !erroneous_txs.is_empty() {
         return Err(TxVerifyFailed::ErroneousTxs(erroneous_txs).into())
     }
 
-    Ok(())
+    Ok((total_gas_used, total_miner_claimable))
 }
-
 /// Verify given [`Proposal`] against provided consensus state.
 ///
 /// A proposal is considered valid when the following rules apply:
@@ -1269,3 +1472,88 @@ pub async fn verify_fork_proposal(
 
     Ok(())
 }
+
+#[cfg(test)]
+mod tests {
+    use darkfi_sdk::{
+        crypto::{DAO_CONTRACT_ID, MONEY_CONTRACT_ID},
+        dark_tree::DarkLeaf,
+        fee::{burn_fee, minimum_fee},
+        tx::ContractCall,
+    };
+    use darkfi_serial::serialize;
+
+    use super::*;
+
+    fn leaf(call: ContractCall) -> DarkLeaf<ContractCall> {
+        DarkLeaf { data: call, parent_index: None, children_indexes: vec![] }
+    }
+
+    fn fee_call(fee: u64) -> DarkLeaf<ContractCall> {
+        let mut data = vec![0x00];
+        data.extend(serialize(&fee));
+        leaf(ContractCall { contract_id: *MONEY_CONTRACT_ID, data })
+    }
+
+    fn short_fee_call() -> DarkLeaf<ContractCall> {
+        leaf(ContractCall { contract_id: *MONEY_CONTRACT_ID, data: vec![0x00] })
+    }
+
+    fn non_fee_call() -> DarkLeaf<ContractCall> {
+        leaf(ContractCall { contract_id: *DAO_CONTRACT_ID, data: vec![0x00] })
+    }
+
+    fn tx(calls: Vec<DarkLeaf<ContractCall>>) -> Transaction {
+        Transaction { calls, proofs: vec![], signatures: vec![] }
+    }
+
+    #[test]
+    fn fee_call_extraction_is_disabled_when_fee_checks_are_disabled() {
+        let tx = tx(vec![]);
+        assert_eq!(extract_fee_call(&tx.hash(), &tx, false).unwrap(), None);
+    }
+
+    #[test]
+    fn fee_call_extraction_allows_one_fee_call_with_other_calls() {
+        let tx = tx(vec![non_fee_call(), fee_call(42)]);
+
+        assert_eq!(extract_fee_call(&tx.hash(), &tx, true).unwrap(), Some((1, 42)));
+    }
+
+    #[test]
+    fn fee_call_extraction_rejects_missing_duplicate_malformed_and_zero_fee() {
+        let missing = tx(vec![non_fee_call()]);
+        assert!(extract_fee_call(&missing.hash(), &missing, true).is_err());
+
+        let duplicate = tx(vec![fee_call(1), fee_call(2)]);
+        assert!(extract_fee_call(&duplicate.hash(), &duplicate, true).is_err());
+
+        let malformed = tx(vec![short_fee_call()]);
+        assert!(extract_fee_call(&malformed.hash(), &malformed, true).is_err());
+
+        let zero = tx(vec![fee_call(0)]);
+        assert!(extract_fee_call(&zero.hash(), &zero, true).is_err());
+    }
+
+    #[test]
+    fn miner_claimable_fee_split_handles_exact_fee_and_tip() {
+        let tx_hash = TransactionHash::none();
+        let gas = 10;
+        let required = minimum_fee(gas).unwrap();
+        let burned = burn_fee(required).unwrap();
+
+        assert_eq!(required, 50);
+        assert_eq!(burned, 37);
+        assert_eq!(calculate_miner_claimable_fee(&tx_hash, gas, required).unwrap(), 13);
+        assert_eq!(calculate_miner_claimable_fee(&tx_hash, gas, required + 7).unwrap(), 20);
+    }
+
+    #[test]
+    fn miner_claimable_fee_split_rejects_insufficient_fee() {
+        let tx_hash = TransactionHash::none();
+        let gas = 10;
+        let required = minimum_fee(gas).unwrap();
+
+        assert!(calculate_miner_claimable_fee(&tx_hash, gas, required - 1).is_err());
+    }
+}