diff --git a/docs/src/calling-contracts/tx-policies.md b/docs/src/calling-contracts/tx-policies.md index c02d83964..87154b5f3 100644 --- a/docs/src/calling-contracts/tx-policies.md +++ b/docs/src/calling-contracts/tx-policies.md @@ -13,6 +13,7 @@ Where: 1. **Tip** - amount to pay the block producer to prioritize the transaction. 2. **Witness Limit** - The maximum amount of witness data allowed for the transaction. 3. **Maturity** - Block until which the transaction cannot be included. +3. **Expiration** - Block after which the transaction cannot be included. 4. **Max Fee** - The maximum fee payable by this transaction. 5. **Script Gas Limit** - The maximum amount of gas the transaction may consume for executing its script code. diff --git a/docs/src/custom-transactions/transaction-builders.md b/docs/src/custom-transactions/transaction-builders.md index 03cfbbe82..084a56f00 100644 --- a/docs/src/custom-transactions/transaction-builders.md +++ b/docs/src/custom-transactions/transaction-builders.md @@ -68,7 +68,7 @@ We need to do one more thing before we stop thinking about transaction inputs. E > **Note** It is recommended to add signers before calling `adjust_for_fee()` as the estimation will include the size of the witnesses. -We can also define transaction policies. For example, we can limit the gas price by doing the following: +We can also define transaction policies. For example, we can set the maturity and expiration with: ```rust,ignore {{#include ../../../examples/cookbook/src/lib.rs:custom_tx_policies}} diff --git a/e2e/tests/contracts.rs b/e2e/tests/contracts.rs index 2021e71e5..c57e15afb 100644 --- a/e2e/tests/contracts.rs +++ b/e2e/tests/contracts.rs @@ -384,37 +384,57 @@ async fn mult_call_has_same_estimated_and_used_gas() -> Result<()> { } #[tokio::test] -async fn contract_method_call_respects_maturity() -> Result<()> { - setup_program_test!( - Wallets("wallet"), - Abigen(Contract( - name = "BlockHeightContract", - project = "e2e/sway/contracts/transaction_block_height" - )), - Deploy( - name = "contract_instance", - contract = "BlockHeightContract", - wallet = "wallet", - random_salt = false, - ), - ); +async fn contract_method_call_respects_maturity_and_expiration() -> Result<()> { + abigen!(Contract( + name = "MyContract", + abi = "e2e/sway/contracts/transaction_block_height/out/release/transaction_block_height-abi.json" + )); - let call_w_maturity = |maturity| { - contract_instance - .methods() - .calling_this_will_produce_a_block() - .with_tx_policies(TxPolicies::default().with_maturity(maturity)) - }; + let wallet = launch_provider_and_get_wallet().await?; + let provider = wallet.try_provider()?.clone(); - call_w_maturity(1).call().await.expect( - "should have passed since we're calling with a maturity \ - that is less or equal to the current block height", - ); + let contract_id = Contract::load_from( + "sway/contracts/transaction_block_height/out/release/transaction_block_height.bin", + LoadConfiguration::default(), + )? + .deploy_if_not_exists(&wallet, TxPolicies::default()) + .await?; - call_w_maturity(3).call().await.expect_err( - "should have failed since we're calling with a maturity \ - that is greater than the current block height", - ); + let contract_instance = MyContract::new(contract_id, wallet); + let maturity = 10; + let expiration = 20; + let call_handler = contract_instance + .methods() + .calling_this_will_produce_a_block() + .with_tx_policies( + TxPolicies::default() + .with_maturity(maturity) + .with_expiration(expiration), + ); + + { + let err = call_handler + .clone() + .call() + .await + .expect_err("maturity not reached"); + + assert!(err.to_string().contains("TransactionMaturity")); + } + { + provider.produce_blocks(15, None).await?; + call_handler + .clone() + .call() + .await + .expect("should succed. Block height between `maturity` and `expiration`"); + } + { + provider.produce_blocks(15, None).await?; + let err = call_handler.call().await.expect_err("expiration reached"); + + assert!(err.to_string().contains("TransactionExpiration")); + } Ok(()) } diff --git a/e2e/tests/predicates.rs b/e2e/tests/predicates.rs index 3e55fa9dc..6cd01c4ac 100644 --- a/e2e/tests/predicates.rs +++ b/e2e/tests/predicates.rs @@ -1245,3 +1245,80 @@ async fn predicate_configurables_in_blobs() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn predicate_transfer_respects_maturity_and_expiration() -> Result<()> { + abigen!(Predicate( + name = "MyPredicate", + abi = "e2e/sway/predicates/basic_predicate/out/release/basic_predicate-abi.json" + )); + + let predicate_data = MyPredicateEncoder::default().encode_data(4097, 4097)?; + + let mut predicate: Predicate = + Predicate::load_from("sway/predicates/basic_predicate/out/release/basic_predicate.bin")? + .with_data(predicate_data); + + let num_coins = 4; + let num_messages = 8; + let amount = 16; + let (provider, predicate_balance, receiver, receiver_balance, asset_id, _) = + setup_predicate_test(predicate.address(), num_coins, num_messages, amount).await?; + + predicate.set_provider(provider.clone()); + + let maturity = 10; + let expiration = 20; + let tx_policies = TxPolicies::default() + .with_maturity(maturity) + .with_expiration(expiration); + let amount_to_send = 10; + + // TODO: https://github.com/FuelLabs/fuels-rs/issues/1394 + let expected_fee = 1; + + { + let err = predicate + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect_err("maturity not reached"); + + assert!(err.to_string().contains("TransactionMaturity")); + } + { + provider.produce_blocks(15, None).await?; + predicate + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect("should succed. Block height between `maturity` and `expiration`"); + } + { + provider.produce_blocks(15, None).await?; + let err = predicate + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect_err("expiration reached"); + + assert!(err.to_string().contains("TransactionExpiration")); + } + + // The predicate has spent the funds + assert_address_balance( + predicate.address(), + &provider, + asset_id, + predicate_balance - amount_to_send - expected_fee, + ) + .await; + + // Funds were transferred + assert_address_balance( + receiver.address(), + &provider, + asset_id, + receiver_balance + amount_to_send, + ) + .await; + + Ok(()) +} diff --git a/e2e/tests/providers.rs b/e2e/tests/providers.rs index add3d1c4c..e67313546 100644 --- a/e2e/tests/providers.rs +++ b/e2e/tests/providers.rs @@ -283,38 +283,51 @@ async fn can_retrieve_latest_block_time() -> Result<()> { } #[tokio::test] -async fn contract_deployment_respects_maturity() -> Result<()> { +async fn contract_deployment_respects_maturity_and_expiration() -> Result<()> { abigen!(Contract(name="MyContract", abi="e2e/sway/contracts/transaction_block_height/out/release/transaction_block_height-abi.json")); - let wallets = - launch_custom_provider_and_get_wallets(WalletsConfig::default(), None, None).await?; - let wallet = &wallets[0]; - let provider = wallet.try_provider()?; + let wallet = launch_provider_and_get_wallet().await?; + let provider = wallet.try_provider()?.clone(); - let deploy_w_maturity = |maturity| { + let maturity = 10; + let expiration = 20; + + let deploy_w_maturity_and_expiration = || { Contract::load_from( "sway/contracts/transaction_block_height/out/release/transaction_block_height.bin", LoadConfiguration::default(), ) .map(|loaded_contract| { - loaded_contract - .deploy_if_not_exists(wallet, TxPolicies::default().with_maturity(maturity)) + loaded_contract.deploy( + &wallet, + TxPolicies::default() + .with_maturity(maturity) + .with_expiration(expiration), + ) }) }; - let err = deploy_w_maturity(1)?.await.expect_err( - "should not deploy contract since block height `0` is less than the requested maturity `1`", - ); + { + let err = deploy_w_maturity_and_expiration()? + .await + .expect_err("maturity not reached"); - let Error::Provider(s) = err else { - panic!("expected `Validation`, got: `{err}`"); - }; - assert!(s.contains("TransactionMaturity")); + assert!(err.to_string().contains("TransactionMaturity")); + } + { + provider.produce_blocks(15, None).await?; + deploy_w_maturity_and_expiration()? + .await + .expect("should succed. Block height between `maturity` and `expiration`"); + } + { + provider.produce_blocks(15, None).await?; + let err = deploy_w_maturity_and_expiration()? + .await + .expect_err("expiration reached"); - provider.produce_blocks(1, None).await?; - deploy_w_maturity(1)? - .await - .expect("Should deploy contract since maturity `1` is <= than the block height `1`"); + assert!(err.to_string().contains("TransactionExpiration")); + } Ok(()) } @@ -1087,12 +1100,14 @@ async fn tx_respects_policies() -> Result<()> { let tip = 22; let witness_limit = 1000; let maturity = 4; + let expiration = 128; let max_fee = 10_000; let script_gas_limit = 3000; let tx_policies = TxPolicies::new( Some(tip), Some(witness_limit), Some(maturity), + Some(expiration), Some(max_fee), Some(script_gas_limit), ); diff --git a/e2e/tests/scripts.rs b/e2e/tests/scripts.rs index b418dd10b..d77682c50 100644 --- a/e2e/tests/scripts.rs +++ b/e2e/tests/scripts.rs @@ -615,3 +615,50 @@ async fn loader_can_be_presented_as_a_normal_script_with_shifted_configurables() Ok(()) } + +#[tokio::test] +async fn script_call_respects_maturity_and_expiration() -> Result<()> { + abigen!(Script( + name = "MyScript", + abi = "e2e/sway/scripts/basic_script/out/release/basic_script-abi.json" + )); + let wallet = launch_provider_and_get_wallet().await.expect(""); + let provider = wallet.try_provider()?.clone(); + let bin_path = "sway/scripts/basic_script/out/release/basic_script.bin"; + + let script_instance = MyScript::new(wallet, bin_path); + + let maturity = 10; + let expiration = 20; + let call_handler = script_instance.main(1, 2).with_tx_policies( + TxPolicies::default() + .with_maturity(maturity) + .with_expiration(expiration), + ); + + { + let err = call_handler + .clone() + .call() + .await + .expect_err("maturity not reached"); + + assert!(err.to_string().contains("TransactionMaturity")); + } + { + provider.produce_blocks(15, None).await?; + call_handler + .clone() + .call() + .await + .expect("should succed. Block height between `maturity` and `expiration`"); + } + { + provider.produce_blocks(15, None).await?; + let err = call_handler.call().await.expect_err("expiration reached"); + + assert!(err.to_string().contains("TransactionExpiration")); + } + + Ok(()) +} diff --git a/e2e/tests/wallets.rs b/e2e/tests/wallets.rs index ae2f00d2e..ac800d5f9 100644 --- a/e2e/tests/wallets.rs +++ b/e2e/tests/wallets.rs @@ -7,6 +7,19 @@ use fuels::{ types::{output::Output, Bytes32}, }; +async fn assert_address_balance( + address: &Bech32Address, + provider: &Provider, + asset_id: AssetId, + amount: u64, +) { + let balance = provider + .get_asset_balance(address, asset_id) + .await + .expect("Could not retrieve balance"); + assert_eq!(balance, amount); +} + #[tokio::test] async fn test_wallet_balance_api_multi_asset() -> Result<()> { let mut wallet = WalletUnlocked::new_random(None); @@ -492,3 +505,62 @@ async fn test_transfer_with_multiple_signatures() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn wallet_transfer_respects_maturity_and_expiration() -> Result<()> { + let wallet = launch_provider_and_get_wallet().await?; + let asset_id = AssetId::zeroed(); + let wallet_balance = wallet.get_asset_balance(&asset_id).await?; + + let provider = wallet.try_provider()?; + let receiver = WalletUnlocked::new_random(None); + + let maturity = 10; + let expiration = 20; + let tx_policies = TxPolicies::default() + .with_maturity(maturity) + .with_expiration(expiration); + let amount_to_send = 10; + + // TODO: https://github.com/FuelLabs/fuels-rs/issues/1394 + let expected_fee = 1; + + { + let err = wallet + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect_err("maturity not reached"); + + assert!(err.to_string().contains("TransactionMaturity")); + } + { + provider.produce_blocks(15, None).await?; + wallet + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect("should succed. Block height between `maturity` and `expiration`"); + } + { + provider.produce_blocks(15, None).await?; + let err = wallet + .transfer(receiver.address(), amount_to_send, asset_id, tx_policies) + .await + .expect_err("expiration reached"); + + assert!(err.to_string().contains("TransactionExpiration")); + } + + // Wallet has spent the funds + assert_address_balance( + wallet.address(), + provider, + asset_id, + wallet_balance - amount_to_send - expected_fee, + ) + .await; + + // Funds were transferred + assert_address_balance(receiver.address(), provider, asset_id, amount_to_send).await; + + Ok(()) +} diff --git a/examples/contracts/src/lib.rs b/examples/contracts/src/lib.rs index 38fbd3e90..a42e6c5be 100644 --- a/examples/contracts/src/lib.rs +++ b/examples/contracts/src/lib.rs @@ -160,7 +160,8 @@ mod tests { let tx_policies = TxPolicies::default() .with_tip(1) .with_script_gas_limit(1_000_000) - .with_maturity(0); + .with_maturity(0) + .with_expiration(10_000); let contract_id_2 = Contract::load_from( "../../e2e/sway/contracts/contract_test/out/release/contract_test.bin", @@ -297,7 +298,8 @@ mod tests { let tx_policies = TxPolicies::default() .with_tip(1) .with_script_gas_limit(1_000_000) - .with_maturity(0); + .with_maturity(0) + .with_expiration(10_000); let response = contract_methods .initialize_counter(42) // Our contract method @@ -568,7 +570,8 @@ mod tests { // ANCHOR: multi_call_build let multi_call_handler = CallHandler::new_multi_call(wallet.clone()) .add_call(call_handler_1) - .add_call(call_handler_2); + .add_call(call_handler_2) + .with_tx_policies(TxPolicies::default()); // ANCHOR_END: multi_call_build let multi_call_handler_tmp = multi_call_handler.clone(); diff --git a/examples/cookbook/src/lib.rs b/examples/cookbook/src/lib.rs index 47e0b2d7b..cdc41d3ca 100644 --- a/examples/cookbook/src/lib.rs +++ b/examples/cookbook/src/lib.rs @@ -312,7 +312,9 @@ mod tests { // ANCHOR_END: custom_tx_adjust // ANCHOR: custom_tx_policies - let tx_policies = TxPolicies::default().with_tip(1); + let tx_policies = TxPolicies::default() + .with_maturity(1) + .with_expiration(1_0000); let tb = tb.with_tx_policies(tx_policies); // ANCHOR_END: custom_tx_policies diff --git a/packages/fuels-core/src/types/transaction_builders.rs b/packages/fuels-core/src/types/transaction_builders.rs index d1ce3ca2a..235e9bf9a 100644 --- a/packages/fuels-core/src/types/transaction_builders.rs +++ b/packages/fuels-core/src/types/transaction_builders.rs @@ -329,6 +329,7 @@ macro_rules! impl_tx_builder_trait { policies.set(PolicyType::MaxFee, self.tx_policies.tip().or(Some(0))); policies.set(PolicyType::Maturity, self.tx_policies.maturity()); policies.set(PolicyType::Tip, self.tx_policies.tip()); + policies.set(PolicyType::Expiration, self.tx_policies.expiration()); Ok(policies) } diff --git a/packages/fuels-core/src/types/wrappers/transaction.rs b/packages/fuels-core/src/types/wrappers/transaction.rs index 992aaa134..f6816d86b 100644 --- a/packages/fuels-core/src/types/wrappers/transaction.rs +++ b/packages/fuels-core/src/types/wrappers/transaction.rs @@ -107,6 +107,7 @@ pub struct TxPolicies { tip: Option, witness_limit: Option, maturity: Option, + expiration: Option, max_fee: Option, script_gas_limit: Option, } @@ -117,6 +118,7 @@ impl TxPolicies { tip: Option, witness_limit: Option, maturity: Option, + expiration: Option, max_fee: Option, script_gas_limit: Option, ) -> Self { @@ -124,6 +126,7 @@ impl TxPolicies { tip, witness_limit, maturity, + expiration, max_fee, script_gas_limit, } @@ -156,6 +159,15 @@ impl TxPolicies { self.maturity } + pub fn with_expiration(mut self, expiration: u64) -> Self { + self.expiration = Some(expiration); + self + } + + pub fn expiration(&self) -> Option { + self.expiration + } + pub fn with_max_fee(mut self, max_fee: u64) -> Self { self.max_fee = Some(max_fee); self @@ -248,6 +260,8 @@ pub trait Transaction: fn with_maturity(self, maturity: u32) -> Self; + fn expiration(&self) -> Option; + fn metered_bytes_size(&self) -> usize; fn inputs(&self) -> &Vec; @@ -429,6 +443,10 @@ macro_rules! impl_tx_wrapper { (*self.tx.maturity()).into() } + fn expiration(&self) -> Option { + self.tx.policies().get(PolicyType::Expiration) + } + fn with_maturity(mut self, maturity: u32) -> Self { self.tx.set_maturity(maturity.into()); self