Browse Source

validator: charge the fee call a fixed gas cost

brid 1 day ago
parent
commit
7d3c9ca52a

+ 6 - 5
bin/drk/src/money.rs

@@ -34,7 +34,7 @@ use darkfi::{
 use darkfi_money_contract::{
     client::{
         compute_remainder_blind,
-        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput, FEE_CALL_GAS},
+        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput},
         MoneyNote, OwnCoin,
     },
     model::{
@@ -53,7 +53,7 @@ use darkfi_sdk::{
         BaseBlind, FuncId, MerkleNode, MerkleTree, ScalarBlind, MONEY_CONTRACT_ID,
     },
     dark_tree::DarkLeaf,
-    fee::{burn_fee, minimum_fee, MONEY_FEE_CALLDATA_PREFIX_LEN},
+    fee::{burn_fee, fee_call_overhead, minimum_fee, MONEY_FEE_CALLDATA_PREFIX_LEN},
     pasta::pallas,
     ContractCall,
 };
@@ -1306,10 +1306,11 @@ impl Drk {
         spent_coins: Option<&[OwnCoin]>,
     ) -> 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 fee_call_fee = minimum_fee(FEE_CALL_GAS)?;
+        // and verification. The fee call is charged a fixed amount of gas,
+        // so this yields the exact final 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)?;
+        let overhead_fee = minimum_fee(fee_call_overhead(tx.calls.len() as u64)?)?;
+        let required_fee = tx_fee.checked_add(overhead_fee).ok_or(Error::AdditionOverflow)?;
         let burned_fee = burn_fee(required_fee)?;
 
         // Knowing the total gas, we can now find an OwnCoin of enough value

+ 38 - 27
doc/src/arch/fees.md

@@ -52,11 +52,20 @@ inclusion_fee(g) = base_fee(g) - burn_fee(g)
 tip_fee          = paid - base_fee(g)
 ```
 
-| Constant     | Value | Description                       |
-|--------------|-------|-----------------------------------|
-| `FEE_PER_GAS`| 5     | Fee per gas unit                  |
-| `BURN_NUM`   | 3     | Mandatory burn ratio numerator    |
-| `BURN_DEN`   | 4     | Mandatory burn ratio denominator  |
+| Constant                 | Value      | Description                            |
+|--------------------------|------------|----------------------------------------|
+| `FEE_PER_GAS`            | 5          | Fee per gas unit                       |
+| `BURN_NUM`               | 3          | Mandatory burn ratio numerator         |
+| `BURN_DEN`               | 4          | Mandatory burn ratio denominator       |
+| `FEE_CALL_GAS`           | 35_000_000 | Fixed gas charged for the fee call     |
+| `FEE_CALL_CIRCUIT_GAS`   | 163_840    | Fee circuit verification gas           |
+| `FEE_CALL_SIG_GAS`       | 1_850      | Fee call signature group gas           |
+| `FEE_CALL_SIZE_DELTA`    | 8_400      | Serialized size the fee call adds      |
+| `FEE_CALL_PAYLOAD_GAS`   | 68_800     | Payload gas per other call             |
+
+These are the canonical consensus values. `darkfi_sdk::fee` defines them
+once, and compile-time assertions in the validator pin the two that
+overlap validator gas rules.
 
 This sets the minimum fee to `5 * gas`, burns 75% of it before
 per-transaction rounding, and pays the remainder to the miner. Any
@@ -78,12 +87,11 @@ burned <= paid
 ```
 
 The burn is enforced as a mandatory floor rather than an exact value.
-Builders cannot predict final gas exactly, since the fee call's own gas
-usage depends on chain state (tree depths) and varies slightly between
-builds of the same transaction, so they estimate conservatively with
-`FEE_CALL_GAS` as an upper bound. Under-declaring the burn below the
-mandatory amount is invalid. Over-declaring is valid and only burns
-more of the builder's own value, reducing the miner-claimable amount;
+Wallet estimates carry a small overestimate, and custom builders may
+over-declare; the floor keeps such transactions valid instead of
+rejecting them. Under-declaring the burn below the mandatory amount is
+invalid. Over-declaring is valid and only burns more of the builder's
+own value, reducing the miner-claimable amount;
 no value can be forged.
 
 Overpayment is settled against the fee values the transaction declares,
