|
|
@@ -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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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(())
|
|
|
+ })
|
|
|
+}
|