Răsfoiți Sursa

contract/money: add fee-burning tests

brid 2 zile în urmă
părinte
comite
30c1df197f

+ 8 - 2
src/contract/money/Makefile

@@ -73,7 +73,13 @@ test-dep8: all
 		--features=no-entrypoint,client \
 		--test dep8
 
-test: test-integration test-mint-pay-swap test-genesis-mint test-token-mint-burn test-dep8
+test-fees: all
+	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) test --target=$(RUST_TARGET) \
+		--release --package $(PKGNAME) \
+		--features=no-entrypoint,client \
+		--test fees
+
+test: test-integration test-mint-pay-swap test-genesis-mint test-token-mint-burn test-dep8 test-fees
 
 clippy: all
 	RUSTFLAGS="$(RUSTFLAGS)" $(CARGO) clippy --target=$(WASM_TARGET) \
@@ -89,4 +95,4 @@ clean:
 		--release --package $(PKGNAME)
 	rm -f $(PROOFS_BIN) $(WASM_BIN)
 
-.PHONY: all test-integration test-mint-pay-swap test-genesis-mint test-delayed-tx test clippy clean
+.PHONY: all test-integration test-mint-pay-swap test-genesis-mint test-token-mint-burn test-dep8 test-fees test clippy clean

+ 344 - 0
src/contract/money/tests/fees.rs