@@ -251,26 +259,29 @@ so the gas value is the ratio of an operation's cost to that baseline.
 
 Every fee-paying transaction includes a `Money::FeeV1` call. Its gas
 cannot be measured before verification, since the fee depends on total
-gas which includes the fee call itself. It is therefore covered by a
-fixed constant added to the base gas:
+gas which includes the fee call itself. The fee call's real execution
+cost (~34.1M gas) also varies between builds of the same transaction
+in fixed ~36K-gas steps (33.9M to 34.15M), and shifts with the fee
+value's bit pattern. The variance is local to the fee call — the rest
+of the transaction is gas-identical across builds — and comes from its
+value-conservation check, which, unlike other calls, runs Pedersen
+arithmetic as plain contract code instead of inside the ZK proof.
+
+Because of this, the validator charges the fee call a fixed gas cost
+instead of metering its opcodes:
 
 ```
-fee = minimum_fee(base_tx_gas + FEE_CALL_GAS)
+fee_call_gas = FEE_CALL_GAS = 35_000_000
 ```
 
-`FEE_CALL_GAS = 42_000_000` is intentionally conservative. The actual
-fee-call overhead is approximately 33.7-33.8M gas, but it varies per
-transaction: SMT and Merkle tree insertions charge per new branch node
-written (`WRITE_GAS_PER_BYTE` * leaf bytes * depth), and the node count
-depends on the specific nullifier and coin values in the transaction.
-This data-dependent variation (~+/-70K gas across runs) means no single
-constant can be exact. The 42M value provides ~24% headroom over the
-observed maximum, ensuring the estimate always covers the real overhead
-without requiring per-transaction recalibration or risking intermittent
-fee-shortfall failures. Since the mandatory burn is enforced as a
-floor, over-estimation keeps transactions valid; if the fee call ever
-outgrows the constant, fee verification rejects transactions loudly and
-the constant has to be bumped.
+The call still executes normally and is still capped by the per-call
+runtime gas limit; only the charge is constant. This makes gas
+accounting deterministic and lets wallets estimate the final gas
+closely: the fee-less transaction's measured gas plus the fixed charge
+and the fee call's circuit, signature, and size overhead
+(`fee_call_overhead()` in `darkfi_sdk::fee`) is a tight upper bound on
+the final gas. The small overestimate is covered by the mandatory burn
+floor; the excess becomes an implicit miner tip.
 
 # Gas limits
 

+ 3 - 8
src/contract/money/src/client/fee_v1.rs

@@ -37,14 +37,9 @@ use crate::{
     model::{CoinAttributes, Nullifier},
 };
 
-/// Estimated gas used by the fee call. Actual usage depends on chain
-/// state (tree depths), so it cannot be exact. It must stay an upper
-/// bound: over-estimating is valid since the burn is enforced as a
-/// floor, under-estimating makes the transaction invalid.
-// TODO: If the fee call outgrows this estimate (deeper trees, gas
-// metering changes), fee verification will reject transactions and
-// the constant has to be bumped.
-pub const FEE_CALL_GAS: u64 = 42_000_000;
+/// Fixed gas charged for this call's wasm execution; see
+/// `darkfi_sdk::fee::FEE_CALL_GAS`.
+pub use darkfi_sdk::fee::FEE_CALL_GAS;
 
 /// Private values related to the Fee call
 pub struct FeeCallSecrets {

+ 7 - 5
src/contract/money/tests/dep8.rs

@@ -25,7 +25,7 @@ use darkfi_contract_test_harness::{init_logger, Holder, TestHarness};
 use darkfi_money_contract::{
     client::{
         compute_remainder_blind,
-        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput, FEE_CALL_GAS},
+        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput},
         transfer_v1::make_transfer_call,
         MoneyNote, OwnCoin,
     },