@@ -0,0 +1,344 @@
+/* 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 darkfi::{
+    tx::{ContractCallLeaf, Transaction, TransactionBuilder},
+    Result,
+};
+use darkfi_contract_test_harness::{init_logger, Holder, TestHarness};
+use darkfi_money_contract::{
+    client::{fee_v1::FEE_CALL_GAS, transfer_v1::make_transfer_call, OwnCoin},
+    model::{MoneyFeeParamsV1, MoneyTransferParamsV1},
+    MoneyFunction, MONEY_CONTRACT_ZKAS_BURN_NS_V1, MONEY_CONTRACT_ZKAS_MINT_NS_V1,
+};
+use darkfi_sdk::{
+    blockchain::expected_reward,
+    crypto::{contract_id::MONEY_CONTRACT_ID, SecretKey},
+    fee::{burn_fee, minimum_fee},
+    ContractCall,
+};
+use darkfi_serial::AsyncEncodable;
+
+/// Build the `Money::TransferV1` call parts for a transfer of `amount`
+/// from `from` to `to`, spending `coin`.
+async fn transfer_call_parts(
+    th: &TestHarness,
+    from: &Holder,
+    to: &Holder,
+    amount: u64,
+    coin: &OwnCoin,
+) -> Result<(ContractCallLeaf, MoneyTransferParamsV1, Vec<SecretKey>, Vec<OwnCoin>)> {
+    let keypair = th.wallet(from).keypair;
+    let tree = th.wallet(from).money_merkle_tree.clone();
+    let rcpt = th.wallet(to).keypair.public;
+
+    let (mint_pk, mint_zkbin) = th.proving_keys.get(MONEY_CONTRACT_ZKAS_MINT_NS_V1).unwrap();
+    let (burn_pk, burn_zkbin) = th.proving_keys.get(MONEY_CONTRACT_ZKAS_BURN_NS_V1).unwrap();
+
+    let (params, secrets, spent_coins) = make_transfer_call(
+        keypair,
+        rcpt,
+        amount,
+        coin.note.token_id,
+        vec![coin.clone()],
+        tree,
+        None,
+        None,
+        mint_zkbin.clone(),
+        mint_pk.clone(),
+        burn_zkbin.clone(),
+        burn_pk.clone(),
+        false,
+    )?;
+
+    let mut data = vec![MoneyFunction::TransferV1 as u8];
+    params.encode_async(&mut data).await?;
+    let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data };
+
+    let leaf = ContractCallLeaf { call, proofs: secrets.proofs };
+    Ok((leaf, params, secrets.signature_secrets, spent_coins))
+}
+
+/// Build a signed transfer + fee transaction with explicitly provided
+/// public fee values, transferring an eighth of `coin` to `to`.
+async fn fee_tx(
+    th: &mut TestHarness,
+    from: &Holder,
+    to: &Holder,
+    coin: &OwnCoin,
+    paid_fee: u64,
+    burned_fee: u64,
+) -> Result<(Transaction, MoneyTransferParamsV1, Option<MoneyFeeParamsV1>)> {
+    let amount = coin.note.value / 8;
+    let (leaf, params, signature_secrets, transfer_spent) =
+        transfer_call_parts(th, from, to, amount, coin).await?;
+
+    // Pass the transfer's spent coins so the fee call does not reuse them.
+    let (fee_call, fee_proofs, fee_secrets, _, fee_params) =
+        th.append_fee_call_with_fees(from, &transfer_spent, paid_fee, burned_fee).await?;
+
+    let mut tx_builder = TransactionBuilder::new(leaf, vec![])?;
+    tx_builder.append(ContractCallLeaf { call: fee_call, proofs: fee_proofs }, vec![])?;
+
+    let mut tx = tx_builder.build()?;
+    let sigs = tx.create_sigs(&signature_secrets)?;
+    tx.signatures = vec![sigs];
+    let sigs = tx.create_sigs(&fee_secrets)?;
+    tx.signatures.push(sigs);
+
+    Ok((tx, params, Some(fee_params)))
+}
+
+/// Dry-run the given transaction and return the exact minimum fee for
+/// its final measured gas.
+async fn measure_required_fee(
+    th: &TestHarness,
+    holder: &Holder,
+    tx: &Transaction,
+    block_height: u32,
+) -> Result<u64> {
+    let validator = th.wallet(holder).validator.read().await;
+    let (gas_used, _) = validator
+        .add_test_transactions(
+            std::slice::from_ref(tx),
+            block_height,
+            validator.consensus.module.target,
+            false,
+            false,
+        )
+        .await?;
+
+    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`.
+async fn converge_fee_tx(
+    th: &mut TestHarness,
+    from: &Holder,
+    to: &Holder,
+    coin: &OwnCoin,
+    block_height: u32,
+    tip: i64,
+    burn_delta: i64,
+) -> Result<(Transaction, MoneyTransferParamsV1, Option<MoneyFeeParamsV1>, u64, u64)> {
+    let mut paid_fee = minimum_fee(FEE_CALL_GAS)?;
+    let mut burned_fee = 0;
+
+    for _ in 0..64 {
+        let (tx, params, fee_params) =
+            fee_tx(th, from, to, coin, paid_fee, burned_fee).await?;
+        let required = measure_required_fee(th, from, &tx, block_height).await?;
+        let mandatory = burn_fee(required)?;
+
+        let want_paid = (required as i64 + tip) as u64;
+        let want_burned = (mandatory as i64 + burn_delta) as u64;
+        if paid_fee == want_paid && burned_fee == want_burned {
+            return Ok((tx, params, fee_params, required, mandatory))
+        }
+
+        paid_fee = want_paid;
+        burned_fee = want_burned;
+    }
+
+    Err(darkfi::Error::Custom("fee values did not converge".to_string()))
+}
+
+/// Execute the given transaction on all holders.
+async fn execute_on_all(
+    th: &mut TestHarness,
+    holders: &[Holder],
+    tx: &Transaction,
+    params: &MoneyTransferParamsV1,
+    fee_params: &Option<MoneyFeeParamsV1>,
+    block_height: u32,
+) -> Result<()> {
+    for holder in holders {
+        th.execute_transfer_tx(holder, tx.clone(), params, fee_params, block_height, true).await?;
+    }
+
+    th.assert_all_trees();
+
+    Ok(())
+}
+
+#[test]
+fn fees() -> 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 mut height = 3;
+
+        const TIP: u64 = 20_000_000;
+        const EXTRA_BURN: u64 = 5_000_000;
+
+        // 1. Exact minimum fee: paid == minimum_fee(gas), burned == the
+        //    mandatory burn. Only the inclusion fee is miner-claimable.
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        let (tx, params, fee_params, required, mandatory_burn) =
+            converge_fee_tx(&mut th, &Alice, &Bob, &coin, height, 0, 0).await?;
+        execute_on_all(&mut th, &holders, &tx, &params, &fee_params, height).await?;
+
+        let inclusion_fee = required - mandatory_burn;
+        assert!(inclusion_fee > 0);
+
+        // Attempting to re-mint the burned portion in the reward fails
+        assert!(th
+            .generate_block_with_fees(&Bob, &holders, inclusion_fee + mandatory_burn)
+            .await
+            .is_err());
+        th.generate_block_with_fees(&Bob, &holders, inclusion_fee).await?;
+        assert_eq!(
+            th.coins(&Bob).last().unwrap().note.value,
+            expected_reward(height) + inclusion_fee
+        );
+        height += 1;
+
+        // 2. Overpayment is an implicit miner tip: the entire tip is
+        //    miner-claimable and is not burned.
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        let (tx, params, fee_params, required, mandatory_burn) =
+            converge_fee_tx(&mut th, &Alice, &Bob, &coin, height, TIP as i64, 0).await?;
+        execute_on_all(&mut th, &holders, &tx, &params, &fee_params, height).await?;
+        let inclusion_fee = required - mandatory_burn;
+        th.generate_block_with_fees(&Bob, &holders, inclusion_fee + TIP).await?;
+        assert_eq!(
+            th.coins(&Bob).last().unwrap().note.value,
+            expected_reward(height) + inclusion_fee + TIP
+        );
+        height += 1;
+
+        // 3. Over-declared burn is valid: only paid - burned is
+        //    miner-claimable, the excess burn is not re-minted.
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        let (tx, params, fee_params, required, mandatory_burn) = converge_fee_tx(&mut th, &Alice, &Bob, &coin, height, TIP as i64, EXTRA_BURN as i64)
+            .await?;
+        execute_on_all(&mut th, &holders, &tx, &params, &fee_params, height).await?;
+        let inclusion_fee = required - mandatory_burn;
+        th.generate_block_with_fees(&Bob, &holders, inclusion_fee + TIP - EXTRA_BURN).await?;
+        assert_eq!(
+            th.coins(&Bob).last().unwrap().note.value,
+            expected_reward(height) + inclusion_fee + TIP - EXTRA_BURN
+        );
+        height += 1;
+
+        // 4. Under-declaring the burn below the mandatory amount is
+        //    rejected by the mandatory burn floor check. The scenarios
+        //    below keep reusing this coin and amount since none of them
+        //    persist state.
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        let amount = coin.note.value / 8;
+        let (tx, params, fee_params, _, _) =
+            converge_fee_tx(&mut th, &Alice, &Bob, &coin, height, TIP as i64, -1).await?;
+        assert!(th
+            .execute_transfer_tx(&Alice, tx, &params, &fee_params, height, true)
+            .await
+            .is_err());
+
+        // 5. Paying less than the minimum fee is rejected by the fee
+        //    sufficiency check.
+        let (tx, params, fee_params, _, _) =
+            converge_fee_tx(&mut th, &Alice, &Bob, &coin, height, -1, 0).await?;
+        assert!(th
+            .execute_transfer_tx(&Alice, tx, &params, &fee_params, height, true)
+            .await
+            .is_err());
+
+        // 6. Zero paid fee is rejected by the contract.
+        let (tx, params, fee_params) = fee_tx(&mut th, &Alice, &Bob, &coin, 0, 0).await?;
+        assert!(th
+            .execute_transfer_tx(&Alice, tx, &params, &fee_params, height, true)
+            .await
+            .is_err());
+
+        // 7. Declaring burned_fee > paid_fee is rejected.
+        let (tx, params, fee_params) =
+            fee_tx(&mut th, &Alice, &Bob, &coin, 1000, 1001).await?;
+        assert!(th
+            .execute_transfer_tx(&Alice, tx, &params, &fee_params, height, true)
+            .await
+            .is_err());
+
+        // 8. A transaction without a fee call is rejected when fee
+        //    verification is enabled.
+        let (leaf, params, signature_secrets, _) =
+            transfer_call_parts(&th, &Alice, &Bob, amount, &coin).await?;
+        let mut tx_builder = TransactionBuilder::new(leaf, vec![])?;
+        let mut tx = tx_builder.build()?;
+        let sigs = tx.create_sigs(&signature_secrets)?;
+        tx.signatures = vec![sigs];
+        assert!(th.execute_transfer_tx(&Alice, tx, &params, &None, height, true).await.is_err());
+
+        // 9. Multiple fee calls in one transaction are rejected.
+        let (leaf, params, signature_secrets, _) =
+            transfer_call_parts(&th, &Alice, &Bob, amount, &coin).await?;
+        // The declared values are irrelevant here: the transaction is
+        // rejected during fee call discovery, before any execution.
+        let fee_paid = minimum_fee(FEE_CALL_GAS)?;
+        let (fee_call0, fee_proofs0, fee_secrets0, _, _) =
+            th.append_fee_call_with_fees(&Alice, &[], fee_paid, 0).await?;
+        let (fee_call1, fee_proofs1, fee_secrets1, _, _) =
+            th.append_fee_call_with_fees(&Alice, &[], fee_paid, 0).await?;
+        let mut tx_builder = TransactionBuilder::new(leaf, vec![])?;
+        tx_builder.append(ContractCallLeaf { call: fee_call0, proofs: fee_proofs0 }, vec![])?;
+        tx_builder.append(ContractCallLeaf { call: fee_call1, proofs: fee_proofs1 }, vec![])?;
+        let mut tx = tx_builder.build()?;
+        let sigs = tx.create_sigs(&signature_secrets)?;
+        tx.signatures = vec![sigs];
+        let sigs = tx.create_sigs(&fee_secrets0)?;
+        tx.signatures.push(sigs);
+        let sigs = tx.create_sigs(&fee_secrets1)?;
+        tx.signatures.push(sigs);
+        assert!(th.execute_transfer_tx(&Alice, tx, &params, &None, height, true).await.is_err());
+
+        // 10. Malformed fee call calldata is rejected without panicking.
+        let (leaf, params, signature_secrets, _) =
+            transfer_call_parts(&th, &Alice, &Bob, amount, &coin).await?;
+        let malformed_call =
+            ContractCall { contract_id: *MONEY_CONTRACT_ID, data: vec![0x00, 0xde, 0xad] };
+        let mut tx_builder = TransactionBuilder::new(leaf, vec![])?;
+        tx_builder.append(ContractCallLeaf { call: malformed_call, proofs: vec![] }, vec![])?;
+        let mut tx = tx_builder.build()?;
+        let sigs = tx.create_sigs(&signature_secrets)?;
+        tx.signatures = vec![sigs];
+        assert!(th.execute_transfer_tx(&Alice, tx, &params, &None, height, true).await.is_err());
+
+        // 11. Fee-disabled mode: transactions without a fee call are
+        //     accepted when fee verification is disabled.
+        let mut th = TestHarness::new(&[Alice, Bob], false).await?;
+        th.generate_block_all(&Alice).await?;
+        let coin = th.coins(&Alice).last().unwrap().clone();
+        th.transfer_to_all(coin.note.value / 2, &Alice, &Bob, coin.note.token_id, 1).await?;
+
+        // Thanks for reading
+        Ok(())
+    })
+}

+ 40 - 20
src/contract/test-harness/src/money_fee.rs

@@ -188,35 +188,55 @@ 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 wallet = self.wallet(holder);
-        let validator = wallet.validator.read().await;
-        let gas_used = validator
-            .add_test_transactions(
-                &[tx],
-                block_height,
-                validator.consensus.module.target,
-                false,
-                false,
-            )
-            .await?
-            .0;
-
-        // Compute the required fee
-        let fee_gas = gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
-        let required_fee = minimum_fee(fee_gas)?;
+        let required_fee = {
+            let wallet = self.wallet(holder);
+            let validator = wallet.validator.read().await;
+            let gas_used = validator
+                .add_test_transactions(
+                    &[tx],
+                    block_height,
+                    validator.consensus.module.target,
+                    false,
+                    false,
+                )
+                .await?
+                .0;
+
+            // Compute the required fee
+            let fee_gas =
+                gas_used.checked_add(FEE_CALL_GAS).ok_or(darkfi::Error::AdditionOverflow)?;
+            minimum_fee(fee_gas)?
+        };
         let burned_fee = burn_fee(required_fee)?;
 
+        self.append_fee_call_with_fees(holder, spent_coins, required_fee, burned_fee).await
+    }
+
+    /// Create a `Money::Fee` call with explicitly provided public fee
+    /// values. Intended for testing the fee validation rules.
+    ///
+    /// Additionally takes a set of spent coins in order not to reuse them here.
+    ///
+    /// Returns the `Fee` call, and all necessary data and parameters related.
+    pub async fn append_fee_call_with_fees(
+        &mut self,
+        holder: &Holder,
+        spent_coins: &[OwnCoin],
+        paid_fee: u64,
+        burned_fee: u64,
+    ) -> Result<(ContractCall, Vec<Proof>, Vec<SecretKey>, Vec<OwnCoin>, MoneyFeeParamsV1)> {
+        let wallet = self.wallet(holder);
+
         // Knowing the total gas, we can now find an OwnCoin of enough
         // value so that we can create a valid Money::Fee call.
         let spent_coins: HashSet<&OwnCoin, RandomState> = HashSet::from_iter(spent_coins);
         let mut available_coins = wallet.unspent_money_coins.clone();
-        available_coins
-            .retain(|x| x.note.token_id == *DARK_TOKEN_ID && x.note.value > required_fee);
+        available_coins.retain(|x| x.note.token_id == *DARK_TOKEN_ID && x.note.value > paid_fee);
         available_coins.retain(|x| !spent_coins.contains(x));
         assert!(!available_coins.is_empty());
 
         let coin = &available_coins[0];
-        let change_value = coin.note.value - required_fee;
+        let change_value = coin.note.value - paid_fee;
 
         // Input and output setup
         let input = FeeCallInput {
@@ -297,7 +317,7 @@ impl TestHarness {
 
         // Encode the contract call
         let mut data = vec![MoneyFunction::FeeV1 as u8];
-        required_fee.encode(&mut data)?;
+        paid_fee.encode(&mut data)?;
         burned_fee.encode(&mut data)?;
         params.encode(&mut data)?;
         let call = ContractCall { contract_id: *MONEY_CONTRACT_ID, data };

+ 16 - 2
src/contract/test-harness/src/money_pow_reward.rs

@@ -61,7 +61,7 @@ impl TestHarness {
         // If there's a set reward recipient, use it, otherwise reward the holder
         let recipient = recipient.map(|holder| self.wallet(holder).keypair.public);
 
-        // If there's fees paid, use them, otherwise set to zero
+        // If there's miner-claimable fees to claim, use them, otherwise set to zero
         let fees = fees.unwrap_or_default();
 
         // Build the transaction
@@ -102,10 +102,24 @@ impl TestHarness {
         &mut self,
         miner: &Holder,
         holders: &[Holder],
+    ) -> Result<Vec<OwnCoin>> {
+        self.generate_block_with_fees(miner, holders, 0).await
+    }
+
+    /// Generate and add a new block to the given [`Holder`]'s blockchains,
+    /// claiming `fees` as the height's accumulated miner-claimable fees.
+    /// The `miner` holder will produce the block and receive the reward.
+    ///
+    /// Returns any found [`OwnCoin`]s.
+    pub async fn generate_block_with_fees(
+        &mut self,
+        miner: &Holder,
+        holders: &[Holder],
+        fees: u64,
     ) -> Result<Vec<OwnCoin>> {
         // Build the POW reward transaction
         info!("Building PoWReward transaction for {miner:?}");
-        let (tx, params) = self.pow_reward(miner, None, None, None).await?;
+        let (tx, params) = self.pow_reward(miner, None, None, Some(fees)).await?;
 
         // Fetch the last block in the blockchain
         let wallet = self.wallet(miner);