@@ -39,7 +39,7 @@ use darkfi_sdk::{
         contract_id::MONEY_CONTRACT_ID, note::AeadEncryptedNote, BaseBlind, FuncId, MerkleNode,
         MerkleTree, ScalarBlind, SecretKey,
     },
-    fee::{burn_fee, minimum_fee},
+    fee::{burn_fee, fee_call_overhead, minimum_fee},
     pasta::pallas,
     ContractCall,
 };
@@ -170,7 +170,7 @@ fn dep8() -> Result<()> {
         let validator = alice_wallet.validator().read().await;
         let gas_used = validator
             .add_test_transactions(
-                &[tx],
+                std::slice::from_ref(&tx),
                 current_block_height,
                 validator.consensus.module.target,
                 false,
@@ -180,8 +180,10 @@ fn dep8() -> Result<()> {
             .0;
         drop(validator);
 
-        // Compute the required fee
-        let fee_gas = gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
+        // Compute the required fee. The fee call is charged a fixed
+        // amount of gas, so this yields the exact final gas.
+        let overhead_gas = fee_call_overhead(tx.calls.len() as u64)?;
+        let fee_gas = gas_used.checked_add(overhead_gas).ok_or(darkfi::Error::AdditionOverflow)?;
         let required_fee = minimum_fee(fee_gas)?;
         let change_value = output_coin.note.value - required_fee;
 

+ 68 - 8
src/contract/money/tests/fees.rs

@@ -30,7 +30,7 @@ use darkfi_money_contract::{
 use darkfi_sdk::{
     blockchain::expected_reward,
     crypto::{contract_id::MONEY_CONTRACT_ID, SecretKey},
-    fee::{burn_fee, minimum_fee},
+    fee::{burn_fee, fee_call_overhead, minimum_fee},
     ContractCall,
 };
 use darkfi_serial::AsyncEncodable;
@@ -127,13 +127,10 @@ async fn measure_required_fee(
     Ok(minimum_fee(gas_used)?)
 }
 
-/// Gas usage varies between builds of the same transaction in fixed
-/// quantized steps, because per-build random values (blinds, proofs)
-/// feed shape- and size-metered storage operations, so fee values
-/// derived from one build do not necessarily hold for the next. This
-/// builds transactions until one declares fee values that are
-/// self-consistent with its own measured gas: `paid = required + tip`
-/// and `burned = mandatory + burn_delta`.
+/// The fee call is charged a fixed amount of gas, so estimation is
+/// deterministic. It builds a transaction declaring values relative
+/// to its own measured gas: `paid = required + tip` and
+/// `burned = mandatory + burn_delta`.
 async fn converge_fee_tx(
     th: &mut TestHarness,
     from: &Holder,
@@ -237,6 +234,69 @@ fn fees_reward_claim() -> Result<()> {
     })
 }
 
+/// The wallet-side estimation formula must bound validator gas
+/// accounting from above, closely: the fee call is charged a fixed
+/// amount of gas, so the measured fee-less gas plus
+/// `fee_call_overhead()` is an upper bound on the final gas of the
+/// assembled transaction, and a transaction built from the formula
+/// passes verification. The bound must be tight (a small overestimate
+/// only, becoming an implicit miner tip under the burn floor).
+///
+/// This test pins the overhead constants; if a serialized structure
+/// changes size, it fails and the constants have to be updated.
+#[test]
+fn fees_overhead_formula_bounds() -> Result<()> {
+    smol::block_on(async {
+        init_logger();
+
+        use Holder::{Alice, Bob};
+
+        let holders = vec![Alice, Bob];
+        let mut th = TestHarness::new(&[Alice, Bob], true).await?;
+
+        // Alice mines two blocks so she holds coins to pay fees with
+        th.generate_block_all(&Alice).await?;
+        th.generate_block_all(&Alice).await?;
+        let height = 3;
+
+        // Build the fee-less transaction and measure its gas
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        let amount = coin.note.value / 8;
+        let (leaf, _, signature_secrets, _) =
+            transfer_call_parts(&th, &Alice, &Bob, amount, &coin).await?;
+        let mut tx_builder = TransactionBuilder::new(leaf, vec![])?;
+        let mut base_tx = tx_builder.build()?;
+        let sigs = base_tx.create_sigs(&signature_secrets)?;
+        base_tx.signatures = vec![sigs];
+        let base_fee = measure_required_fee(&th, &Alice, &base_tx, height).await?;
+
+        // Price the fee call with the formula
+        let overhead_fee = minimum_fee(fee_call_overhead(1)?)?;
+        let required = base_fee.checked_add(overhead_fee).ok_or(darkfi::Error::AdditionOverflow)?;
+        let mandatory_burn = burn_fee(required)?;
+
+        // Build the full transaction with those exact values. The
+        // transfer is rebuilt with fresh randomness, which does not
+        // affect its gas, and the fee call is charged a fixed amount.
+        let (tx, params, fee_params) =
+            fee_tx(&mut th, &Alice, &Bob, &coin, required, mandatory_burn).await?;
+
+        // The assembled transaction's measured gas must be covered by
+        // the formula, tightly: the formula is an upper bound and the
+        // overestimate must stay small.
+        let measured = measure_required_fee(&th, &Alice, &tx, height).await?;
+        assert!(measured <= required, "formula underestimates: {measured} > {required}");
+        let overestimate = required - measured;
+        assert!(overestimate <= minimum_fee(1_024)?, "formula too loose: +{overestimate}");
+
+        // And it must verify paying exactly the minimum fee
+        execute_on_all(&mut th, &holders, &tx, &params, &fee_params, height).await?;
+
+        // Thanks for reading
+        Ok(())
+    })
+}
+
 #[test]
 fn fees() -> Result<()> {
     smol::block_on(async {

+ 11 - 6
src/contract/test-harness/src/money_fee.rs

@@ -26,7 +26,7 @@ use darkfi::{
 use darkfi_money_contract::{
     client::{
         compute_remainder_blind,
-        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput, FEE_CALL_GAS},
+        fee_v1::{create_fee_proof, FeeCallInput, FeeCallOutput},
         MoneyNote, OwnCoin,
     },
     model::{token_id::DARK_TOKEN_ID, Input, MoneyFeeParamsV1, Output},
@@ -37,7 +37,7 @@ use darkfi_sdk::{
         contract_id::MONEY_CONTRACT_ID, note::AeadEncryptedNote, BaseBlind, Blind, FuncId,
         ScalarBlind, SecretKey,
     },
-    fee::{burn_fee, minimum_fee},
+    fee::{burn_fee, fee_call_overhead, minimum_fee},
     pasta::pallas,
     ContractCall,
 };
@@ -57,8 +57,9 @@ impl TestHarness {
     ) -> Result<(Transaction, MoneyFeeParamsV1)> {
         let wallet = self.wallet(holder);
 
-        // Compute fee call required fee
-        let required_fee = minimum_fee(FEE_CALL_GAS)?;
+        // Compute fee call required fee: the flat wasm charge plus the
+        // fee circuit, signature, and size overhead.
+        let required_fee = minimum_fee(fee_call_overhead(1)?)?;
 
         // Find a compatible OwnCoin
         let coin = wallet
@@ -188,6 +189,7 @@ impl TestHarness {
     ) -> Result<(ContractCall, Vec<Proof>, Vec<SecretKey>, Vec<OwnCoin>, MoneyFeeParamsV1)> {
         // First we verify the fee-less transaction to see how much gas it
         // uses for execution and verification.
+        let non_fee_calls = tx.calls.len() as u64;
         let required_fee = {
             let wallet = self.wallet(holder);
             let validator = wallet.validator.read().await;
@@ -202,9 +204,12 @@ impl TestHarness {
                 .await?
                 .0;
 
-            // Compute the required fee
+            // Compute the required fee. The fee call is charged a fixed
+            // amount of gas, so this is a close upper bound on the
+            // final gas; the excess becomes an implicit miner tip.
+            let overhead_gas = fee_call_overhead(non_fee_calls)?;
             let fee_gas =
-                gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
+                gas_used.checked_add(overhead_gas).ok_or(darkfi::Error::AdditionOverflow)?;
             minimum_fee(fee_gas)?
         };
         let burned_fee = burn_fee(required_fee)?;

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

@@ -31,6 +31,54 @@ pub const BURN_NUM: u64 = 3;
 /// Denominator for the mandatory burn ratio applied to the minimum fee.
 pub const BURN_DEN: u64 = 4;
 
+/// Fixed gas charged for the `Money::FeeV1` call's wasm execution.
+/// Charging a constant makes gas accounting deterministic and lets
+/// wallets estimate the exact minimum fee.
+pub const FEE_CALL_GAS: u64 = 35_000_000;
+
+/// Verification gas of the fee circuit (VERIFY_GAS_PER_ROW * 2^k with
+/// k = 11 for fee_v1.zk). Duplicates the validator's circuit gas rule;
+/// pinned against it by a compile-time assertion in
+/// `darkfi::validator::fees`. Update manually if the circuit is
+/// regenerated with a different size.
+pub const FEE_CALL_CIRCUIT_GAS: u64 = 163_840;
+
+/// Signature gas for the fee call's extra signature group. Matches
+/// `PALLAS_SCHNORR_VERIFY_GAS` in `darkfi::validator::fees`, pinned by
+/// a compile-time assertion there.
+pub const FEE_CALL_SIG_GAS: u64 = 1_850;
+
+/// Serialized size the fee call adds to its transaction (calldata,
+/// proof, and signature), rounded up over transaction shapes.
+/// Signature gas charges one per byte, so this bounds that term.
+/// Pinned by the `fees_overhead_formula_bounds` integration test.
+pub const FEE_CALL_SIZE_DELTA: u64 = 8_400;
+
+/// Additional gas each of the transaction's other calls is charged
+/// when the fee call leaf is appended, since their wasm execution
+/// handles the larger transaction payload, rounded up over
+/// transaction shapes. Pinned by the `fees_overhead_formula_bounds`
+/// integration test.
+pub const FEE_CALL_PAYLOAD_GAS: u64 = 68_800;
+
+/// Upper bound on the marginal gas a `Money::FeeV1` call adds to its
+/// transaction with `non_fee_calls` other calls: the flat wasm charge,
+/// the fee circuit's verification gas, the extra signature group, the
+/// serialized size delta, and the payload gas the other calls are
+/// charged. Adding this to the measured gas of the fee-less
+/// transaction yields a close upper bound on the final gas; the
+/// mandatory burn floor makes the small overestimate valid, the excess
+/// becoming an implicit miner tip. All arithmetic is checked and fails
+/// closed like the other helpers in this module.
+pub fn fee_call_overhead(non_fee_calls: u64) -> FeeResult<u64> {
+    let payload_gas =
+        FEE_CALL_PAYLOAD_GAS.checked_mul(non_fee_calls).ok_or(FeeError::ArithmeticOverflow)?;
+
+    [FEE_CALL_GAS, FEE_CALL_CIRCUIT_GAS, FEE_CALL_SIG_GAS, FEE_CALL_SIZE_DELTA, payload_gas]
+        .into_iter()
+        .try_fold(0_u64, accumulate_fee)
+}
+
 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)
@@ -80,6 +128,16 @@ pub fn accumulate_fee(total: u64, fee: u64) -> FeeResult<u64> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn fee_call_overhead_sums_its_parts() {
+        assert_eq!(fee_call_overhead(1).unwrap(), 35_000_000 + 163_840 + 1_850 + 8_400 + 68_800);
+        assert_eq!(
+            fee_call_overhead(3).unwrap(),
+            35_000_000 + 163_840 + 1_850 + 8_400 + 3 * 68_800
+        );
+        assert_eq!(fee_call_overhead(u64::MAX), Err(FeeError::ArithmeticOverflow));
+    }
+
     #[test]
     fn minimum_fee_uses_fixed_fee_per_gas() {
         assert_eq!(minimum_fee(0).unwrap(), 0);

+ 10 - 1
src/validator/fees.rs

@@ -50,6 +50,14 @@ pub const COMPILE_GAS_PER_ROW: u64 = 7800;
 /// Per-row gas for verifying ZK circuits.
 pub const VERIFY_GAS_PER_ROW: u64 = 80;
 
+/// The fee-estimation constants in `darkfi_sdk::fee` mirror the validator
+/// gas rules above so wallets can predict the exact minimum fee. These
+/// assertions fail the build if the two ever drift apart. The circuit gas
+/// uses k = 11, the size of fee_v1.zk; update it if the circuit is ever
+/// regenerated with a different size.
+const _: () = assert!(PALLAS_SCHNORR_VERIFY_GAS == darkfi_sdk::fee::FEE_CALL_SIG_GAS);
+const _: () = assert!(VERIFY_GAS_PER_ROW * (1 << 11) == darkfi_sdk::fee::FEE_CALL_CIRCUIT_GAS);
+
 /// Calculate the gas use for verifying a given zkas circuit.
 /// This function assumes that the zkbin was properly decoded.
 pub fn circuit_gas_use(zkbin: &ZkBinary) -> u64 {
@@ -64,7 +72,8 @@ pub fn circuit_gas_use(zkbin: &ZkBinary) -> u64 {
 /// relating to resource consumption across different transactions.
 #[derive(Default, Clone, Eq, PartialEq, SerialEncodable, SerialDecodable)]
 pub struct GasData {
-    /// Wasm calls gas consumption
+    /// Wasm calls gas consumption. The fee call's entry is the fixed
+    /// `FEE_CALL_GAS` charge, not its measured opcode count.
     pub wasm: u64,
     /// ZK circuits gas consumption
     pub zk_circuits: u64,

+ 6 - 5
src/validator/verification.rs

@@ -26,7 +26,7 @@ use darkfi_sdk::{
     },
     dark_tree::dark_forest_leaf_vec_integrity_check,
     deploy::DeployParamsV1,
-    fee::{burn_fee, minimum_fee},
+    fee::{burn_fee, minimum_fee, FEE_CALL_GAS},
     pasta::pallas,
 };
 use darkfi_serial::{deserialize_async, serialize_async, AsyncDecodable, AsyncEncodable};
@@ -851,8 +851,10 @@ pub async fn verify_transaction(
         }
 
         // At this point we're done with the call and move on to the
-        // next one. Accumulate the WASM gas used.
-        let wasm_gas_used = runtime.gas_used();
+        // next one. Accumulate the WASM gas used. The fee call is
+        // charged a fixed constant to keep gas accounting deterministic.
+        let wasm_gas_used =
+            if call.data.is_money_fee() { FEE_CALL_GAS } else { runtime.gas_used() };
         debug!(target: "validator::verification::verify_transaction", "The gas used for WASM call {call:?} of transaction {tx_hash}: {wasm_gas_used}");
 
         // Append the used wasm gas
@@ -960,11 +962,10 @@ pub async fn verify_transaction(
             );
             return Err(TxVerifyFailed::InvalidFee.into())
         }
-        debug!(target: "validator::verification::verify_transaction", "The gas paid for transaction {tx_hash}: {}", gas_data.paid);
-
         // Store paid and burned fees
         gas_data.paid = fee;
         gas_data.burned = fee_values.burned_fee;
+        debug!(target: "validator::verification::verify_transaction", "The gas paid for transaction {tx_hash}: {}, burned: {}", gas_data.paid, gas_data.burned);
     }
 
     // When we're done looping and executing over the tx's contract