diff --git a/.changes/added/2769.md b/.changes/added/2769.md new file mode 100644 index 00000000000..4f5d0decf17 --- /dev/null +++ b/.changes/added/2769.md @@ -0,0 +1,24 @@ +Added a new `assembleTx` GraphQL endpoint. +The endpoint can be used to assemble the transaction based on the provided requirements. + +The returned transaction contains: +- Input coins to cover `required_balances` +- Input coins to cover the fee of the transaction based on the gas price from `block_horizon` +- `Change` or `Destroy` outputs for all assets from the inputs +- `Variable` outputs in the case they are required during the execution +- `Contract` inputs and outputs in the case they are required during the execution +- Reserved witness slots for signed coins filled with `64` zeroes +- Set script gas limit(unless `script` is empty) +- Estimated predicates, if `estimate_predicates == true` + +Returns an error if: +- The number of required balances exceeds the maximum number of inputs allowed. +- The fee address index is out of bounds. +- The same asset has multiple change policies(either the receiver of + the change is different, or one of the policies states about the destruction + of the token while the other does not). The `Change` output from the transaction + also count as a `ChangePolicy`. +- The number of excluded coin IDs exceeds the maximum number of inputs allowed. +- Required assets have multiple entries. +- If accounts don't have sufficient amounts to cover the transaction requirements in assets. +- If a constructed transaction breaks the rules defined by consensus parameters. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fbe6241bf22..0b344e63891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3641,6 +3641,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "test-helpers", "tikv-jemallocator", "tokio", "toml 0.5.11", diff --git a/bin/e2e-test-client/Cargo.toml b/bin/e2e-test-client/Cargo.toml index fc9241fa8df..390f3f99051 100644 --- a/bin/e2e-test-client/Cargo.toml +++ b/bin/e2e-test-client/Cargo.toml @@ -9,7 +9,7 @@ license = { workspace = true } repository = { workspace = true } version = { workspace = true } name = "fuel-core-e2e-client" -publish = true +publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] @@ -24,6 +24,7 @@ itertools = { workspace = true } libtest-mimic = "0.6.0" serde = { workspace = true } serde_json = { workspace = true } +test-helpers = { path = "../../tests/test-helpers" } tikv-jemallocator = { workspace = true } tokio = { workspace = true } toml = { version = "0.5" } diff --git a/bin/e2e-test-client/src/test_context.rs b/bin/e2e-test-client/src/test_context.rs index 1cb6bcb8b07..6f1e9e0b23d 100644 --- a/bin/e2e-test-client/src/test_context.rs +++ b/bin/e2e-test-client/src/test_context.rs @@ -41,6 +41,10 @@ use fuel_core_types::{ fuel_vm::SecretKey, }; use itertools::Itertools; +use test_helpers::assemble_tx::{ + AssembleAndRunTx, + SigningAccount, +}; use crate::config::{ ClientConfig, @@ -121,42 +125,15 @@ impl Wallet { asset_id: Option, ) -> anyhow::Result { let asset_id = asset_id.unwrap_or(*self.consensus_params.base_asset_id()); - let total_amount = transfer_amount + BASE_AMOUNT; - // select coins - let coins = &self + let tx = self .client - .coins_to_spend(&self.address, vec![(asset_id, total_amount, None)], None) - .await?[0]; - - // build transaction - let mut tx = TransactionBuilder::script(Default::default(), Default::default()); - tx.max_fee_limit(BASE_AMOUNT); - tx.script_gas_limit(0); - - for coin in coins { - if let CoinType::Coin(coin) = coin { - tx.add_unsigned_coin_input( - self.secret, - coin.utxo_id, - coin.amount, - coin.asset_id, - Default::default(), - ); - } - } - tx.add_output(Output::Coin { - to: destination, - amount: transfer_amount, - asset_id, - }); - tx.add_output(Output::Change { - to: self.address, - amount: 0, - asset_id, - }); - tx.with_params(self.consensus_params.clone()); + .assemble_transfer( + SigningAccount::Wallet(self.secret), + vec![(destination, asset_id, transfer_amount)], + ) + .await?; - Ok(tx.finalize_as_transaction()) + Ok(tx) } /// Creates the script transaction that collects fee. @@ -165,16 +142,6 @@ impl Wallet { coinbase_contract: ContractId, asset_id: AssetId, ) -> anyhow::Result { - // select coins - let coins = &self - .client - .coins_to_spend( - &self.address, - vec![(*self.consensus_params.base_asset_id(), BASE_AMOUNT, None)], - None, - ) - .await?[0]; - let output_index = 2u64; let call_struct_register = 0x10; // Now call the fee collection contract to withdraw the fees @@ -189,59 +156,21 @@ impl Wallet { op::call(call_struct_register, RegId::ZERO, RegId::ZERO, RegId::CGAS), op::ret(RegId::ONE), ]; + let script_data = asset_id + .to_bytes() + .into_iter() + .chain(output_index.to_bytes().into_iter()) + .chain(coinbase_contract.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .collect(); - // build transaction - let mut tx_builder = TransactionBuilder::script( - script.into_iter().collect(), - asset_id - .to_bytes() - .into_iter() - .chain(output_index.to_bytes().into_iter()) - .chain(coinbase_contract.to_bytes().into_iter()) - .chain(0u64.to_bytes().into_iter()) - .chain(0u64.to_bytes().into_iter()) - .collect(), - ); - tx_builder.max_fee_limit(BASE_AMOUNT); - tx_builder - .script_gas_limit(self.consensus_params.tx_params().max_gas_per_tx() / 10); - - tx_builder.add_input(Input::contract( - Default::default(), - Default::default(), - Default::default(), - Default::default(), - coinbase_contract, - )); - for coin in coins { - if let CoinType::Coin(coin) = coin { - tx_builder.add_unsigned_coin_input( - self.secret, - coin.utxo_id, - coin.amount, - coin.asset_id, - Default::default(), - ); - } - } - tx_builder.add_output(Output::contract( - 0, - Default::default(), - Default::default(), - )); - tx_builder.add_output(Output::Change { - to: self.address, - amount: 0, - asset_id, - }); - tx_builder.add_output(Output::Variable { - to: Default::default(), - amount: Default::default(), - asset_id: Default::default(), - }); - tx_builder.with_params(self.consensus_params.clone()); + let tx = self + .client + .assemble_script(script, script_data, SigningAccount::Wallet(self.secret)) + .await?; - Ok(tx_builder.finalize_as_transaction()) + Ok(tx) } /// Transfers coins from this wallet to another @@ -280,7 +209,11 @@ impl Wallet { // select coins let coins = &self .client - .coins_to_spend(&self.address, vec![(asset_id, total_amount, None)], None) + .coins_to_spend( + &self.address, + vec![(asset_id, u128::from(total_amount), None)], + None, + ) .await?[0]; let ContractConfig { diff --git a/bin/fuel-core/src/cli/run.rs b/bin/fuel-core/src/cli/run.rs index 6f84b2f6022..d7f152e6c7e 100644 --- a/bin/fuel-core/src/cli/run.rs +++ b/bin/fuel-core/src/cli/run.rs @@ -634,6 +634,7 @@ impl Command { coins_to_spend: graphql.costs.coins_to_spend, get_peers: graphql.costs.get_peers, estimate_predicates: graphql.costs.estimate_predicates, + assemble_tx: graphql.costs.assemble_tx, dry_run: graphql.costs.dry_run, storage_read_replay: graphql.costs.storage_read_replay, submit: graphql.costs.submit, diff --git a/bin/fuel-core/src/cli/run/graphql.rs b/bin/fuel-core/src/cli/run/graphql.rs index 064dca2ceff..a71bbc379e9 100644 --- a/bin/fuel-core/src/cli/run/graphql.rs +++ b/bin/fuel-core/src/cli/run/graphql.rs @@ -131,6 +131,14 @@ pub struct QueryCosts { )] pub dry_run: usize, + /// Query costs for assembling the transaction. + #[clap( + long = "query-cost-assemble-tx", + default_value = DEFAULT_QUERY_COSTS.assemble_tx.to_string(), + env + )] + pub assemble_tx: usize, + /// Query costs for generating execution trace for a block. #[clap( long = "query-cost-storage-read-replay", diff --git a/crates/chain-config/src/config/state.rs b/crates/chain-config/src/config/state.rs index 490a36b148f..a1ac6a25e9c 100644 --- a/crates/chain-config/src/config/state.rs +++ b/crates/chain-config/src/config/state.rs @@ -77,7 +77,7 @@ mod writer; // Fuel Network human-readable part for bech32 encoding pub const FUEL_BECH32_HRP: &str = "fuel"; -pub const TESTNET_INITIAL_BALANCE: u64 = 10_000_000; +pub const TESTNET_INITIAL_BALANCE: u64 = 10_000_000_000; pub const TESTNET_WALLET_SECRETS: [&str; 5] = [ "0xde97d8624a438121b86a1956544bd72ed68cd69f2c99555b08b1e8c51ffd511c", diff --git a/crates/client/assets/schema.sdl b/crates/client/assets/schema.sdl index 8b55e5b663c..d974add1173 100644 --- a/crates/client/assets/schema.sdl +++ b/crates/client/assets/schema.sdl @@ -1,5 +1,16 @@ +input Account @oneOf { + address: Address + predicate: Predicate +} + scalar Address +type AssembleTransactionResult { + transaction: Transaction! + status: DryRunTransactionStatus! + gasPrice: U64! +} + scalar AssetId type AssetInfoDetails { @@ -128,6 +139,18 @@ type ChangeOutput { assetId: AssetId! } +input ChangePolicy @oneOf { + """ + Adds `Output::Change` to the transaction if it is not already present. + Sending remaining assets to the provided address. + """ + change: Address + """ + Destroys the remaining assets by the transaction for provided address. + """ + destroy: Destroy +} + type Coin { utxoId: UtxoId! owner: Address! @@ -297,6 +320,10 @@ type DaCompressedBlock { union DependentCost = LightOperation | HeavyOperation +enum Destroy { + DESTROY +} + type DryRunFailureStatus { programState: ProgramState reason: String! @@ -608,7 +635,7 @@ type InputCoin { amount: U64! assetId: AssetId! txPointer: TxPointer! - witnessIndex: Int! + witnessIndex: U16! predicateGasUsed: U64! predicate: HexString! predicateData: HexString! @@ -891,6 +918,12 @@ type PreconfirmationSuccessStatus { resolvedOutputs: [Output!] } +input Predicate { + predicateAddress: Address! + predicate: HexString! + predicateData: HexString! +} + type PredicateParameters { version: PredicateParametersVersion! maxPredicateLength: U64! @@ -961,6 +994,60 @@ type Query { transactions(first: Int, after: String, last: Int, before: String): TransactionConnection! transactionsByOwner(owner: Address!, first: Int, after: String, last: Int, before: String): TransactionConnection! """ + Assembles the transaction based on the provided requirements. + The return transaction contains: + - Input coins to cover `required_balances` + - Input coins to cover the fee of the transaction based on the gas price from `block_horizon` + - `Change` or `Destroy` outputs for all assets from the inputs + - `Variable` outputs in the case they are required during the execution + - `Contract` inputs and outputs in the case they are required during the execution + - Reserved witness slots for signed coins filled with `64` zeroes + - Set script gas limit(unless `script` is empty) + - Estimated predicates, if `estimate_predicates == true` + + Returns an error if: + - The number of required balances exceeds the maximum number of inputs allowed. + - The fee address index is out of bounds. + - The same asset has multiple change policies(either the receiver of + the change is different, or one of the policies states about the destruction + of the token while the other does not). The `Change` output from the transaction + also count as a `ChangePolicy`. + - The number of excluded coin IDs exceeds the maximum number of inputs allowed. + - Required assets have multiple entries. + - If accounts don't have sufficient amounts to cover the transaction requirements in assets. + - If a constructed transaction breaks the rules defined by consensus parameters. + """ + assembleTx( + """ + The original transaction that contains application level logic only + """ + tx: HexString!, + """ + Number of blocks into the future to estimate the gas price for + """ + blockHorizon: U32!, + """ + The list of required balances for the transaction to include as inputs. The list should be created based on the application-required assets. The base asset requirement should not require assets to cover the transaction fee, which will be calculated and added automatically at the end of the assembly process. + """ + requiredBalances: [RequiredBalance!]!, + """ + The index from the `required_balances` list that points to the address who pays fee for the transaction. If you only want to cover the fee of transaction, you can set the required balance to 0, set base asset and point to this required address. + """ + feeAddressIndex: U16!, + """ + The list of resources to exclude from the selection for the inputs + """ + excludeInput: ExcludeInput, + """ + Perform the estimation of the predicates before cover fee of the transaction + """ + estimatePredicates: Boolean, + """ + During the phase of the fee calculation, adds `reserve_gas` to the total gas used by the transaction and fetch assets to cover the fee. + """ + reserveGas: U64 + ): AssembleTransactionResult! + """ Estimate the predicate gas for the provided transaction """ estimatePredicates(tx: HexString!): Transaction! @@ -1137,6 +1224,13 @@ scalar RelayedTransactionId union RelayedTransactionStatus = RelayedTransactionFailed +input RequiredBalance { + assetId: AssetId! + amount: U64! + account: Account! + changePolicy: ChangePolicy! +} + enum ReturnType { RETURN RETURN_DATA @@ -1182,11 +1276,11 @@ input SpendQueryElementInput { """ Target amount for the query. """ - amount: U64! + amount: U128! """ The maximum number of currencies for selection. """ - max: U32 + max: U16 } type SqueezedOutStatus { @@ -1410,6 +1504,7 @@ type VariableOutput { directive @deprecated(reason: String = "No longer supported") on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @oneOf on INPUT_OBJECT directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT schema { query: Query diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index cd5c3471659..11038bb867d 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -110,6 +110,7 @@ use schema::{ StorageReadReplayArgs, }, tx::{ + AssembleTxArg, TransactionsByOwnerConnectionArgs, TxArg, TxIdArgs, @@ -153,6 +154,10 @@ use std::{ use tai64::Tai64; use tracing as _; use types::{ + assemble_tx::{ + AssembleTransactionResult, + RequiredBalance, + }, TransactionResponse, TransactionStatus, }; @@ -743,6 +748,68 @@ impl FuelClient { .collect()) } + /// Assembles the transaction based on the provided requirements. + /// The return transaction contains: + /// - Input coins to cover `required_balances` + /// - Input coins to cover the fee of the transaction based on the gas price from `block_horizon` + /// - `Change` or `Destroy` outputs for all assets from the inputs + /// - `Variable` outputs in the case they are required during the execution + /// - `Contract` inputs and outputs in the case they are required during the execution + /// - Reserved witness slots for signed coins filled with `64` zeroes + /// - Set script gas limit(unless `script` is empty) + /// - Estimated predicates, if `estimate_predicates == true` + /// + /// Returns an error if: + /// - The number of required balances exceeds the maximum number of inputs allowed. + /// - The fee address index is out of bounds. + /// - The same asset has multiple change policies(either the receiver of + /// the change is different, or one of the policies states about the destruction + /// of the token while the other does not). The `Change` output from the transaction + /// also count as a `ChangePolicy`. + /// - The number of excluded coin IDs exceeds the maximum number of inputs allowed. + /// - Required assets have multiple entries. + /// - If accounts don't have sufficient amounts to cover the transaction requirements in assets. + /// - If a constructed transaction breaks the rules defined by consensus parameters. + #[allow(clippy::too_many_arguments)] + pub async fn assemble_tx( + &self, + tx: &Transaction, + block_horizon: u32, + required_balances: Vec, + fee_address_index: u16, + exclude: Option<(Vec, Vec)>, + estimate_predicates: bool, + reserve_gas: Option, + ) -> io::Result { + let tx = HexString(Bytes(tx.to_bytes())); + let block_horizon = block_horizon.into(); + + let required_balances: Vec<_> = required_balances + .into_iter() + .map(schema::tx::RequiredBalance::try_from) + .collect::, _>>()?; + + let fee_address_index = fee_address_index.into(); + + let exclude_input = exclude.map(Into::into); + + let reserve_gas = reserve_gas.map(U64::from); + + let query_arg = AssembleTxArg { + tx, + block_horizon, + required_balances, + fee_address_index, + exclude_input, + estimate_predicates, + reserve_gas, + }; + + let query = schema::tx::AssembleTx::build(query_arg); + let assemble_tx_result = self.query(query).await.map(|r| r.assemble_tx)?; + Ok(assemble_tx_result.try_into()?) + } + /// Estimate predicates for the transaction pub async fn estimate_predicates(&self, tx: &mut Transaction) -> io::Result<()> { let serialized_tx = tx.to_bytes(); @@ -1331,7 +1398,7 @@ impl FuelClient { pub async fn coins_to_spend( &self, owner: &Address, - spend_query: Vec<(AssetId, u64, Option)>, + spend_query: Vec<(AssetId, u128, Option)>, // (Utxos, Messages Nonce) excluded_ids: Option<(Vec, Vec)>, ) -> io::Result>> { @@ -1346,16 +1413,7 @@ impl FuelClient { }) }) .try_collect()?; - let excluded_ids: Option = excluded_ids - .map( - |(utxos, nonces)| -> (Vec, Vec) { - ( - utxos.into_iter().map(Into::into).collect(), - nonces.into_iter().map(Into::into).collect(), - ) - }, - ) - .map(Into::into); + let excluded_ids: Option = excluded_ids.map(Into::into); let args = schema::coins::CoinsToSpendArgs::from((owner, spend_query, excluded_ids)); let query = schema::coins::CoinsToSpendQuery::build(args); diff --git a/crates/client/src/client/schema/coins.rs b/crates/client/src/client/schema/coins.rs index 00d84bd8dc8..ef4bf7f2637 100644 --- a/crates/client/src/client/schema/coins.rs +++ b/crates/client/src/client/schema/coins.rs @@ -6,6 +6,7 @@ use crate::client::{ Nonce, PageInfo, UtxoId, + U128, U16, U32, U64, @@ -13,6 +14,10 @@ use crate::client::{ PageDirection, PaginationRequest, }; +use fuel_core_types::{ + fuel_tx, + fuel_types, +}; #[derive(cynic::QueryVariables, Debug)] pub struct CoinByIdArgs { @@ -139,15 +144,26 @@ impl From<(Vec, Vec)> for ExcludeInput { } } +impl From<(Vec, Vec)> for ExcludeInput { + fn from(value: (Vec, Vec)) -> Self { + let vectors: (Vec, Vec) = ( + value.0.into_iter().map(Into::into).collect(), + value.1.into_iter().map(Into::into).collect(), + ); + + vectors.into() + } +} + #[derive(cynic::InputObject, Clone, Debug)] #[cynic(schema_path = "./assets/schema.sdl")] pub struct SpendQueryElementInput { /// asset ID of the coins pub asset_id: AssetId, /// the amount to cover with this asset - pub amount: U64, + pub amount: U128, /// the maximum number of coins per asset from the owner to return. - pub max: Option, + pub max: Option, } #[derive(cynic::QueryFragment, Debug, Clone)] diff --git a/crates/client/src/client/schema/primitives.rs b/crates/client/src/client/schema/primitives.rs index d183eb210ac..5b0b9131a55 100644 --- a/crates/client/src/client/schema/primitives.rs +++ b/crates/client/src/client/schema/primitives.rs @@ -178,7 +178,7 @@ impl LowerHex for TxPointer { } } -#[derive(cynic::Scalar, Debug, Clone)] +#[derive(cynic::Scalar, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct HexString(pub Bytes); impl From for Vec { @@ -187,6 +187,12 @@ impl From for Vec { } } +impl From> for HexString { + fn from(s: Vec) -> Self { + HexString(Bytes(s)) + } +} + impl Deref for HexString { type Target = Bytes; @@ -195,7 +201,7 @@ impl Deref for HexString { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Bytes(pub Vec); impl FromStr for Bytes { diff --git a/crates/client/src/client/schema/tx.rs b/crates/client/src/client/schema/tx.rs index b3845e5c931..87b83ba6f35 100644 --- a/crates/client/src/client/schema/tx.rs +++ b/crates/client/src/client/schema/tx.rs @@ -1,11 +1,13 @@ use crate::client::{ schema::{ + coins::ExcludeInput, schema, tx::{ transparent_receipt::Receipt, transparent_tx::Output, }, Address, + AssetId, ConnectionArgsFields, ConversionError, HexString, @@ -13,6 +15,7 @@ use crate::client::{ Tai64Timestamp, TransactionId, TxPointer, + U16, U32, U64, }, @@ -499,6 +502,85 @@ pub struct EstimatePredicates { pub estimate_predicates: OpaqueTransaction, } +#[derive(cynic::InputObject, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct Predicate { + pub predicate_address: Address, + pub predicate: HexString, + pub predicate_data: HexString, +} + +#[derive(cynic::InputObject, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct Account { + #[cynic(skip_serializing_if = "Option::is_none")] + pub address: Option
, + #[cynic(skip_serializing_if = "Option::is_none")] + pub predicate: Option, +} + +#[derive(cynic::InputObject, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct ChangePolicy { + #[cynic(skip_serializing_if = "Option::is_none")] + pub change: Option
, + #[cynic(skip_serializing_if = "Option::is_none")] + pub destroy: Option, +} + +#[derive(cynic::Enum, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub enum Destroy { + Destroy, +} + +#[derive(cynic::InputObject, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct RequiredBalance { + pub asset_id: AssetId, + pub amount: U64, + pub account: Account, + pub change_policy: ChangePolicy, +} + +#[derive(cynic::QueryVariables)] +pub struct AssembleTxArg { + pub tx: HexString, + pub block_horizon: U32, + pub required_balances: Vec, + pub fee_address_index: U16, + pub exclude_input: Option, + pub estimate_predicates: bool, + pub reserve_gas: Option, +} + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic( + schema_path = "./assets/schema.sdl", + graphql_type = "Query", + variables = "AssembleTxArg" +)] +pub struct AssembleTx { + #[arguments( + tx: $tx, + blockHorizon: $block_horizon, + requiredBalances: $required_balances, + feeAddressIndex: $fee_address_index, + excludeInput: $exclude_input, + estimatePredicates: $estimate_predicates, + reserveGas: $reserve_gas, + )] + pub assemble_tx: AssembleTransactionResult, +} + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct AssembleTransactionResult { + pub transaction: OpaqueTransaction, + pub status: DryRunTransactionStatus, + pub gas_price: U64, +} + #[derive(cynic::QueryVariables)] pub struct DryRunArg { pub txs: Vec, diff --git a/crates/client/src/client/schema/tx/transparent_tx.rs b/crates/client/src/client/schema/tx/transparent_tx.rs index 8d5f3e7e990..911e921ba5a 100644 --- a/crates/client/src/client/schema/tx/transparent_tx.rs +++ b/crates/client/src/client/schema/tx/transparent_tx.rs @@ -472,7 +472,7 @@ pub struct InputCoin { pub amount: U64, pub asset_id: AssetId, pub tx_pointer: TxPointer, - pub witness_index: i32, + pub witness_index: U16, pub predicate_gas_used: U64, pub predicate: HexString, pub predicate_data: HexString, @@ -515,7 +515,7 @@ impl TryFrom for fuel_tx::Input { coin.amount.into(), coin.asset_id.into(), coin.tx_pointer.into(), - coin.witness_index.try_into()?, + coin.witness_index.into(), ) } else { fuel_tx::Input::coin_predicate( diff --git a/crates/client/src/client/types.rs b/crates/client/src/client/types.rs index a68f31105b8..8345bcf91a7 100644 --- a/crates/client/src/client/types.rs +++ b/crates/client/src/client/types.rs @@ -7,6 +7,7 @@ pub mod contract; pub mod gas_costs; pub mod upgrades; +pub mod assemble_tx; pub mod asset; pub mod gas_price; pub mod merkle_proof; diff --git a/crates/client/src/client/types/assemble_tx.rs b/crates/client/src/client/types/assemble_tx.rs new file mode 100644 index 00000000000..3bb4de6a290 --- /dev/null +++ b/crates/client/src/client/types/assemble_tx.rs @@ -0,0 +1,130 @@ +use fuel_core_types::{ + fuel_tx::{ + Address, + AssetId, + Transaction, + }, + fuel_types::canonical::Deserialize, + services::executor::TransactionExecutionResult, +}; + +use crate::client::schema::{ + self, + tx::Destroy, + ConversionError, +}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct RequiredBalance { + pub asset_id: AssetId, + pub amount: u64, + pub account: Account, + pub change_policy: ChangePolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Predicate { + pub address: Address, + pub predicate: Vec, + pub predicate_data: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum Account { + Address(Address), + Predicate(Predicate), +} + +impl Account { + pub fn owner(&self) -> Address { + match self { + Account::Address(address) => *address, + Account::Predicate(predicate) => predicate.address, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum ChangePolicy { + Change(Address), + Destroy, +} + +#[derive(Debug, Clone)] +pub struct AssembleTransactionResult { + pub transaction: Transaction, + pub status: TransactionExecutionResult, + pub gas_price: u64, +} + +impl TryFrom for AssembleTransactionResult { + type Error = ConversionError; + + fn try_from( + value: schema::tx::AssembleTransactionResult, + ) -> Result { + let transaction = Transaction::from_bytes(&value.transaction.raw_payload) + .map_err(ConversionError::TransactionFromBytesError)?; + let status = value.status.try_into()?; + + Ok(Self { + transaction, + status, + gas_price: value.gas_price.into(), + }) + } +} + +impl TryFrom for schema::tx::RequiredBalance { + type Error = ConversionError; + + fn try_from(value: RequiredBalance) -> Result { + let asset_id = value.asset_id.into(); + let amount = value.amount.into(); + let account = value.account.into(); + let change_policy = value.change_policy.into(); + + Ok(Self { + asset_id, + amount, + account, + change_policy, + }) + } +} + +impl From for schema::tx::Account { + fn from(value: Account) -> Self { + let (address, predicate) = match value { + Account::Address(address) => (Some(address.into()), None), + Account::Predicate(predicate) => (None, Some(predicate.into())), + }; + + Self { address, predicate } + } +} + +impl From for schema::tx::Predicate { + fn from(value: Predicate) -> Self { + let predicate_address = value.address.into(); + let predicate = value.predicate.into(); + let predicate_data = value.predicate_data.into(); + + Self { + predicate_address, + predicate, + predicate_data, + } + } +} + +impl From for schema::tx::ChangePolicy { + fn from(value: ChangePolicy) -> Self { + let (change, destroy) = match value { + ChangePolicy::Change(address) => (Some(address.into()), None), + ChangePolicy::Destroy => (None, Some(Destroy::Destroy)), + }; + + Self { change, destroy } + } +} diff --git a/crates/fuel-core/src/coins_query.rs b/crates/fuel-core/src/coins_query.rs index 2fa36188eff..e14ff47ddaa 100644 --- a/crates/fuel-core/src/coins_query.rs +++ b/crates/fuel-core/src/coins_query.rs @@ -17,16 +17,10 @@ use fuel_core_storage::{ Result as StorageResult, }; use fuel_core_types::{ - entities::coins::{ - CoinId, - CoinType, - }, - fuel_tx::UtxoId, + entities::coins::CoinType, fuel_types::{ Address, AssetId, - Nonce, - Word, }, }; use futures::{ @@ -36,8 +30,8 @@ use futures::{ }; use rand::prelude::*; use std::{ + borrow::Cow, cmp::Reverse, - collections::HashSet, }; use thiserror::Error; @@ -48,7 +42,7 @@ pub enum CoinsQueryError { #[error("the target cannot be met due to no coins available or exceeding the {max} coin limit.")] InsufficientCoinsForTheMax { asset_id: AssetId, - collected_amount: Word, + collected_amount: u128, max: u16, }, #[error("the query contains duplicate assets")] @@ -67,6 +61,8 @@ pub enum CoinsQueryError { UnexpectedInternalState(&'static str), #[error("coins to spend index contains incorrect key")] IncorrectCoinsToSpendIndexKey, + #[error("unknown error: {0}")] + Other(anyhow::Error), } #[cfg(test)] @@ -76,50 +72,23 @@ impl PartialEq for CoinsQueryError { } } -pub struct ExcludedCoinIds<'a> { - coins: HashSet<&'a UtxoId>, - messages: HashSet<&'a Nonce>, -} - -impl<'a> ExcludedCoinIds<'a> { - pub(crate) fn new( - coins: impl Iterator, - messages: impl Iterator, - ) -> Self { - Self { - coins: coins.collect(), - messages: messages.collect(), - } - } - - pub(crate) fn is_coin_excluded(&self, coin: &UtxoId) -> bool { - self.coins.contains(&coin) - } - - pub(crate) fn is_message_excluded(&self, message: &Nonce) -> bool { - self.messages.contains(&message) - } -} - /// The prepared spend queries. -pub struct SpendQuery { +pub struct SpendQuery<'a> { owner: Address, query_per_asset: Vec, - exclude: Exclude, + exclude: Cow<'a, Exclude>, base_asset_id: AssetId, } -impl SpendQuery { +impl<'s> SpendQuery<'s> { // TODO: Check that number of `queries` is not too high(to prevent attacks). // https://github.com/FuelLabs/fuel-core/issues/588#issuecomment-1240074551 pub fn new( owner: Address, query_per_asset: &[AssetSpendTarget], - exclude_vec: Option>, + exclude: Cow<'s, Exclude>, base_asset_id: AssetId, ) -> Result { - let exclude = exclude_vec.map_or_else(Default::default, Exclude::new); - Ok(Self { owner, query_per_asset: query_per_asset.to_vec(), @@ -142,7 +111,7 @@ impl SpendQuery { &self.owner, asset, &self.base_asset_id, - Some(&self.exclude), + Some(self.exclude.as_ref()), db, ) }) @@ -151,7 +120,7 @@ impl SpendQuery { /// Returns exclude that contains information about excluded ids. pub fn exclude(&self) -> &Exclude { - &self.exclude + self.exclude.as_ref() } /// Returns the owner of the query. @@ -172,7 +141,7 @@ pub async fn largest_first( let mut inputs: Vec = query.coins().try_collect().await?; inputs.sort_by_key(|coin| Reverse(coin.amount())); - let mut collected_amount = 0u64; + let mut collected_amount = 0u128; let mut coins = vec![]; for coin in inputs { @@ -191,7 +160,7 @@ pub async fn largest_first( } // Add to list - collected_amount = collected_amount.saturating_add(coin.amount()); + collected_amount = collected_amount.saturating_add(coin.amount() as u128); coins.push(coin); } @@ -209,7 +178,7 @@ pub async fn largest_first( // An implementation of the method described on: https://iohk.io/en/blog/posts/2018/07/03/self-organisation-in-coin-selection/ pub async fn random_improve( db: &ReadView, - spend_query: &SpendQuery, + spend_query: &SpendQuery<'_>, ) -> Result>, CoinsQueryError> { let mut coins_per_asset = vec![]; @@ -231,7 +200,9 @@ pub async fn random_improve( // Try to improve the result by adding dust to the result. if collected_amount >= target { // Break if found coin exceeds max `u64` or the upper limit - if collected_amount == u64::MAX || coin.amount() > upper_target { + if collected_amount >= u64::MAX as u128 + || coin.amount() as u128 > upper_target + { break } @@ -241,14 +212,14 @@ pub async fn random_improve( .expect("We checked it above"); let distance = target.abs_diff(change_amount); let next_distance = - target.abs_diff(change_amount.saturating_add(coin.amount())); + target.abs_diff(change_amount.saturating_add(coin.amount() as u128)); if next_distance >= distance { break } } // Add to list - collected_amount = collected_amount.saturating_add(coin.amount()); + collected_amount = collected_amount.saturating_add(coin.amount() as u128); coins.push(coin); } @@ -268,17 +239,17 @@ pub async fn select_coins_to_spend( big_coins_iter, dust_coins_iter, }: CoinsToSpendIndexIter<'_>, - total: u64, + total: u128, max: u16, asset_id: &AssetId, - excluded_ids: &ExcludedCoinIds<'_>, + exclude: &Exclude, batch_size: usize, ) -> Result, CoinsQueryError> { // We aim to reduce dust creation by targeting twice the required amount for selection, // inspired by the random-improve approach. This increases the likelihood of generating // useful change outputs for future transactions, minimizing unusable dust outputs. // See also "let upper_target = target.saturating_mul(2);" in "fn random_improve()". - const TOTAL_AMOUNT_ADJUSTMENT_FACTOR: u64 = 2; + const TOTAL_AMOUNT_ADJUSTMENT_FACTOR: u128 = 2; // After selecting large coins that cover at least twice the required amount, // we include a limited number of small (dust) coins. The maximum number of dust coins @@ -304,7 +275,7 @@ pub async fn select_coins_to_spend( let dust_coins_stream = futures::stream::iter(dust_coins_iter).yield_each(batch_size); let (selected_big_coins_total, selected_big_coins) = - big_coins(big_coins_stream, adjusted_total, max, excluded_ids).await?; + big_coins(big_coins_stream, adjusted_total, max, exclude).await?; if selected_big_coins_total < total { return Err(CoinsQueryError::InsufficientCoinsForTheMax { @@ -339,7 +310,7 @@ pub async fn select_coins_to_spend( dust_coins_stream, last_selected_big_coin, max_dust_count, - excluded_ids, + exclude, ) .await?; @@ -354,11 +325,11 @@ pub async fn select_coins_to_spend( async fn big_coins( big_coins_stream: impl Stream> + Unpin, - total: u64, + total: u128, max: u16, - excluded_ids: &ExcludedCoinIds<'_>, -) -> Result<(u64, Vec), CoinsQueryError> { - select_coins_until(big_coins_stream, max, excluded_ids, |_, total_so_far| { + exclude: &Exclude, +) -> Result<(u128, Vec), CoinsQueryError> { + select_coins_until(big_coins_stream, max, exclude, |_, total_so_far| { total_so_far >= total }) .await @@ -368,35 +339,32 @@ async fn dust_coins( dust_coins_stream: impl Stream> + Unpin, last_big_coin: &CoinsToSpendIndexKey, max_dust_count: u16, - excluded_ids: &ExcludedCoinIds<'_>, -) -> Result<(u64, Vec), CoinsQueryError> { - select_coins_until( - dust_coins_stream, - max_dust_count, - excluded_ids, - |coin, _| coin == last_big_coin, - ) + exclude: &Exclude, +) -> Result<(u128, Vec), CoinsQueryError> { + select_coins_until(dust_coins_stream, max_dust_count, exclude, |coin, _| { + coin == last_big_coin + }) .await } async fn select_coins_until( mut coins_stream: impl Stream> + Unpin, max: u16, - excluded_ids: &ExcludedCoinIds<'_>, + exclude: &Exclude, predicate: Pred, -) -> Result<(u64, Vec), CoinsQueryError> +) -> Result<(u128, Vec), CoinsQueryError> where - Pred: Fn(&CoinsToSpendIndexKey, u64) -> bool, + Pred: Fn(&CoinsToSpendIndexKey, u128) -> bool, { - let mut coins_total_value: u64 = 0; + let mut coins_total_value: u128 = 0; let mut coins = Vec::with_capacity(max as usize); while let Some(coin) = coins_stream.next().await { let coin = coin?; - if !is_excluded(&coin, excluded_ids) { + if !is_excluded(&coin, exclude) { if coins.len() >= max as usize || predicate(&coin, coins_total_value) { break; } - let amount = coin.amount(); + let amount = coin.amount() as u128; coins_total_value = coins_total_value.saturating_add(amount); coins.push(coin); } @@ -404,14 +372,10 @@ where Ok((coins_total_value, coins)) } -fn is_excluded(key: &CoinsToSpendIndexKey, excluded_ids: &ExcludedCoinIds) -> bool { +fn is_excluded(key: &CoinsToSpendIndexKey, exclude: &Exclude) -> bool { match key { - CoinsToSpendIndexKey::Coin { utxo_id, .. } => { - excluded_ids.is_coin_excluded(utxo_id) - } - CoinsToSpendIndexKey::Message { nonce, .. } => { - excluded_ids.is_message_excluded(nonce) - } + CoinsToSpendIndexKey::Coin { utxo_id, .. } => exclude.contains_coin(utxo_id), + CoinsToSpendIndexKey::Message { nonce, .. } => exclude.contains_message(nonce), } } @@ -427,11 +391,11 @@ fn max_dust_count(max: u16, big_coins_len: u16, dust_to_big_coins_factor: u16) - fn skip_big_coins_up_to_amount( big_coins: impl IntoIterator, - skipped_amount: u64, + skipped_amount: u128, ) -> impl Iterator { let mut current_dust_coins_value = skipped_amount; big_coins.into_iter().skip_while(move |item| { - let item_amount = item.amount(); + let item_amount = item.amount() as u128; current_dust_coins_value .checked_sub(item_amount) .map(|new_value| { @@ -448,6 +412,12 @@ impl From for CoinsQueryError { } } +impl From for CoinsQueryError { + fn from(e: anyhow::Error) -> Self { + CoinsQueryError::Other(e) + } +} + #[allow(clippy::arithmetic_side_effects)] #[cfg(test)] mod tests { @@ -476,6 +446,7 @@ mod tests { query::asset_query::{ AssetQuery, AssetSpendTarget, + Exclude, }, }; use assert_matches::assert_matches; @@ -513,7 +484,10 @@ mod tests { Rng, SeedableRng, }; - use std::cmp::Reverse; + use std::{ + borrow::Cow, + cmp::Reverse, + }; fn setup_coins() -> (Address, [AssetId; 2], AssetId, TestDatabase) { let mut rng = StdRng::seed_from_u64(0xf00df00d); @@ -739,6 +713,8 @@ mod tests { mod random_improve { use super::*; + use crate::query::asset_query::Exclude; + use std::borrow::Cow; async fn query( query_per_asset: Vec, @@ -749,7 +725,12 @@ mod tests { ) -> Result, CoinsQueryError> { let coins = random_improve( &db.test_view(), - &SpendQuery::new(owner, &query_per_asset, None, base_asset_id)?, + &SpendQuery::new( + owner, + &query_per_asset, + Cow::Owned(Exclude::default()), + base_asset_id, + )?, ) .await; @@ -810,7 +791,7 @@ mod tests { // This range should... 1..=7 => { // ...satisfy the amount - assert_matches!(coins, Ok(coins) if coins.iter().sum::() >= amount) + assert_matches!(coins, Ok(coins) if coins.iter().sum::() as u128 >= amount) // ...and add more for dust management // TODO: Implement the test } @@ -934,7 +915,9 @@ mod tests { mod exclusion { use super::*; + use crate::query::asset_query::Exclude; use fuel_core_types::entities::coins::CoinId; + use std::borrow::Cow; async fn query( db: &ServiceDatabase, @@ -947,7 +930,7 @@ mod tests { let spend_query = SpendQuery::new( owner, &query_per_asset, - Some(excluded_ids), + Cow::Owned(Exclude::new(excluded_ids)), base_asset_id, )?; let coins = random_improve(&db.test_view(), &spend_query).await; @@ -1009,7 +992,7 @@ mod tests { // This range should... 1..=4 => { // ...satisfy the amount - assert_matches!(coins, Ok(coins) if coins.iter().sum::() >= amount) + assert_matches!(coins, Ok(coins) if coins.iter().sum::() as u128 >= amount) // ...and add more for dust management // TODO: Implement the test } @@ -1088,12 +1071,14 @@ mod tests { mod indexed_coins_to_spend { use fuel_core_storage::iter::IntoBoxedIter; use fuel_core_types::{ - entities::coins::coin::Coin, + entities::coins::{ + coin::Coin, + CoinId, + }, fuel_tx::{ AssetId, TxId, UtxoId, - Word, }, }; @@ -1103,9 +1088,9 @@ mod tests { select_coins_until, CoinsQueryError, CoinsToSpendIndexKey, - ExcludedCoinIds, }, graphql_api::ports::CoinsToSpendIndexIter, + query::asset_query::Exclude, }; const BATCH_SIZE: usize = 1; @@ -1150,13 +1135,13 @@ mod tests { .map(|spec| (spec.index_entry, spec.utxo_id)) .unzip(); - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); // When let result = select_coins_until( futures::stream::iter(coins), MAX, - &excluded, + &exclude, |_, _| false, ) .await @@ -1180,14 +1165,13 @@ mod tests { // Exclude coin with amount '2'. let utxo_id = utxo_ids[1]; - let excluded = - ExcludedCoinIds::new(std::iter::once(&utxo_id), std::iter::empty()); + let exclude = Exclude::new(vec![CoinId::Utxo(utxo_id)]); // When let result = select_coins_until( futures::stream::iter(coins), MAX, - &excluded, + &exclude, |_, _| false, ) .await @@ -1202,7 +1186,7 @@ mod tests { async fn select_coins_until_respects_predicate() { // Given const MAX: u16 = u16::MAX; - const TOTAL: u64 = 7; + const TOTAL: u128 = 7; let coins = setup_test_coins([1, 2, 3, 4, 5]); let (coins, _): (Vec<_>, Vec<_>) = coins @@ -1210,16 +1194,16 @@ mod tests { .map(|spec| (spec.index_entry, spec.utxo_id)) .unzip(); - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); - let predicate: fn(&CoinsToSpendIndexKey, u64) -> bool = + let predicate: fn(&CoinsToSpendIndexKey, u128) -> bool = |_, total| total > TOTAL; // When let result = select_coins_until( futures::stream::iter(coins), MAX, - &excluded, + &exclude, predicate, ) .await @@ -1234,7 +1218,7 @@ mod tests { async fn already_selected_big_coins_are_never_reselected_as_dust() { // Given const MAX: u16 = u16::MAX; - const TOTAL: u64 = 101; + const TOTAL: u128 = 101; let test_coins = [100, 100, 4, 3, 2]; let big_coins_iter = setup_test_coins(test_coins) @@ -1253,7 +1237,7 @@ mod tests { dust_coins_iter, }; - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); // When let result = select_coins_to_spend( @@ -1261,7 +1245,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), - &excluded, + &exclude, BATCH_SIZE, ) .await @@ -1302,7 +1286,7 @@ mod tests { async fn selects_double_the_value_of_coins() { // Given const MAX: u16 = u16::MAX; - const TOTAL: u64 = 10; + const TOTAL: u128 = 10; let coins = setup_test_coins([10, 10, 9, 8, 7]); let (coins, _): (Vec<_>, Vec<_>) = coins @@ -1310,7 +1294,7 @@ mod tests { .map(|spec| (spec.index_entry, spec.utxo_id)) .unzip(); - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); let coins_to_spend_iter = CoinsToSpendIndexIter { big_coins_iter: coins.into_iter().into_boxed(), @@ -1323,7 +1307,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), - &excluded, + &exclude, BATCH_SIZE, ) .await @@ -1338,7 +1322,7 @@ mod tests { async fn selection_algorithm_should_bail_on_storage_error() { // Given const MAX: u16 = u16::MAX; - const TOTAL: u64 = 101; + const TOTAL: u128 = 101; let coins = setup_test_coins([10, 9, 8, 7]); let (mut coins, _): (Vec<_>, Vec<_>) = coins @@ -1350,7 +1334,7 @@ mod tests { let first_2: Vec<_> = coins.drain(..2).collect(); let last_2: Vec<_> = std::mem::take(&mut coins); - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); // Inject an error into the middle of coins. let coins: Vec<_> = first_2 @@ -1370,7 +1354,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), - &excluded, + &exclude, BATCH_SIZE, ) .await; @@ -1384,9 +1368,9 @@ mod tests { async fn selection_algorithm_should_bail_on_incorrect_max() { // Given const MAX: u16 = 0; - const TOTAL: u64 = 101; + const TOTAL: u128 = 101; - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); let coins_to_spend_iter = CoinsToSpendIndexIter { big_coins_iter: std::iter::empty().into_boxed(), @@ -1398,7 +1382,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), - &excluded, + &exclude, BATCH_SIZE, ) .await; @@ -1411,9 +1395,9 @@ mod tests { async fn selection_algorithm_should_bail_on_incorrect_total() { // Given const MAX: u16 = 101; - const TOTAL: u64 = 0; + const TOTAL: u128 = 0; - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); let coins_to_spend_iter = CoinsToSpendIndexIter { big_coins_iter: std::iter::empty().into_boxed(), @@ -1425,7 +1409,7 @@ mod tests { TOTAL, MAX, &AssetId::default(), - &excluded, + &exclude, BATCH_SIZE, ) .await; @@ -1438,7 +1422,7 @@ mod tests { async fn selection_algorithm_should_bail_on_not_enough_coins() { // Given const MAX: u16 = 3; - const TOTAL: u64 = 2137; + const TOTAL: u128 = 2137; let coins = setup_test_coins([10, 9, 8, 7]); let (coins, _): (Vec<_>, Vec<_>) = coins @@ -1446,7 +1430,7 @@ mod tests { .map(|spec| (spec.index_entry, spec.utxo_id)) .unzip(); - let excluded = ExcludedCoinIds::new(std::iter::empty(), std::iter::empty()); + let exclude = Exclude::default(); let coins_to_spend_iter = CoinsToSpendIndexIter { big_coins_iter: coins.into_iter().into_boxed(), @@ -1460,12 +1444,12 @@ mod tests { TOTAL, MAX, &asset_id, - &excluded, + &exclude, BATCH_SIZE, ) .await; - const EXPECTED_COLLECTED_AMOUNT: Word = 10 + 9 + 8; // Because MAX == 3 + const EXPECTED_COLLECTED_AMOUNT: u128 = 10 + 9 + 8; // Because MAX == 3 // Then assert!(matches!(result, Err(actual_error) @@ -1476,7 +1460,7 @@ mod tests { #[derive(Clone, Debug)] struct TestCase { db_amount: Vec, - target_amount: u64, + target_amount: u128, max_coins: u16, } @@ -1518,7 +1502,7 @@ mod tests { target: target_amount, max: max_coins, }], - None, + Cow::Owned(Exclude::default()), base_asset_id, )?, ) @@ -1532,7 +1516,7 @@ mod tests { async fn insufficient_coins_returns_error() { let test_case = TestCase { db_amount: vec![0], - target_amount: u64::MAX, + target_amount: u128::MAX, max_coins: u16::MAX, }; let mut rng = StdRng::seed_from_u64(0xF00DF00D); @@ -1576,7 +1560,7 @@ mod tests { #[test_case::test_case( TestCase { db_amount: vec![u64::MAX, u64::MAX], - target_amount: u64::MAX, + target_amount: u64::MAX as u128, max_coins: u16::MAX, } => Ok(1) @@ -1585,7 +1569,7 @@ mod tests { #[test_case::test_case( TestCase { db_amount: vec![2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, u64::MAX - 1], - target_amount: u64::MAX, + target_amount: u64::MAX as u128, max_coins: 2, } => Ok(2) @@ -1608,7 +1592,7 @@ mod tests { let case = TestCase { db_amount: vec![u64::MAX, u64::MAX], - target_amount: u64::MAX, + target_amount: u128::MAX, max_coins: 0, }; diff --git a/crates/fuel-core/src/graphql_api.rs b/crates/fuel-core/src/graphql_api.rs index 959cc358a99..37d4bd60bad 100644 --- a/crates/fuel-core/src/graphql_api.rs +++ b/crates/fuel-core/src/graphql_api.rs @@ -64,6 +64,7 @@ pub struct Costs { pub coins_to_spend: usize, pub get_peers: usize, pub estimate_predicates: usize, + pub assemble_tx: usize, pub dry_run: usize, pub storage_read_replay: usize, pub submit: usize, @@ -98,6 +99,7 @@ pub const DEFAULT_QUERY_COSTS: Costs = Costs { get_peers: 40001, estimate_predicates: 40001, dry_run: 12000, + assemble_tx: 76_000, storage_read_replay: 40001, submit: 40001, submit_and_await: 40001, diff --git a/crates/fuel-core/src/graphql_api/ports.rs b/crates/fuel-core/src/graphql_api/ports.rs index 915371df2cb..8680177179f 100644 --- a/crates/fuel-core/src/graphql_api/ports.rs +++ b/crates/fuel-core/src/graphql_api/ports.rs @@ -278,7 +278,7 @@ pub trait BlockProducerPort: Send + Sync { time: Option, utxo_validation: Option, gas_price: Option, - ) -> anyhow::Result>; + ) -> anyhow::Result>; async fn storage_read_replay( &self, diff --git a/crates/fuel-core/src/query/balance.rs b/crates/fuel-core/src/query/balance.rs index 8902da6dcf8..fe6f43bf797 100644 --- a/crates/fuel-core/src/query/balance.rs +++ b/crates/fuel-core/src/query/balance.rs @@ -47,7 +47,7 @@ impl ReadView { } else { AssetQuery::new( &owner, - &AssetSpendTarget::new(asset_id, u64::MAX, u16::MAX), + &AssetSpendTarget::new(asset_id, u128::MAX, u16::MAX), &base_asset_id, None, self, diff --git a/crates/fuel-core/src/query/balance/asset_query.rs b/crates/fuel-core/src/query/balance/asset_query.rs index 6ea62390fd1..9f4f244305d 100644 --- a/crates/fuel-core/src/query/balance/asset_query.rs +++ b/crates/fuel-core/src/query/balance/asset_query.rs @@ -10,9 +10,11 @@ use fuel_core_types::{ CoinId, CoinType, }, + fuel_tx::UtxoId, fuel_types::{ Address, AssetId, + Nonce, }, }; use futures::{ @@ -26,17 +28,17 @@ use tokio_stream::StreamExt; #[derive(Clone)] pub struct AssetSpendTarget { pub id: AssetId, - pub target: u64, + pub target: u128, pub max: u16, } impl AssetSpendTarget { - pub fn new(id: AssetId, target: u64, max: u16) -> Self { + pub fn new(id: AssetId, target: u128, max: u16) -> Self { Self { id, target, max } } } -#[derive(Default)] +#[derive(Default, Clone)] pub struct Exclude { pub coin_ids: HashSet, } @@ -51,6 +53,18 @@ impl Exclude { instance } + + pub fn exclude(&mut self, coin: CoinId) { + self.coin_ids.insert(coin); + } + + pub fn contains_coin(&self, id: &UtxoId) -> bool { + self.coin_ids.contains(&CoinId::Utxo(*id)) + } + + pub fn contains_message(&self, id: &Nonce) -> bool { + self.coin_ids.contains(&CoinId::Message(*id)) + } } #[derive(Clone)] diff --git a/crates/fuel-core/src/schema/coins.rs b/crates/fuel-core/src/schema/coins.rs index 732929f89f6..65154863d8b 100644 --- a/crates/fuel-core/src/schema/coins.rs +++ b/crates/fuel-core/src/schema/coins.rs @@ -1,11 +1,13 @@ -use std::collections::HashSet; +use std::{ + borrow::Cow, + collections::HashSet, +}; use crate::{ coins_query::{ random_improve, select_coins_to_spend, CoinsQueryError, - ExcludedCoinIds, SpendQuery, }, database::database_description::IndexationKind, @@ -18,13 +20,17 @@ use crate::{ api_service::ChainInfoProvider, database::ReadView, }, - query::asset_query::AssetSpendTarget, + query::asset_query::{ + AssetSpendTarget, + Exclude, + }, schema::{ scalars::{ Address, AssetId, Nonce, UtxoId, + U128, U16, U32, U64, @@ -51,6 +57,7 @@ use fuel_core_types::{ }, fuel_tx::{ self, + ConsensusParameters, }, }; use itertools::Itertools; @@ -143,6 +150,15 @@ pub enum CoinType { MessageCoin(MessageCoin), } +impl CoinType { + pub fn amount(&self) -> u64 { + match self { + CoinType::Coin(coin) => coin.0.amount, + CoinType::MessageCoin(coin) => coin.0.amount, + } + } +} + impl From for CoinType { fn from(value: coins::CoinType) -> Self { match value { @@ -163,19 +179,37 @@ struct CoinFilterInput { #[derive(async_graphql::InputObject)] pub struct SpendQueryElementInput { /// Identifier of the asset to spend. - asset_id: AssetId, + pub asset_id: AssetId, /// Target amount for the query. - amount: U64, + pub amount: U128, /// The maximum number of currencies for selection. - max: Option, + pub max: Option, } #[derive(async_graphql::InputObject)] pub struct ExcludeInput { /// Utxos to exclude from the selection. - utxos: Vec, + pub utxos: Vec, /// Messages to exclude from the selection. - messages: Vec, + pub messages: Vec, +} + +impl From> for Exclude { + fn from(value: Option) -> Self { + let excluded_ids: Option> = value.map(|exclude| { + let utxos = exclude + .utxos + .into_iter() + .map(|utxo| coins::CoinId::Utxo(utxo.into())); + let messages = exclude + .messages + .into_iter() + .map(|message| coins::CoinId::Message(message.into())); + utxos.chain(messages).collect() + }); + + Exclude::new(excluded_ids.unwrap_or_default()) + } } #[derive(Default)] @@ -271,6 +305,8 @@ impl CoinQuery { .into()); } + let exclude: Exclude = excluded_ids.into(); + let mut duplicate_checker = HashSet::with_capacity(query_per_asset.len()); for query in &query_per_asset { let asset_id: fuel_tx::AssetId = query.asset_id.into(); @@ -290,27 +326,38 @@ impl CoinQuery { query_per_asset.truncate(max_input as usize); let read_view = ctx.read_view()?; - let indexation_available = read_view + let result = read_view + .coins_to_spend(owner, &query_per_asset, &exclude, ¶ms, max_input) + .await?; + + Ok(result) + } +} + +impl ReadView { + pub async fn coins_to_spend( + &self, + owner: fuel_tx::Address, + query_per_asset: &[SpendQueryElementInput], + excluded: &Exclude, + params: &ConsensusParameters, + max_input: u16, + ) -> Result>, CoinsQueryError> { + let indexation_available = self .indexation_flags .contains(&IndexationKind::CoinsToSpend); if indexation_available { - coins_to_spend_with_cache( - owner, - query_per_asset, - excluded_ids, - max_input, - read_view.as_ref(), - ) - .await + coins_to_spend_with_cache(owner, query_per_asset, excluded, max_input, self) + .await } else { let base_asset_id = params.base_asset_id(); coins_to_spend_without_cache( owner, query_per_asset, - excluded_ids, + excluded, max_input, base_asset_id, - read_view.as_ref(), + self, ) .await } @@ -319,39 +366,29 @@ impl CoinQuery { async fn coins_to_spend_without_cache( owner: fuel_tx::Address, - query_per_asset: Vec, - excluded_ids: Option, + query_per_asset: &[SpendQueryElementInput], + exclude: &Exclude, max_input: u16, base_asset_id: &fuel_tx::AssetId, db: &ReadView, -) -> async_graphql::Result>> { +) -> Result>, CoinsQueryError> { let query_per_asset = query_per_asset - .into_iter() + .iter() .map(|e| { AssetSpendTarget::new( e.asset_id.0, e.amount.0, - e.max - .and_then(|max| u16::try_from(max.0).ok()) - .unwrap_or(max_input) - .min(max_input), + e.max.map(|max| max.0).unwrap_or(max_input).min(max_input), ) }) .collect_vec(); - let excluded_ids: Option> = excluded_ids.map(|exclude| { - let utxos = exclude - .utxos - .into_iter() - .map(|utxo| coins::CoinId::Utxo(utxo.into())); - let messages = exclude - .messages - .into_iter() - .map(|message| coins::CoinId::Message(message.into())); - utxos.chain(messages).collect() - }); - - let spend_query = - SpendQuery::new(owner, &query_per_asset, excluded_ids, *base_asset_id)?; + + let spend_query = SpendQuery::new( + owner, + &query_per_asset, + Cow::Borrowed(exclude), + *base_asset_id, + )?; let all_coins = random_improve(db, &spend_query) .await? @@ -374,30 +411,19 @@ async fn coins_to_spend_without_cache( async fn coins_to_spend_with_cache( owner: fuel_tx::Address, - query_per_asset: Vec, - excluded_ids: Option, + query_per_asset: &[SpendQueryElementInput], + excluded: &Exclude, max_input: u16, db: &ReadView, -) -> async_graphql::Result>> { +) -> Result>, CoinsQueryError> { let mut all_coins = Vec::with_capacity(query_per_asset.len()); - let excluded = ExcludedCoinIds::new( - excluded_ids - .iter() - .flat_map(|exclude| exclude.utxos.iter()) - .map(|utxo_id| &utxo_id.0), - excluded_ids - .iter() - .flat_map(|exclude| exclude.messages.iter()) - .map(|nonce| &nonce.0), - ); - for asset in query_per_asset { let asset_id = asset.asset_id.0; let total_amount = asset.amount.0; let max = asset .max - .and_then(|max| u16::try_from(max.0).ok()) + .map(|max| max.0) .unwrap_or(max_input) .min(max_input); @@ -406,7 +432,7 @@ async fn coins_to_spend_with_cache( total_amount, max, &asset_id, - &excluded, + excluded, db.batch_size, ) .await?; diff --git a/crates/fuel-core/src/schema/gas_price.rs b/crates/fuel-core/src/schema/gas_price.rs index c321855c94d..73c57784833 100644 --- a/crates/fuel-core/src/schema/gas_price.rs +++ b/crates/fuel-core/src/schema/gas_price.rs @@ -89,24 +89,44 @@ impl EstimateGasPriceQuery { )] block_horizon: Option, ) -> async_graphql::Result { - let query = ctx.read_view()?; + let block_horizon = block_horizon.map(|h| h.0); + + let gas_price = ctx.estimate_gas_price(block_horizon)?; + + Ok(EstimateGasPrice { + gas_price: gas_price.into(), + }) + } +} + +pub trait EstimateGasPriceExt { + fn estimate_gas_price( + &self, + block_horizon: Option, + ) -> async_graphql::Result; +} + +impl EstimateGasPriceExt for Context<'_> { + fn estimate_gas_price( + &self, + block_horizon: Option, + ) -> async_graphql::Result { + let query = self.read_view()?; let latest_block_height: u32 = query.latest_block_height()?.into(); let target_block = block_horizon - .and_then(|h| h.0.checked_add(latest_block_height)) + .and_then(|h| h.checked_add(latest_block_height)) .ok_or(async_graphql::Error::new(format!( "Invalid block horizon. Overflows latest block :{latest_block_height:?}" )))?; - let gas_price_provider = ctx.data_unchecked::(); + let gas_price_provider = self.data_unchecked::(); let gas_price = gas_price_provider .worst_case_gas_price(target_block.into()) .ok_or(async_graphql::Error::new(format!( "Failed to estimate gas price for block, algorithm not yet set: {target_block:?}" )))?; - Ok(EstimateGasPrice { - gas_price: gas_price.into(), - }) + Ok(gas_price) } } diff --git a/crates/fuel-core/src/schema/tx.rs b/crates/fuel-core/src/schema/tx.rs index 023ee7e8a28..817ad6fdc49 100644 --- a/crates/fuel-core/src/schema/tx.rs +++ b/crates/fuel-core/src/schema/tx.rs @@ -1,8 +1,11 @@ use super::scalars::{ + AssetId, + U16, U32, U64, }; use crate::{ + coins_query::CoinsQueryError, fuel_core_graphql_api::{ api_service::{ BlockProducer, @@ -19,10 +22,13 @@ use crate::{ ports::MemoryPool, }, query::{ + asset_query::Exclude, transaction_status_change, TxnStatusChangeState, }, schema::{ + coins::ExcludeInput, + gas_price::EstimateGasPriceExt, scalars::{ Address, HexString, @@ -30,7 +36,16 @@ use crate::{ TransactionId, TxPointer, }, - tx::types::TransactionStatus, + tx::{ + assemble_tx::{ + AssembleArguments, + AssembleTx, + }, + types::{ + AssembleTransactionResult, + TransactionStatus, + }, + }, ReadViewProvider, }, service::adapters::SharedMemoryPool, @@ -54,6 +69,7 @@ use fuel_core_tx_status_manager::TxStatusMessage; use fuel_core_types::{ blockchain::transaction::TransactionExt, fuel_tx::{ + self, Bytes32, Cacheable, Transaction as FuelTx, @@ -76,6 +92,7 @@ use futures::{ use std::{ borrow::Cow, iter, + sync::Arc, }; use types::{ DryRunTransactionExecutionStatus, @@ -83,6 +100,7 @@ use types::{ Transaction, }; +mod assemble_tx; pub mod input; pub mod output; pub mod receipt; @@ -243,6 +261,162 @@ impl TxQuery { .await } + /// Assembles the transaction based on the provided requirements. + /// The return transaction contains: + /// - Input coins to cover `required_balances` + /// - Input coins to cover the fee of the transaction based on the gas price from `block_horizon` + /// - `Change` or `Destroy` outputs for all assets from the inputs + /// - `Variable` outputs in the case they are required during the execution + /// - `Contract` inputs and outputs in the case they are required during the execution + /// - Reserved witness slots for signed coins filled with `64` zeroes + /// - Set script gas limit(unless `script` is empty) + /// - Estimated predicates, if `estimate_predicates == true` + /// + /// Returns an error if: + /// - The number of required balances exceeds the maximum number of inputs allowed. + /// - The fee address index is out of bounds. + /// - The same asset has multiple change policies(either the receiver of + /// the change is different, or one of the policies states about the destruction + /// of the token while the other does not). The `Change` output from the transaction + /// also count as a `ChangePolicy`. + /// - The number of excluded coin IDs exceeds the maximum number of inputs allowed. + /// - Required assets have multiple entries. + /// - If accounts don't have sufficient amounts to cover the transaction requirements in assets. + /// - If a constructed transaction breaks the rules defined by consensus parameters. + #[graphql(complexity = "query_costs().assemble_tx")] + #[allow(clippy::too_many_arguments)] + async fn assemble_tx( + &self, + ctx: &Context<'_>, + #[graphql( + desc = "The original transaction that contains application level logic only" + )] + tx: HexString, + #[graphql( + desc = "Number of blocks into the future to estimate the gas price for" + )] + block_horizon: U32, + #[graphql( + desc = "The list of required balances for the transaction to include as inputs. \ + The list should be created based on the application-required assets. \ + The base asset requirement should not require assets to cover the \ + transaction fee, which will be calculated and added automatically \ + at the end of the assembly process." + )] + required_balances: Vec, + #[graphql(desc = "The index from the `required_balances` list \ + that points to the address who pays fee for the transaction. \ + If you only want to cover the fee of transaction, you can set the required balance \ + to 0, set base asset and point to this required address.")] + fee_address_index: U16, + #[graphql( + desc = "The list of resources to exclude from the selection for the inputs" + )] + exclude_input: Option, + #[graphql( + desc = "Perform the estimation of the predicates before cover fee of the transaction" + )] + estimate_predicates: Option, + #[graphql( + desc = "During the phase of the fee calculation, adds `reserve_gas` to the \ + total gas used by the transaction and fetch assets to cover the fee." + )] + reserve_gas: Option, + ) -> async_graphql::Result { + let consensus_parameters = ctx + .data_unchecked::() + .current_consensus_params(); + + let max_input = consensus_parameters.tx_params().max_inputs(); + + if required_balances.len() > max_input as usize { + return Err(CoinsQueryError::TooManyCoinsSelected { + required: required_balances.len(), + max: max_input, + } + .into()); + } + + let fee_index: u16 = fee_address_index.into(); + let estimate_predicates: bool = estimate_predicates.unwrap_or(false); + let reserve_gas: u64 = reserve_gas.map(Into::into).unwrap_or(0); + + let excluded_id_count = exclude_input.as_ref().map_or(0, |exclude| { + exclude.utxos.len().saturating_add(exclude.messages.len()) + }); + if excluded_id_count > max_input as usize { + return Err(CoinsQueryError::TooManyExcludedId { + provided: excluded_id_count, + allowed: max_input, + } + .into()); + } + + let required_balances: Vec = + required_balances.into_iter().map(Into::into).collect(); + let exclude: Exclude = exclude_input.into(); + + let gas_price = ctx.estimate_gas_price(Some(block_horizon.into()))?; + + let tx = FuelTx::from_bytes(&tx.0)?; + + let read_view = Arc::new(ctx.read_view()?.into_owned()); + let block_producer = ctx.data_unchecked::(); + let shared_memory_pool = ctx.data_unchecked::(); + + let arguments = AssembleArguments { + fee_index, + required_balances, + exclude, + estimate_predicates, + reserve_gas, + consensus_parameters, + gas_price, + block_producer, + read_view, + shared_memory_pool, + }; + + let assembled_tx: fuel_tx::Transaction = match tx { + fuel_tx::Transaction::Script(tx) => { + AssembleTx::new(tx, arguments)?.assemble().await?.into() + } + fuel_tx::Transaction::Create(tx) => { + AssembleTx::new(tx, arguments)?.assemble().await?.into() + } + fuel_tx::Transaction::Mint(_) => { + return Err(anyhow::anyhow!("Mint transaction is not supported").into()); + } + fuel_tx::Transaction::Upgrade(tx) => { + AssembleTx::new(tx, arguments)?.assemble().await?.into() + } + fuel_tx::Transaction::Upload(tx) => { + AssembleTx::new(tx, arguments)?.assemble().await?.into() + } + fuel_tx::Transaction::Blob(tx) => { + AssembleTx::new(tx, arguments)?.assemble().await?.into() + } + }; + + let (assembled_tx, status) = block_producer + .dry_run_txs(vec![assembled_tx], None, None, Some(false), Some(gas_price)) + .await? + .into_iter() + .next() + .ok_or(anyhow::anyhow!( + "Failed to do the final `dry_run` of the assembled transaction" + ))?; + + let result = AssembleTransactionResult { + tx_id: status.id, + tx: assembled_tx, + status: status.result, + gas_price, + }; + + Ok(result) + } + /// Estimate the predicate gas for the provided transaction #[graphql(complexity = "query_costs().estimate_predicates + child_complexity")] async fn estimate_predicates( @@ -337,7 +511,7 @@ impl TxQuery { .await?; let tx_statuses = tx_statuses .into_iter() - .map(DryRunTransactionExecutionStatus) + .map(|(_, status)| DryRunTransactionExecutionStatus(status)) .collect(); Ok(tx_statuses) @@ -544,6 +718,121 @@ impl<'a> TxnStatusChangeState for StatusChangeState<'a> { } } +pub mod schema_types { + use super::*; + + #[derive(async_graphql::Enum, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] + pub enum Destroy { + Destroy, + } + + #[derive(async_graphql::OneofObject)] + pub enum ChangePolicy { + /// Adds `Output::Change` to the transaction if it is not already present. + /// Sending remaining assets to the provided address. + Change(Address), + /// Destroys the remaining assets by the transaction for provided address. + Destroy(Destroy), + } + + #[derive(async_graphql::OneofObject)] + pub enum Account { + Address(Address), + Predicate(Predicate), + } + + #[derive(async_graphql::InputObject)] + pub struct Predicate { + // The address of the predicate can be different from the actual bytecode. + // This feature is used by wallets during estimation of the predicate that requires + // signature verification. They provide a mocked version of the predicate that + // returns `true` even if the signature doesn't match. + pub predicate_address: Address, + pub predicate: HexString, + pub predicate_data: HexString, + } + + #[derive(async_graphql::InputObject)] + pub struct RequiredBalance { + pub asset_id: AssetId, + pub amount: U64, + pub account: Account, + pub change_policy: ChangePolicy, + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ChangePolicy { + /// Adds `Output::Change` to the transaction if it is not already present. + /// Sending remaining assets to the provided address. + Change(fuel_tx::Address), + /// Destroys the remaining assets by the transaction for provided address. + Destroy, +} + +#[derive(Clone)] +pub enum Account { + Address(fuel_tx::Address), + Predicate(Predicate), +} + +impl Account { + pub fn owner(&self) -> fuel_tx::Address { + match self { + Account::Address(address) => *address, + Account::Predicate(predicate) => predicate.predicate_address, + } + } +} + +#[derive(Clone)] +pub struct Predicate { + pub predicate_address: fuel_tx::Address, + pub predicate: Vec, + pub predicate_data: Vec, +} + +struct RequiredBalance { + asset_id: fuel_tx::AssetId, + amount: fuel_tx::Word, + account: Account, + change_policy: ChangePolicy, +} + +impl From for RequiredBalance { + fn from(required_balance: schema_types::RequiredBalance) -> Self { + let asset_id: fuel_tx::AssetId = required_balance.asset_id.into(); + let amount: fuel_tx::Word = required_balance.amount.into(); + let account = match required_balance.account { + schema_types::Account::Address(address) => Account::Address(address.into()), + schema_types::Account::Predicate(predicate) => { + let predicate_address = predicate.predicate_address.into(); + let predicate_data = predicate.predicate_data.into(); + let predicate = predicate.predicate.into(); + Account::Predicate(Predicate { + predicate_address, + predicate, + predicate_data, + }) + } + }; + + let change_policy = match required_balance.change_policy { + schema_types::ChangePolicy::Change(address) => { + ChangePolicy::Change(address.into()) + } + schema_types::ChangePolicy::Destroy(_) => ChangePolicy::Destroy, + }; + + Self { + asset_id, + amount, + account, + change_policy, + } + } +} + pub trait ContextExt { fn try_find_tx( &self, diff --git a/crates/fuel-core/src/schema/tx/assemble_tx.rs b/crates/fuel-core/src/schema/tx/assemble_tx.rs new file mode 100644 index 00000000000..eeff68510a1 --- /dev/null +++ b/crates/fuel-core/src/schema/tx/assemble_tx.rs @@ -0,0 +1,1024 @@ +use crate::{ + fuel_core_graphql_api::{ + api_service::BlockProducer, + database::ReadView, + ports::MemoryPool, + }, + query::asset_query::Exclude, + schema::{ + coins::{ + CoinType, + SpendQueryElementInput, + }, + tx::{ + Account, + ChangePolicy, + RequiredBalance, + }, + }, + service::adapters::SharedMemoryPool, +}; +use fuel_core_types::{ + entities::coins::CoinId, + fuel_asm::{ + op, + PanicReason, + Word, + }, + fuel_crypto::Signature, + fuel_tx::{ + field::{ + Inputs, + MaxFeeLimit, + Outputs, + Policies, + Script as ScriptField, + ScriptGasLimit, + Tip, + WitnessLimit, + }, + input::{ + coin::CoinSigned, + message::{ + MessageCoinSigned, + MessageDataSigned, + }, + }, + policies::PolicyType, + Address, + AssetId, + Cacheable, + Chargeable, + ConsensusParameters, + Input, + Output, + Receipt, + Script, + Transaction, + }, + fuel_types::canonical::Serialize, + fuel_vm::{ + checked_transaction::CheckPredicateParams, + interpreter::ExecutableTransaction, + }, + services::executor::{ + TransactionExecutionResult, + TransactionExecutionStatus, + }, +}; +use std::{ + collections::{ + hash_map::Entry, + HashMap, + HashSet, + }, + sync::Arc, +}; + +pub struct AssembleArguments<'a> { + pub fee_index: u16, + pub required_balances: Vec, + pub exclude: Exclude, + pub estimate_predicates: bool, + pub reserve_gas: u64, + pub consensus_parameters: Arc, + pub gas_price: u64, + pub read_view: Arc, + pub block_producer: &'a BlockProducer, + pub shared_memory_pool: &'a SharedMemoryPool, +} + +impl<'a> AssembleArguments<'a> { + async fn coins( + &self, + owner: Address, + asset_id: AssetId, + amount: u64, + remaining_input_slots: u16, + ) -> anyhow::Result> { + if amount == 0 { + return Ok(Vec::new()); + } + + let query_per_asset = SpendQueryElementInput { + asset_id: asset_id.into(), + amount: (amount as u128).into(), + max: None, + }; + + let result = self + .read_view + .coins_to_spend( + owner, + &[query_per_asset], + &self.exclude, + &self.consensus_parameters, + remaining_input_slots, + ) + .await? + .into_iter() + .next(); + + let result = result.ok_or(anyhow::anyhow!("No result for the coins to spend"))?; + + Ok(result) + } + + /// Dry run the transaction to estimate the script gas limit. + /// It uses zero gas price to not depend on the coins to cover the fee. + async fn dry_run( + &self, + script: Script, + ) -> anyhow::Result<(Transaction, TransactionExecutionStatus)> { + self.block_producer + .dry_run_txs(vec![script.into()], None, None, Some(false), Some(0)) + .await? + .into_iter() + .next() + .ok_or(anyhow::anyhow!("No result for the dry run")) + } +} + +pub struct AssembleTx<'a, Tx> { + tx: Tx, + arguments: AssembleArguments<'a>, + signature_witness_indexes: HashMap, + change_output_policies: HashMap, + set_change_outputs: HashSet, + // The amount of the base asset that is reserved for the application logic + base_asset_reserved: u64, + has_predicates: bool, + index_of_first_fake_variable_output: Option, + original_max_fee: u64, + original_witness_limit: u64, + fee_payer_account: Account, + estimated_predicates_count: usize, +} + +impl<'a, Tx> AssembleTx<'a, Tx> +where + Tx: ExecutableTransaction + Cacheable + Send + 'static, +{ + pub fn new(tx: Tx, mut arguments: AssembleArguments<'a>) -> anyhow::Result { + let max_inputs = arguments.consensus_parameters.tx_params().max_inputs(); + let max_outputs = arguments.consensus_parameters.tx_params().max_outputs(); + + if tx.inputs().len() > max_inputs as usize { + return Err(anyhow::anyhow!( + "The transaction has more inputs than allowed by the consensus" + )); + } + + if tx.outputs().len() > max_outputs as usize { + return Err(anyhow::anyhow!( + "The transaction has more outputs than allowed by the consensus" + )); + } + + if arguments.fee_index as usize >= arguments.required_balances.len() { + return Err(anyhow::anyhow!("The fee address index is out of bounds")); + } + + if has_duplicates(&arguments.required_balances, |balance| { + (balance.asset_id, balance.account.owner()) + }) { + return Err(anyhow::anyhow!( + "required balances contain duplicate (asset, account) pair" + )); + } + + let base_asset_id = *arguments.consensus_parameters.base_asset_id(); + let mut signature_witness_indexes = HashMap::::new(); + + // Exclude inputs that already are used by the transaction + let mut has_predicates = false; + let inputs = tx.inputs(); + let mut unique_used_asset = HashSet::new(); + for input in inputs { + if let Some(utxo_id) = input.utxo_id() { + arguments.exclude.exclude(CoinId::Utxo(*utxo_id)); + } + + if let Some(nonce) = input.nonce() { + arguments.exclude.exclude(CoinId::Message(*nonce)); + } + + if let Some(asset_id) = input.asset_id(&base_asset_id) { + unique_used_asset.insert(*asset_id); + } + + match input { + Input::CoinSigned(CoinSigned { + owner, + witness_index, + .. + }) + | Input::MessageCoinSigned(MessageCoinSigned { + recipient: owner, + witness_index, + .. + }) + | Input::MessageDataSigned(MessageDataSigned { + recipient: owner, + witness_index, + .. + }) => { + signature_witness_indexes.insert(*owner, *witness_index); + } + Input::Contract(_) => { + // Do nothing + } + + Input::CoinPredicate(_) + | Input::MessageCoinPredicate(_) + | Input::MessageDataPredicate(_) => { + has_predicates = true; + } + } + } + + let mut change_output_policies = HashMap::::new(); + let mut set_change_outputs = HashSet::::new(); + + for output in tx.outputs() { + if let Output::Change { to, asset_id, .. } = output { + change_output_policies.insert(*asset_id, ChangePolicy::Change(*to)); + set_change_outputs.insert(*asset_id); + } + } + + let mut base_asset_reserved: Option = None; + + let fee_payer_account = arguments + .required_balances + .get(arguments.fee_index as usize) + .ok_or_else(|| anyhow::anyhow!("fee index out of bounds"))? + .account + .clone(); + + let mut requested_asset = HashSet::new(); + for required_balance in &arguments.required_balances { + let asset_id = required_balance.asset_id; + requested_asset.insert(asset_id); + + if asset_id == base_asset_id + && fee_payer_account.owner() == required_balance.account.owner() + { + base_asset_reserved = Some(required_balance.amount); + } + } + + for input_asset_id in &unique_used_asset { + // If the user didn't request the asset, we add it to the required balances + // with minimal amount `0` and `ChangePolicy::Change` policy. + if !requested_asset.contains(input_asset_id) { + let recipient = fee_payer_account.owner(); + + arguments.required_balances.push(RequiredBalance { + account: fee_payer_account.clone(), + asset_id: *input_asset_id, + amount: 0, + change_policy: ChangePolicy::Change(recipient), + }); + } + } + + if base_asset_reserved.is_none() { + base_asset_reserved = Some(0); + } + + for required_balance in &arguments.required_balances { + let asset_id = required_balance.asset_id; + + let entry = change_output_policies.entry(asset_id); + + match entry { + Entry::Occupied(old) => { + if old.get() != &required_balance.change_policy { + return Err(anyhow::anyhow!( + "The asset {} has multiple change policies", + asset_id + )); + } + } + Entry::Vacant(vacant) => { + vacant.insert(required_balance.change_policy); + } + } + } + + // Removed required balances with zero amount and if they are not used in inputs + let required_balances = core::mem::take(&mut arguments.required_balances); + arguments.required_balances = required_balances + .into_iter() + .filter_map(|r| { + if r.amount != 0 || unique_used_asset.contains(&r.asset_id) { + Some(r) + } else { + None + } + }) + .collect(); + + let original_max_fee = tx.max_fee_limit(); + let original_witness_limit = tx.witness_limit(); + + Ok(Self { + tx, + arguments, + signature_witness_indexes, + change_output_policies, + set_change_outputs, + base_asset_reserved: base_asset_reserved.expect("Set above; qed"), + has_predicates, + index_of_first_fake_variable_output: None, + original_max_fee, + original_witness_limit, + fee_payer_account, + estimated_predicates_count: 0, + }) + } + + pub fn add_missing_witnesses(&mut self) { + let witnesses = self.tx.witnesses_mut(); + for (_, witness_index) in self.signature_witness_indexes.iter() { + let witness_index = *witness_index as usize; + if witness_index >= witnesses.len() { + witnesses.resize(witness_index.saturating_add(1), Vec::new().into()); + } + + let witness = witnesses[witness_index].as_vec_mut(); + if witness.len() < Signature::LEN { + witness.resize(Signature::LEN, 0); + } + } + } + + pub async fn assemble(mut self) -> anyhow::Result { + self.add_missing_witnesses(); + self.add_inputs_and_witnesses_and_changes().await?; + + if let Some(script) = self.tx.as_script_mut() { + *script.script_gas_limit_mut() = 0; + } + + self = self.cover_fee().await?; + + self.adjust_witness_limit(); + + self.fill_with_variable_outputs()?; + + // The `cover_fee` already can estimate predicates inside, + // we don't need to duplicate the work, if it was already done. + if self.estimated_predicates_count == 0 { + self = self.estimate_predicates().await?; + } + + self.estimate_script_if_possible().await?; + + self.remove_unused_variable_outputs(); + + self = self.cover_fee().await?; + + self.adjust_witness_limit(); + + Ok(self.tx) + } + + fn remaining_input_slots(&self) -> anyhow::Result { + let max_input = self.arguments.consensus_parameters.tx_params().max_inputs(); + let used_inputs = u16::try_from(self.tx.inputs().len()).unwrap_or(u16::MAX); + + if used_inputs > max_input { + return Err(anyhow::anyhow!( + "Filling required balances occupies a number \ + of inputs more than can fit into the transaction" + )); + } + + Ok(max_input.saturating_sub(used_inputs)) + } + + async fn add_inputs_and_witnesses_and_changes(&mut self) -> anyhow::Result<()> { + let required_balance = core::mem::take(&mut self.arguments.required_balances); + + for required_balance in required_balance { + let remaining_input_slots = self.remaining_input_slots()?; + + let asset_id = required_balance.asset_id; + let amount = required_balance.amount; + let owner = required_balance.account.owner(); + + self.satisfy_change_policy(asset_id)?; + + let selected_coins = self + .arguments + .coins(owner, asset_id, amount, remaining_input_slots) + .await?; + + for coin in selected_coins + .into_iter() + .take(remaining_input_slots as usize) + { + self.add_input_and_witness_and_change(&required_balance.account, coin)?; + } + } + + Ok(()) + } + + fn reserve_witness_index(&mut self, account: &Address) -> u16 { + self.signature_witness_indexes + .get(account) + .cloned() + .unwrap_or_else(|| { + let vacant_index = + u16::try_from(self.tx.witnesses().len()).unwrap_or(u16::MAX); + + self.tx.witnesses_mut().push(vec![0; Signature::LEN].into()); + self.signature_witness_indexes + .insert(*account, vacant_index); + vacant_index + }) + } + + fn add_input_and_witness_and_change( + &mut self, + account: &Account, + coin: CoinType, + ) -> anyhow::Result<()> { + let base_asset_id = *self.arguments.consensus_parameters.base_asset_id(); + + let input = match account { + Account::Address(account) => { + let signature_index = self.reserve_witness_index(account); + + match coin { + CoinType::Coin(coin) => Input::coin_signed( + coin.0.utxo_id, + coin.0.owner, + coin.0.amount, + coin.0.asset_id, + coin.0.tx_pointer, + signature_index, + ), + CoinType::MessageCoin(message) => Input::message_coin_signed( + message.0.sender, + message.0.recipient, + message.0.amount, + message.0.nonce, + signature_index, + ), + } + } + Account::Predicate(predicate) => { + self.has_predicates = true; + let predicate_gas_used = 0; + match coin { + CoinType::Coin(coin) => Input::coin_predicate( + coin.0.utxo_id, + predicate.predicate_address, + coin.0.amount, + coin.0.asset_id, + coin.0.tx_pointer, + predicate_gas_used, + predicate.predicate.clone(), + predicate.predicate_data.clone(), + ), + CoinType::MessageCoin(message) => Input::message_coin_predicate( + message.0.sender, + message.0.recipient, + message.0.amount, + message.0.nonce, + predicate_gas_used, + predicate.predicate.clone(), + predicate.predicate_data.clone(), + ), + } + } + }; + + if let Some(utxo_id) = input.utxo_id() { + self.arguments.exclude.exclude(CoinId::Utxo(*utxo_id)); + } + + if let Some(nonce) = input.nonce() { + self.arguments.exclude.exclude(CoinId::Message(*nonce)); + } + + if let Some(asset_id) = input.asset_id(&base_asset_id) { + self.satisfy_change_policy(*asset_id)?; + } + + self.tx.inputs_mut().push(input); + + let max_inputs = self.arguments.consensus_parameters.tx_params().max_inputs(); + + if self.tx.inputs().len() > max_inputs as usize { + return Err(anyhow::anyhow!( + "Unable to add more inputs \ + because reached the maximum allowed inputs limit" + )); + } + + Ok(()) + } + + fn satisfy_change_policy(&mut self, asset_id: AssetId) -> anyhow::Result<()> { + if self.set_change_outputs.insert(asset_id) { + let change_policy = + if let Some(policy) = self.change_output_policies.get(&asset_id) { + *policy + } else { + ChangePolicy::Change(self.fee_payer_account.owner()) + }; + + match change_policy { + ChangePolicy::Change(change_receiver) => { + self.tx.outputs_mut().push(Output::change( + change_receiver, + 0, + asset_id, + )); + + let max_outputs = self + .arguments + .consensus_parameters + .tx_params() + .max_outputs(); + + if self.tx.outputs().len() > max_outputs as usize { + return Err(anyhow::anyhow!( + "Unable to add more `Change` outputs \ + because reached the maximum allowed outputs limit" + )); + } + } + ChangePolicy::Destroy => { + // Do nothing for now, since `fuel-tx` crate doesn't have + // `Destroy` output yet. + // https://github.com/FuelLabs/fuel-specs/issues/621 + } + } + } + + Ok(()) + } + + fn is_runnable_script(&self) -> bool { + if let Some(script) = self.tx.as_script() { + if !script.script().is_empty() { + return true + } + } + false + } + + // TODO: Optimize this function later to use information from the VM about missing + // `Variable` outputs. + fn fill_with_variable_outputs(&mut self) -> anyhow::Result<()> { + if !self.is_runnable_script() { + return Ok(()) + } + + let max_outputs = self + .arguments + .consensus_parameters + .tx_params() + .max_outputs(); + + let outputs = u16::try_from(self.tx.outputs().len()).unwrap_or(u16::MAX); + + self.tx.outputs_mut().resize( + max_outputs as usize, + Output::variable(Default::default(), Default::default(), Default::default()), + ); + + self.index_of_first_fake_variable_output = Some(outputs); + + Ok(()) + } + + fn remove_unused_variable_outputs(&mut self) { + if !self.is_runnable_script() { + return + } + + if self.index_of_first_fake_variable_output.is_none() { + return + } + + let index_of_first_fake_variable_output = self + .index_of_first_fake_variable_output + .take() + .expect("Checked above; qed"); + + while let Some(output) = self.tx.outputs().last() { + if self.tx.outputs().len() <= index_of_first_fake_variable_output as usize { + break + } + + if let Output::Variable { amount, .. } = output { + if *amount == 0 { + self.tx.outputs_mut().pop(); + } else { + break; + } + } else { + break; + } + } + } + + fn adjust_witness_limit(&mut self) { + // If the user sets the `WitnessLimit` policy, we are only allowed to increase + // it in the case if the transaction got more witnesses when we inserted new inputs. + let mut witness_size = self.tx.witnesses().size_dynamic() as u64; + witness_size = witness_size.max(self.original_witness_limit); + self.tx.set_witness_limit(witness_size); + } + + async fn estimate_predicates(mut self) -> anyhow::Result { + if !self.arguments.estimate_predicates { + return Ok(self) + } + + if !self.has_predicates { + return Ok(self) + } + + if self.estimated_predicates_count > 10 { + return Err(anyhow::anyhow!( + "The transaction estimation requires running of predicate more than 10 times" + )); + } + + let memory = self.arguments.shared_memory_pool.get_memory().await; + let chain_id = self.arguments.consensus_parameters.chain_id(); + self.tx + .precompute(&chain_id) + .map_err(|err| anyhow::anyhow!("{:?}", err))?; + + let parameters = + CheckPredicateParams::from(self.arguments.consensus_parameters.as_ref()); + let read_view = self.arguments.read_view.clone(); + + let mut tx_to_estimate = self.tx; + let estimated_tx = tokio_rayon::spawn_fifo(move || { + let result = tx_to_estimate.estimate_predicates( + ¶meters, + memory, + read_view.as_ref(), + ); + result.map(|_| tx_to_estimate) + }) + .await + .map_err(|err| anyhow::anyhow!("{:?}", err))?; + + self.tx = estimated_tx; + + self.estimated_predicates_count = + self.estimated_predicates_count.saturating_add(1); + + Ok(self) + } + + async fn estimate_script_if_possible(&mut self) -> anyhow::Result<()> { + if !self.is_runnable_script() { + return Ok(()) + } + + let Some(script_ref) = self.tx.as_script_mut() else { + unreachable!("The transaction is a script, checked above; qed"); + }; + + // Trick to avoid cloning `Script` + let dummy_script = Transaction::script( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + ); + let mut script = core::mem::replace(script_ref, dummy_script); + + let dry_run_gas_price = 0; + set_max_fee( + &mut script, + &self.arguments.consensus_parameters, + self.arguments.reserve_gas, + dry_run_gas_price, + self.original_max_fee, + ); + + let max_tx_gas = self + .arguments + .consensus_parameters + .tx_params() + .max_gas_per_tx(); + + let has_spendable_input = script.inputs().iter().any(|input| match input { + Input::CoinSigned(_) + | Input::CoinPredicate(_) + | Input::MessageCoinSigned(_) + | Input::MessageCoinPredicate(_) => true, + Input::MessageDataSigned(_) + | Input::MessageDataPredicate(_) + | Input::Contract(_) => false, + }); + + // If `max_fee` policy is not set, set it because it affects the maximum gas + // used by transaction. + if script.policies().get(PolicyType::MaxFee).is_none() { + script.set_max_fee_limit(0); + } + + let (mut script, status) = self + .populate_missing_contract_inputs(script, has_spendable_input, max_tx_gas) + .await?; + + let mut total_gas_used = 0u64; + + for receipt in status.result.receipts() { + if let Receipt::ScriptResult { gas_used, .. } = receipt { + total_gas_used = total_gas_used.saturating_add(*gas_used); + } + } + + *script.script_gas_limit_mut() = total_gas_used; + + let Some(script_ref) = self.tx.as_script_mut() else { + unreachable!("The transaction is a script, checked above; qed"); + }; + *script_ref = script; + + Ok(()) + } + + async fn populate_missing_contract_inputs( + &mut self, + mut script: Script, + has_spendable_input: bool, + max_tx_gas: u64, + ) -> Result<(Script, TransactionExecutionStatus), anyhow::Error> { + let mut status: TransactionExecutionStatus; + + let gas_costs = self.arguments.consensus_parameters.gas_costs(); + let fee_params = self.arguments.consensus_parameters.fee_params(); + + loop { + if !has_spendable_input { + script.inputs_mut().push(fake_input()); + } + + // We want to calculate `max_gas` for the script, but without script limit + *script.script_gas_limit_mut() = 0; + + let gas_used_by_tx = script.max_gas(gas_costs, fee_params); + + let max_gas_limit = max_tx_gas.saturating_sub(gas_used_by_tx); + + *script.script_gas_limit_mut() = max_gas_limit; + + let (updated_tx, new_status) = self.arguments.dry_run(script).await?; + let Transaction::Script(updated_script) = updated_tx else { + return Err(anyhow::anyhow!( + "During script gas limit estimation, \ + dry-run returned incorrect transaction" + )); + }; + + script = updated_script; + status = new_status; + + if !has_spendable_input { + script.inputs_mut().pop(); + } + + let mut contracts_not_in_inputs = Vec::new(); + + match &status.result { + TransactionExecutionResult::Success { .. } => break, + TransactionExecutionResult::Failed { receipts, .. } => { + for receipt in receipts.iter().rev() { + if let Receipt::Panic { + reason, + contract_id, + .. + } = receipt + { + if reason.reason() == &PanicReason::ContractNotInInputs { + let contract_id = contract_id.ok_or_else(|| { + anyhow::anyhow!("missing contract id") + })?; + contracts_not_in_inputs.push(contract_id); + } + } + } + } + } + + if contracts_not_in_inputs.is_empty() { + break; + } + + for contract_id in contracts_not_in_inputs { + let inptus = script.inputs_mut(); + + let contract_idx = u16::try_from(inptus.len()).unwrap_or(u16::MAX); + + inptus.push(Input::contract( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + contract_id, + )); + + let slot = self + .index_of_first_fake_variable_output + .and_then(|index| script.outputs_mut().get_mut(index as usize)); + + if let Some(slot) = slot { + *slot = Output::contract( + contract_idx, + Default::default(), + Default::default(), + ); + self.index_of_first_fake_variable_output = self + .index_of_first_fake_variable_output + .and_then(|index| index.checked_add(1)); + } else { + return Err(anyhow::anyhow!( + "Run out of slots for the contract outputs" + )); + } + } + } + + Ok((script, status)) + } + + async fn cover_fee(mut self) -> anyhow::Result { + let base_asset_id = *self.arguments.consensus_parameters.base_asset_id(); + let gas_costs = self.arguments.consensus_parameters.gas_costs().clone(); + let fee_params = *self.arguments.consensus_parameters.fee_params(); + let max_gas_per_tx = self + .arguments + .consensus_parameters + .tx_params() + .max_gas_per_tx(); + let gas_price_factor = fee_params.gas_price_factor(); + let fee_payer_account = self.fee_payer_account.clone(); + + let mut total_base_asset = 0u64; + + for input in self.tx.inputs() { + let Some(amount) = input.amount() else { + continue; + }; + let Some(asset_id) = input.asset_id(&base_asset_id) else { + continue; + }; + let Some(owner) = input.input_owner() else { + continue; + }; + + if asset_id == &base_asset_id && &fee_payer_account.owner() == owner { + total_base_asset = + total_base_asset.checked_add(amount).ok_or(anyhow::anyhow!( + "The total base asset amount used by the transaction is too big" + ))?; + } + } + + loop { + let max_gas = self.tx.max_gas(&gas_costs, &fee_params); + let max_gas_with_reserve = max_gas.saturating_add(self.arguments.reserve_gas); + + let final_gas = max_gas_with_reserve.min(max_gas_per_tx); + let final_fee = + gas_to_fee(final_gas, self.arguments.gas_price, gas_price_factor); + let final_fee = u64::try_from(final_fee) + .map_err(|_| { + anyhow::anyhow!("The final fee is too big to fit into `u64`") + })? + .saturating_add(self.tx.tip()); + + let need_to_cover = final_fee.saturating_add(self.base_asset_reserved); + + if need_to_cover <= total_base_asset { + break; + } + + let remaining_input_slots = self.remaining_input_slots()?; + + let how_much_to_add = need_to_cover.saturating_sub(total_base_asset); + let coins = self + .arguments + .coins( + fee_payer_account.owner(), + base_asset_id, + how_much_to_add, + remaining_input_slots, + ) + .await?; + + if coins.is_empty() { + return Err(anyhow::anyhow!( + "Unable to find any coins to pay for the fee" + )); + } + + for coin in coins.into_iter().take(remaining_input_slots as usize) { + total_base_asset = total_base_asset.checked_add(coin.amount()).ok_or( + anyhow::anyhow!( + "The total base asset amount \ + became too big when tried to cover fee" + ), + )?; + self.add_input_and_witness_and_change(&fee_payer_account, coin)?; + } + + // In the case when predicates iterates over the inputs, + // it increases its used gas. So we need to re-estimate predicates. + self = self.estimate_predicates().await?; + } + + set_max_fee( + &mut self.tx, + &self.arguments.consensus_parameters, + self.arguments.reserve_gas, + self.arguments.gas_price, + self.original_max_fee, + ); + + Ok(self) + } +} + +fn has_duplicates(items: &[T], extractor: F) -> bool +where + F: Fn(&T) -> K, + K: std::hash::Hash + std::cmp::Eq, +{ + let mut duplicates = HashSet::with_capacity(items.len()); + for item in items { + let key = extractor(item); + if !duplicates.insert(key) { + return true + } + } + + false +} + +fn set_max_fee( + tx: &mut Tx, + consensus_parameters: &ConsensusParameters, + reserve_gas: u64, + gas_price: u64, + original_max_fee: u64, +) where + Tx: ExecutableTransaction, +{ + let gas_costs = consensus_parameters.gas_costs(); + let fee_params = consensus_parameters.fee_params(); + let max_gas_per_tx = consensus_parameters.tx_params().max_gas_per_tx(); + let gas_price_factor = fee_params.gas_price_factor(); + + let max_gas = tx.max_gas(gas_costs, fee_params); + let max_gas_with_reserve = max_gas.saturating_add(reserve_gas); + + let final_gas = max_gas_with_reserve.min(max_gas_per_tx); + let final_fee = gas_to_fee(final_gas, gas_price, gas_price_factor); + let mut final_fee = u64::try_from(final_fee).unwrap_or(u64::MAX); + final_fee = final_fee.saturating_add(tx.tip()); + + // If the user sets the `MaxFee` policy, we are only allowed to increase + // it in the case if the transaction requires more fee to cover it. + final_fee = final_fee.max(original_max_fee); + tx.set_max_fee_limit(final_fee); +} + +fn gas_to_fee(gas: Word, gas_price: Word, factor: Word) -> u128 { + let total_price = (gas as u128) + .checked_mul(gas_price as u128) + .expect("Impossible to overflow because multiplication of two `u64` <= `u128`"); + total_price.div_ceil(factor as u128) +} + +fn fake_input() -> Input { + Input::coin_predicate( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + Default::default(), + [op::ret(1)].into_iter().collect(), + Default::default(), + ) +} diff --git a/crates/fuel-core/src/schema/tx/input.rs b/crates/fuel-core/src/schema/tx/input.rs index 5245a346212..1f274403f92 100644 --- a/crates/fuel-core/src/schema/tx/input.rs +++ b/crates/fuel-core/src/schema/tx/input.rs @@ -57,8 +57,8 @@ impl InputCoin { self.tx_pointer } - async fn witness_index(&self) -> u16 { - self.witness_index + async fn witness_index(&self) -> U16 { + self.witness_index.into() } async fn predicate_gas_used(&self) -> U64 { diff --git a/crates/fuel-core/src/schema/tx/types.rs b/crates/fuel-core/src/schema/tx/types.rs index b1c1d189a5d..1a6e5e203c4 100644 --- a/crates/fuel-core/src/schema/tx/types.rs +++ b/crates/fuel-core/src/schema/tx/types.rs @@ -1152,3 +1152,25 @@ impl From for Policies { Policies(value) } } + +pub struct AssembleTransactionResult { + pub tx_id: fuel_tx::TxId, + pub tx: fuel_tx::Transaction, + pub status: TransactionExecutionResult, + pub gas_price: u64, +} + +#[Object] +impl AssembleTransactionResult { + async fn transaction(&self) -> Transaction { + Transaction::from_tx(self.tx_id, self.tx.clone()) + } + + async fn status(&self) -> DryRunTransactionStatus { + DryRunTransactionStatus::new(self.status.clone()) + } + + async fn gas_price(&self) -> U64 { + self.gas_price.into() + } +} diff --git a/crates/fuel-core/src/service/adapters/gas_price_adapters.rs b/crates/fuel-core/src/service/adapters/gas_price_adapters.rs index 634500947bc..5504389ec4b 100644 --- a/crates/fuel-core/src/service/adapters/gas_price_adapters.rs +++ b/crates/fuel-core/src/service/adapters/gas_price_adapters.rs @@ -88,7 +88,7 @@ impl From for V1AlgorithmConfig { block_activity_threshold, } = value.gas_price_config; V1AlgorithmConfig { - new_exec_gas_price: starting_exec_gas_price, + new_exec_gas_price: starting_exec_gas_price.max(min_exec_gas_price), min_exec_gas_price, exec_gas_price_change_percent, l2_block_fullness_threshold_percent: exec_gas_price_threshold_percent, diff --git a/crates/fuel-core/src/service/adapters/graphql_api.rs b/crates/fuel-core/src/service/adapters/graphql_api.rs index ee30ea70e57..0f7d187eab5 100644 --- a/crates/fuel-core/src/service/adapters/graphql_api.rs +++ b/crates/fuel-core/src/service/adapters/graphql_api.rs @@ -126,7 +126,7 @@ impl BlockProducerPort for BlockProducerAdapter { time: Option, utxo_validation: Option, gas_price: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result> { self.block_producer .dry_run(transactions, height, time, utxo_validation, gas_price) .await diff --git a/crates/fuel-core/src/service/adapters/producer.rs b/crates/fuel-core/src/service/adapters/producer.rs index 07a1ddedebf..b7119a59971 100644 --- a/crates/fuel-core/src/service/adapters/producer.rs +++ b/crates/fuel-core/src/service/adapters/producer.rs @@ -131,7 +131,7 @@ impl fuel_core_producer::ports::DryRunner for ExecutorAdapter { block: Components>, utxo_validation: Option, at_height: Option, - ) -> ExecutorResult> { + ) -> ExecutorResult> { self.executor.dry_run(block, utxo_validation, at_height) } } diff --git a/crates/fuel-core/src/service/config.rs b/crates/fuel-core/src/service/config.rs index c78b864a177..60dad28dfda 100644 --- a/crates/fuel-core/src/service/config.rs +++ b/crates/fuel-core/src/service/config.rs @@ -41,6 +41,7 @@ use crate::{ }, }; +use fuel_core_types::fuel_types::AssetId; #[cfg(feature = "parallel-executor")] use std::num::NonZeroUsize; @@ -233,6 +234,14 @@ impl Config { self } + + pub fn base_asset_id(&self) -> AssetId { + *self + .snapshot_reader + .chain_config() + .consensus_parameters + .base_asset_id() + } } impl From<&Config> for fuel_core_poa::Config { diff --git a/crates/services/executor/src/executor.rs b/crates/services/executor/src/executor.rs index cb58508e89c..ba7111f1870 100644 --- a/crates/services/executor/src/executor.rs +++ b/crates/services/executor/src/executor.rs @@ -1632,13 +1632,16 @@ where .coinbase .checked_add(tx_fee) .ok_or(ExecutorError::FeeOverflow)?; - execution_data.used_gas = execution_data.used_gas.checked_add(used_gas).ok_or( - ExecutorError::GasOverflow( - "Execution used gas overflowed.".into(), - execution_data.used_gas, - used_gas, - ), - )?; + execution_data.used_gas = execution_data + .used_gas + .checked_add(used_gas) + .ok_or_else(|| { + ExecutorError::GasOverflow( + "Execution used gas overflowed.".into(), + execution_data.used_gas, + used_gas, + ) + })?; execution_data.used_size = execution_data .used_size .checked_add(used_size) @@ -1997,14 +2000,13 @@ where gas_price, ) .ok_or(ExecutorError::FeeOverflow)?; - let total_used_gas = - min_gas - .checked_add(used_gas) - .ok_or(ExecutorError::GasOverflow( - "Total used gas overflowed.".into(), - min_gas, - used_gas, - ))?; + let total_used_gas = min_gas.checked_add(used_gas).ok_or_else(|| { + ExecutorError::GasOverflow( + "Total used gas overflowed.".into(), + min_gas, + used_gas, + ) + })?; // if there's no script result (i.e. create) then fee == base amount Ok(( total_used_gas, diff --git a/crates/services/producer/src/block_producer.rs b/crates/services/producer/src/block_producer.rs index 706b00d6338..ea86c9e365d 100644 --- a/crates/services/producer/src/block_producer.rs +++ b/crates/services/producer/src/block_producer.rs @@ -343,7 +343,7 @@ where time: Option, utxo_validation: Option, gas_price: Option, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let view = self.view_provider.latest_view()?; let latest_height = view.latest_height().unwrap_or_default(); @@ -382,23 +382,19 @@ where let executor = self.executor.clone(); // use the blocking threadpool for dry_run to avoid clogging up the main async runtime - let tx_statuses = tokio_rayon::spawn_fifo( - move || -> anyhow::Result> { + let txs = tokio_rayon::spawn_fifo( + move || -> anyhow::Result> { Ok(executor.dry_run(component, utxo_validation, height)?) }, ) .await?; - if transactions - .iter() - .zip(tx_statuses.iter()) - .any(|(transaction, tx_status)| { - transaction.is_script() && tx_status.result.receipts().is_empty() - }) - { + if txs.iter().any(|(transaction, tx_status)| { + transaction.is_script() && tx_status.result.receipts().is_empty() + }) { Err(anyhow!("Expected at least one set of receipts")) } else { - Ok(tx_statuses) + Ok(txs) } } } diff --git a/crates/services/producer/src/mocks.rs b/crates/services/producer/src/mocks.rs index 364cbc1ee52..ca4a9bc627b 100644 --- a/crates/services/producer/src/mocks.rs +++ b/crates/services/producer/src/mocks.rs @@ -212,7 +212,7 @@ impl DryRunner for MockExecutorWithCapture { block: Components>, _utxo_validation: Option, _height: Option, - ) -> ExecutorResult> { + ) -> ExecutorResult> { *self.captured.lock().unwrap() = Some(block); Ok(Vec::new()) diff --git a/crates/services/producer/src/ports.rs b/crates/services/producer/src/ports.rs index c10fe80ae52..349f0408e55 100644 --- a/crates/services/producer/src/ports.rs +++ b/crates/services/producer/src/ports.rs @@ -112,7 +112,7 @@ pub trait DryRunner: Send + Sync { block: Components>, utxo_validation: Option, at_height: Option, - ) -> ExecutorResult>; + ) -> ExecutorResult>; } pub trait StorageReadReplayRecorder: Send + Sync { diff --git a/crates/services/upgradable-executor/src/executor.rs b/crates/services/upgradable-executor/src/executor.rs index e1f771b88d6..dc366438cdc 100644 --- a/crates/services/upgradable-executor/src/executor.rs +++ b/crates/services/upgradable-executor/src/executor.rs @@ -392,7 +392,7 @@ where component: Components>, utxo_validation: Option, at_height: Option, - ) -> ExecutorResult> { + ) -> ExecutorResult> { if at_height.is_some() && !self.config.allow_historical_execution { return Err(ExecutorError::Other( "The historical execution is not allowed".to_string(), @@ -418,6 +418,7 @@ where }; let ExecutionResult { + block, skipped_transactions, tx_status, .. @@ -437,7 +438,10 @@ where return Err(err) } - Ok(tx_status) + let (_, txs) = block.into_inner(); + let result = txs.into_iter().zip(tx_status).collect(); + + Ok(result) } pub fn validate( diff --git a/tests/test-helpers/src/assemble_tx.rs b/tests/test-helpers/src/assemble_tx.rs new file mode 100644 index 00000000000..39c594ee49b --- /dev/null +++ b/tests/test-helpers/src/assemble_tx.rs @@ -0,0 +1,287 @@ +use fuel_core_client::client::{ + types::{ + assemble_tx::{ + Account, + ChangePolicy, + Predicate, + RequiredBalance, + }, + TransactionStatus, + }, + FuelClient, +}; +use fuel_core_types::{ + fuel_asm::Instruction, + fuel_crypto::SecretKey, + fuel_tx::{ + Address, + AssetId, + Input, + Output, + Signable, + Transaction, + TransactionBuilder, + }, +}; +use std::{ + collections::HashMap, + future::Future, + io, +}; + +pub trait AssembleAndRunTx { + fn assemble_and_run_tx( + &self, + tx_to_assemble: &Transaction, + wallet: SigningAccount, + ) -> impl Future> + Send; + + fn assemble_transaction( + &self, + tx_to_assemble: &Transaction, + wallet: SigningAccount, + required_balances: Vec, + ) -> impl Future> + Send; + + fn run_transfer( + &self, + wallet: SigningAccount, + recipients: Vec<(Address, AssetId, u64)>, + ) -> impl Future> + Send; + + fn assemble_transfer( + &self, + wallet: SigningAccount, + recipients: Vec<(Address, AssetId, u64)>, + ) -> impl Future> + Send; + + fn run_script( + &self, + script: Vec, + script_data: Vec, + wallet: SigningAccount, + ) -> impl Future> + Send; + + fn assemble_script( + &self, + script: Vec, + script_data: Vec, + wallet: SigningAccount, + ) -> impl Future> + Send; +} + +impl AssembleAndRunTx for FuelClient { + async fn assemble_and_run_tx( + &self, + tx_to_assemble: &Transaction, + wallet: SigningAccount, + ) -> io::Result { + let tx = self + .assemble_transaction(tx_to_assemble, wallet, Vec::new()) + .await?; + + self.submit_and_await_commit(&tx).await + } + + async fn assemble_transaction( + &self, + tx_to_assemble: &Transaction, + wallet: SigningAccount, + mut required_balances: Vec, + ) -> io::Result { + let params = self.chain_info().await?.consensus_parameters; + let chain_id = params.chain_id(); + let base_asset_id = *params.base_asset_id(); + let wallet_owner = wallet.owner(); + + let mut fee_payer_index = required_balances + .iter() + .enumerate() + .find(|(_, balance)| balance.account.owner() == wallet_owner) + .map(|(i, _)| i); + + if fee_payer_index.is_none() { + let required_balance = match &wallet { + SigningAccount::Wallet(_) => RequiredBalance { + asset_id: base_asset_id, + amount: 0, + account: Account::Address(wallet_owner), + change_policy: ChangePolicy::Change(wallet_owner), + }, + SigningAccount::Predicate { + predicate, + predicate_data, + } => RequiredBalance { + asset_id: base_asset_id, + amount: 0, + account: Account::Predicate(Predicate { + address: wallet_owner, + predicate: predicate.clone(), + predicate_data: predicate_data.clone(), + }), + change_policy: ChangePolicy::Change(wallet_owner), + }, + }; + + fee_payer_index = Some(required_balances.len()); + required_balances.push(required_balance); + } + + let fee_index = + u16::try_from(fee_payer_index.expect("Fee index set above; qed")).unwrap(); + let mut tx = self + .assemble_tx( + tx_to_assemble, + 1, + required_balances, + fee_index, + None, + true, + None, + ) + .await?; + + match wallet { + SigningAccount::Wallet(secret_key) => { + match &mut tx.transaction { + Transaction::Script(ref mut tx) => { + tx.sign_inputs(&secret_key, &chain_id) + } + Transaction::Create(ref mut tx) => { + tx.sign_inputs(&secret_key, &chain_id) + } + Transaction::Mint(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot sign mint transaction", + )); + } + Transaction::Upgrade(ref mut tx) => { + tx.sign_inputs(&secret_key, &chain_id) + } + Transaction::Upload(ref mut tx) => { + tx.sign_inputs(&secret_key, &chain_id) + } + Transaction::Blob(ref mut tx) => { + tx.sign_inputs(&secret_key, &chain_id) + } + }; + } + SigningAccount::Predicate { .. } => { + // Do nothing + } + } + + Ok(tx.transaction) + } + + async fn run_transfer( + &self, + wallet: SigningAccount, + recipients: Vec<(Address, AssetId, u64)>, + ) -> io::Result { + let tx = self.assemble_transfer(wallet, recipients).await?; + + self.submit_and_await_commit(&tx).await + } + + async fn assemble_transfer( + &self, + wallet: SigningAccount, + recipients: Vec<(Address, AssetId, u64)>, + ) -> io::Result { + let wallet_owner = wallet.owner(); + + let mut tx_to_assemble = TransactionBuilder::script(vec![], vec![]); + + let mut total_balances = HashMap::new(); + + for (recipient, asset_id, amount) in recipients { + tx_to_assemble.add_output(Output::Coin { + to: recipient, + asset_id, + amount, + }); + + total_balances + .entry(asset_id) + .and_modify(|balance| *balance += amount) + .or_insert(amount); + } + + let required_balances = total_balances + .into_iter() + .map(|(asset_id, amount)| RequiredBalance { + asset_id, + amount, + account: wallet.clone().into_account(), + change_policy: ChangePolicy::Change(wallet_owner), + }) + .collect(); + + let tx = tx_to_assemble.finalize_as_transaction(); + + self.assemble_transaction(&tx, wallet, required_balances) + .await + } + + async fn run_script( + &self, + script: Vec, + script_data: Vec, + wallet: SigningAccount, + ) -> io::Result { + let tx = self.assemble_script(script, script_data, wallet).await?; + + self.submit_and_await_commit(&tx).await + } + + async fn assemble_script( + &self, + script: Vec, + script_data: Vec, + wallet: SigningAccount, + ) -> io::Result { + let tx_to_assemble = + TransactionBuilder::script(script.into_iter().collect(), script_data) + .finalize_as_transaction(); + + self.assemble_transaction(&tx_to_assemble, wallet, vec![]) + .await + } +} + +#[derive(Clone)] +pub enum SigningAccount { + Wallet(SecretKey), + Predicate { + predicate: Vec, + predicate_data: Vec, + }, +} + +impl SigningAccount { + pub fn owner(&self) -> Address { + match self { + SigningAccount::Wallet(secret_key) => Input::owner(&secret_key.public_key()), + SigningAccount::Predicate { predicate, .. } => { + Input::predicate_owner(predicate) + } + } + } + + pub fn into_account(self) -> Account { + let wallet_owner = self.owner(); + match self { + SigningAccount::Wallet(_) => Account::Address(wallet_owner), + SigningAccount::Predicate { + predicate, + predicate_data, + } => Account::Predicate(Predicate { + address: wallet_owner, + predicate: predicate.clone(), + predicate_data: predicate_data.clone(), + }), + } + } +} diff --git a/tests/test-helpers/src/counter_contract.rs b/tests/test-helpers/src/counter_contract.rs index a1ff4a499c3..962d6b47958 100644 --- a/tests/test-helpers/src/counter_contract.rs +++ b/tests/test-helpers/src/counter_contract.rs @@ -138,13 +138,7 @@ pub async fn increment( ) -> (BlockHeight, u64) { let tx = increment_tx(rng, contract_id); - let mut status_stream = client.submit_and_await_status(&tx).await.unwrap(); - let intermediate_status = status_stream.next().await.unwrap().unwrap(); - assert!(matches!( - intermediate_status, - TransactionStatus::Submitted { .. } - )); - let final_status = status_stream.next().await.unwrap().unwrap(); + let final_status = client.submit_and_await_commit(&tx).await.unwrap(); let TransactionStatus::Success { block_height, receipts, diff --git a/tests/test-helpers/src/lib.rs b/tests/test-helpers/src/lib.rs index 2038e91d7dc..d8636fbee17 100644 --- a/tests/test-helpers/src/lib.rs +++ b/tests/test-helpers/src/lib.rs @@ -1,3 +1,8 @@ +use crate::assemble_tx::SigningAccount; +use fuel_core::{ + chain_config::TESTNET_WALLET_SECRETS, + service::Config, +}; use fuel_core_client::client::{ types::TransactionStatus, FuelClient, @@ -26,6 +31,7 @@ use rand::{ RngCore, }; +pub mod assemble_tx; pub mod builder; pub mod counter_contract; pub mod fuel_core_driver; @@ -127,3 +133,20 @@ pub async fn produce_block_with_tx(rng: &mut StdRng, client: &FuelClient) { "{status:?}" ); } + +/// Returns a config that enables utxo validation and sets a minimum gas price. +/// It enables fee in the network and requirement to use real coins. +pub fn config_with_fee() -> Config { + let mut config = Config::local_node(); + config.utxo_validation = true; + config.gas_price_config.min_exec_gas_price = 1000; + config +} + +pub fn default_signing_wallet() -> SigningAccount { + let wallet_secret: SecretKey = TESTNET_WALLET_SECRETS[1] + .parse() + .expect("Expected valid secret"); + + SigningAccount::Wallet(wallet_secret) +} diff --git a/tests/tests/assemble_tx.rs b/tests/tests/assemble_tx.rs new file mode 100644 index 00000000000..9fa5e82e419 --- /dev/null +++ b/tests/tests/assemble_tx.rs @@ -0,0 +1,353 @@ +use fuel_core::{ + chain_config::{ + ChainConfig, + StateConfig, + TESTNET_WALLET_SECRETS, + }, + service::{ + Config, + FuelService, + }, +}; +use fuel_core_client::client::{ + types::{ + assemble_tx::{ + ChangePolicy, + RequiredBalance, + }, + CoinType, + }, + FuelClient, +}; +use fuel_core_types::{ + blockchain::transaction::TransactionExt, + fuel_asm::op, + fuel_crypto::SecretKey, + fuel_tx::{ + policies::Policies, + Address, + AssetId, + Input, + Output, + Transaction, + TransactionBuilder, + TxPointer, + }, + services::executor::TransactionExecutionResult, +}; +use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, + }, + config_with_fee, + default_signing_wallet, +}; + +#[tokio::test] +async fn assemble_transaction__witness_limit() { + let config = config_with_fee(); + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + + // Given + let tx = TransactionBuilder::script(vec![op::ret(1)].into_iter().collect(), vec![]) + .witness_limit(10000) + .finalize_as_transaction(); + + // When + let tx = client + .assemble_transaction(&tx, default_signing_wallet(), vec![]) + .await + .unwrap(); + let status = client.dry_run(&vec![tx]).await.unwrap(); + + // Then + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); +} + +#[tokio::test] +async fn assemble_transaction__preserves_users_variable_output_even_if_it_is_empty() { + let config = config_with_fee(); + let base_asset_id = config.base_asset_id(); + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + let secret: SecretKey = TESTNET_WALLET_SECRETS[1].parse().unwrap(); + let account = SigningAccount::Wallet(secret); + let CoinType::Coin(coin) = client + .coins_to_spend(&account.owner(), vec![(base_asset_id, 100, None)], None) + .await + .unwrap()[0][0] + else { + panic!("Expected a coin"); + }; + + // Given + let tx: Transaction = + TransactionBuilder::script(vec![op::ret(1)].into_iter().collect(), vec![]) + .add_unsigned_coin_input( + secret, + coin.utxo_id, + coin.amount, + coin.asset_id, + TxPointer::new(coin.block_created.into(), coin.tx_created_idx), + ) + .add_output(Output::change(account.owner(), 0, base_asset_id)) + .add_output(Output::variable(Default::default(), 0, Default::default())) + .finalize_as_transaction(); + + // When + let tx = client + .assemble_transaction(&tx, default_signing_wallet(), vec![]) + .await + .unwrap(); + let status = client.dry_run(&vec![tx.clone()]).await.unwrap(); + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); + + // Then + let outputs = tx.outputs(); + assert_eq!(outputs.len(), 2); + assert!(outputs[0].is_change()); + assert!(outputs[1].is_variable()); +} + +#[tokio::test] +async fn assemble_transaction__input_without_witness() { + let config = config_with_fee(); + let base_asset_id = config.base_asset_id(); + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + let account = default_signing_wallet(); + let CoinType::Coin(coin) = client + .coins_to_spend(&account.owner(), vec![(base_asset_id, 100, None)], None) + .await + .unwrap()[0][0] + else { + panic!("Expected a coin"); + }; + + // Given + let tx = Transaction::script( + 0, + vec![], + vec![], + Policies::new(), + vec![Input::coin_signed( + coin.utxo_id, + coin.owner, + coin.amount, + coin.asset_id, + TxPointer::new(coin.block_created.into(), coin.tx_created_idx), + 0, + )], + vec![], + vec![], + ); + + // When + let tx = client + .assemble_transaction(&tx.into(), account, vec![]) + .await + .unwrap(); + let status = client.dry_run(&vec![tx]).await.unwrap(); + + // Then + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); +} + +#[tokio::test] +async fn assemble_transaction__user_provided_change_output() { + let config = config_with_fee(); + let base_asset_id = config.base_asset_id(); + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + let account = default_signing_wallet(); + let CoinType::Coin(coin) = client + .coins_to_spend(&account.owner(), vec![(base_asset_id, 100, None)], None) + .await + .unwrap()[0][0] + else { + panic!("Expected a coin"); + }; + + // Given + let tx = Transaction::script( + 0, + vec![], + vec![], + Policies::new(), + vec![Input::coin_signed( + coin.utxo_id, + coin.owner, + coin.amount, + coin.asset_id, + TxPointer::new(coin.block_created.into(), coin.tx_created_idx), + 0, + )], + vec![Output::Change { + asset_id: base_asset_id, + to: coin.owner, + amount: 0, + }], + vec![], + ); + + // When + let tx = client + .assemble_transaction( + &tx.into(), + account.clone(), + vec![RequiredBalance { + asset_id: base_asset_id, + amount: 0, + account: account.clone().into_account(), + change_policy: ChangePolicy::Change(account.owner()), + }], + ) + .await + .unwrap(); + let status = client.dry_run(&vec![tx]).await.unwrap(); + + // Then + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); +} + +#[tokio::test] +async fn assemble_transaction__transfer_non_based_asset() { + let mut state_config = StateConfig::local_testnet(); + let chain_config = ChainConfig::local_testnet(); + + let secret: SecretKey = TESTNET_WALLET_SECRETS[1].parse().unwrap(); + let account = SigningAccount::Wallet(secret); + let owner = account.owner(); + let base_asset_id = *chain_config.consensus_parameters.base_asset_id(); + let non_base_asset_id = AssetId::from([1; 32]); + assert_ne!(base_asset_id, non_base_asset_id); + + // Given + state_config.coins[0].owner = owner; + state_config.coins[0].asset_id = base_asset_id; + state_config.coins[1].owner = owner; + state_config.coins[1].asset_id = non_base_asset_id; + + let mut config = Config::local_node_with_configs(chain_config, state_config); + config.utxo_validation = true; + config.gas_price_config.min_exec_gas_price = 1000; + + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + + // Given + let recipient = Address::new([123; 32]); + let amount = 5_000; + let tx = TransactionBuilder::script(vec![op::ret(1)].into_iter().collect(), vec![]) + .add_output(Output::Coin { + to: recipient, + asset_id: non_base_asset_id, + amount, + }) + .finalize_as_transaction(); + + // When + let tx = client + .assemble_transaction( + &tx, + default_signing_wallet(), + vec![RequiredBalance { + asset_id: non_base_asset_id, + amount, + account: account.clone().into_account(), + change_policy: ChangePolicy::Change(owner), + }], + ) + .await + .unwrap(); + let status = client.dry_run(&vec![tx.clone()]).await.unwrap(); + + // Then + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); +} + +#[tokio::test] +async fn assemble_transaction__adds_change_output_for_non_required_non_base_balance() { + let mut state_config = StateConfig::local_testnet(); + let chain_config = ChainConfig::local_testnet(); + + let secret: SecretKey = TESTNET_WALLET_SECRETS[1].parse().unwrap(); + let account = SigningAccount::Wallet(secret); + let owner = account.owner(); + let base_asset_id = *chain_config.consensus_parameters.base_asset_id(); + let non_base_asset_id = AssetId::from([1; 32]); + assert_ne!(base_asset_id, non_base_asset_id); + + // Given + state_config.coins[0].owner = owner; + state_config.coins[0].asset_id = base_asset_id; + state_config.coins[1].owner = owner; + state_config.coins[1].asset_id = non_base_asset_id; + + let mut config = Config::local_node_with_configs(chain_config, state_config); + config.utxo_validation = true; + config.gas_price_config.min_exec_gas_price = 1000; + + let service = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(service.bound_address); + let CoinType::Coin(coin) = client + .coins_to_spend(&owner, vec![(non_base_asset_id, 100, None)], None) + .await + .unwrap()[0][0] + else { + panic!("Expected a coin"); + }; + + // Given + let tx = TransactionBuilder::script(vec![op::ret(1)].into_iter().collect(), vec![]) + .add_unsigned_coin_input( + secret, + coin.utxo_id, + coin.amount, + coin.asset_id, + TxPointer::new(coin.block_created.into(), coin.tx_created_idx), + ) + .finalize_as_transaction(); + + // When + let tx = client + .assemble_transaction(&tx, default_signing_wallet(), vec![]) + .await + .unwrap(); + let status = client.dry_run(&vec![tx.clone()]).await.unwrap(); + let status = status.into_iter().next().unwrap(); + assert!(matches!( + status.result, + TransactionExecutionResult::Success { .. } + )); + + // Then + let outputs = tx.outputs(); + assert_eq!(outputs.len(), 2); + assert!(outputs[0].is_change()); + assert_eq!(outputs[0].asset_id(), Some(&non_base_asset_id)); + assert!(outputs[1].is_change()); + assert_eq!(outputs[1].asset_id(), Some(&base_asset_id)); +} diff --git a/tests/tests/balances.rs b/tests/tests/balances.rs index 5a16e64c575..16b16d1b71d 100644 --- a/tests/tests/balances.rs +++ b/tests/tests/balances.rs @@ -16,12 +16,9 @@ use fuel_core_client::client::{ PageDirection, PaginationRequest, }, - types::{ - primitives::{ - Address, - AssetId, - }, - CoinType, + types::primitives::{ + Address, + AssetId, }, FuelClient, }; @@ -31,20 +28,22 @@ use fuel_core_types::{ fuel_tx::{ Bytes32, ContractIdExt, - Input, - Output, - TransactionBuilder, }, }; use rand::SeedableRng; -use test_helpers::mint_contract; +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + default_signing_wallet, + mint_contract, +}; const RETRYABLE: &[u8] = &[1]; const NON_RETRYABLE: &[u8] = &[]; #[tokio::test] async fn balance() { - let owner = Address::default(); + let wallet = default_signing_wallet(); + let owner = wallet.owner(); let asset_id = AssetId::BASE; // setup config @@ -92,59 +91,17 @@ async fn balance() { let balance = client.balance(&owner, Some(&asset_id)).await.unwrap(); assert_eq!(balance, 450); - // spend some coins and check again - let coins_per_asset = client - .coins_to_spend(&owner, vec![(asset_id, 1, None)], None) + // Spend almost all coins - 449, from available balance 450 + client + .run_transfer(wallet, vec![(Address::new([1u8; 32]), asset_id, 449)]) .await .unwrap(); - let mut tx = TransactionBuilder::script(vec![], vec![]) - .script_gas_limit(1_000_000) - .to_owned(); - for coins in coins_per_asset { - for coin in coins { - match coin { - CoinType::Coin(coin) => tx.add_input(Input::coin_signed( - coin.utxo_id, - coin.owner, - coin.amount, - coin.asset_id, - Default::default(), - 0, - )), - CoinType::MessageCoin(message) => { - tx.add_input(Input::message_coin_signed( - message.sender, - message.recipient, - message.amount, - message.nonce, - 0, - )) - } - CoinType::Unknown => panic!("Unknown coin"), - }; - } - } - let tx = tx - .add_output(Output::Coin { - to: Address::new([1u8; 32]), - amount: 1, - asset_id, - }) - .add_output(Output::Change { - to: owner, - amount: 0, - asset_id, - }) - .add_witness(Default::default()) - .finalize_as_transaction(); - - client.submit_and_await_commit(&tx).await.unwrap(); - let balance = client.balance(&owner, Some(&asset_id)).await.unwrap(); // Note that the big (200000) message, which is RETRYABLE is not included in the balance - assert_eq!(balance, 449); + // 1 coin is left, 449 spent, 200000 message is not included in the balance + assert_eq!(balance, 1); } #[tokio::test] diff --git a/tests/tests/blob.rs b/tests/tests/blob.rs index d158eccaf0b..503248f72b8 100644 --- a/tests/tests/blob.rs +++ b/tests/tests/blob.rs @@ -6,14 +6,15 @@ use fuel_core::{ database_description::on_chain::OnChain, Database, }, - service::Config, + service::{ + Config, + FuelService, + }, }; -use fuel_core_bin::FuelService; use fuel_core_client::client::{ types::TransactionStatus, FuelClient, }; -use fuel_core_storage::transactional::AtomicView; use fuel_core_types::{ fuel_asm::{ op, @@ -27,22 +28,21 @@ use fuel_core_types::{ BlobIdExt, Finalizable, Input, - Transaction, TransactionBuilder, }, fuel_types::canonical::Serialize, - fuel_vm::{ - checked_transaction::IntoChecked, - constraints::reg_key::{ - IS, - SSP, - ZERO, - }, - interpreter::{ - ExecutableTransaction, - MemoryInstance, - }, + fuel_vm::constraints::reg_key::{ + IS, + SSP, + ZERO, + }, +}; +use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, }, + config_with_fee, }; use tokio::io; @@ -50,13 +50,11 @@ struct TestContext { _node: FuelService, client: FuelClient, } + impl TestContext { async fn new() -> Self { - let config = Config { - debug: true, - utxo_validation: false, - ..Config::local_node() - }; + let mut config = config_with_fee(); + config.debug = true; Self::new_with_config(config).await } @@ -74,16 +72,16 @@ impl TestContext { } async fn new_blob( - &mut self, + &self, blob_data: Vec, ) -> io::Result<(TransactionStatus, BlobId)> { self.new_blob_with_input(blob_data, None).await } async fn new_blob_with_input( - &mut self, + &self, blob_data: Vec, - input: Option, + account: Option, ) -> io::Result<(TransactionStatus, BlobId)> { let blob_id = BlobId::compute(&blob_data); let mut builder = TransactionBuilder::blob(BlobBody { @@ -92,50 +90,39 @@ impl TestContext { }); builder.add_witness(blob_data.into()); - if let Some(input) = input { - builder.add_input(input); - } else { - builder.add_fee_input(); - } - let tx = builder.finalize(); - let status = self.submit(tx).await?; + + let status = self + .client + .assemble_and_run_tx(&tx.into(), unwrap_account(account)) + .await?; Ok((status, blob_id)) } - async fn submit(&mut self, mut tx: Tx) -> io::Result - where - Tx: ExecutableTransaction, - { - let consensus_parameters = - self.client.chain_info().await.unwrap().consensus_parameters; - - let database = self._node.shared.database.on_chain().latest_view().unwrap(); - tx.estimate_predicates( - &consensus_parameters.clone().into(), - MemoryInstance::new(), - &database, - ) - .unwrap(); - - let tx: Transaction = tx.into(); - let tx = tx - .into_checked_basic(Default::default(), &consensus_parameters) - .expect("Cannot check transaction"); + async fn run_script( + &self, + script: Vec, + script_data: Vec, + account: Option, + ) -> io::Result { + self.client + .run_script(script, script_data, unwrap_account(account)) + .await + } +} - let status = self - .client - .submit_and_await_commit(tx.transaction()) - .await?; - Ok(status) +fn unwrap_account(account: Option) -> SigningAccount { + match account { + Some(account) => account, + None => test_helpers::default_signing_wallet(), } } #[tokio::test] async fn blob__upload_works() { // Given - let mut ctx = TestContext::new().await; + let ctx = TestContext::new().await; // When let (status, blob_id) = ctx @@ -145,37 +132,47 @@ async fn blob__upload_works() { assert!(matches!(status, TransactionStatus::Success { .. })); // Then - let script_tx = TransactionBuilder::script( - vec![ - op::gtf_args(0x11, RegId::ZERO, GTFArgs::ScriptData), - op::bsiz(0x20, 0x11), // This panics if blob doesn't exist - op::ret(RegId::ONE), - ] - .into_iter() - .collect(), - blob_id.to_bytes(), - ) - .script_gas_limit(1000000) - .add_fee_input() - .finalize_as_transaction(); - let tx_status = ctx - .client - .submit_and_await_commit(&script_tx) - .await - .unwrap(); + let script = vec![ + op::gtf_args(0x11, RegId::ZERO, GTFArgs::ScriptData), + op::bsiz(0x20, 0x11), // This panics if blob doesn't exist + op::ret(RegId::ONE), + ]; + let script_data = blob_id.to_bytes(); + let tx_status = ctx.run_script(script, script_data, None).await.unwrap(); assert!(matches!(tx_status, TransactionStatus::Success { .. })); } #[tokio::test] -async fn blob__cannot_post_already_existing_blob() { +async fn blob__cannot_post_already_existing_blob_in_tx_pool() { // Given - let mut ctx = TestContext::new().await; + let mut config = Config::local_node(); + config.utxo_validation = false; + config.gas_price_config.min_exec_gas_price = 1000; + let params = config + .snapshot_reader + .chain_config() + .consensus_parameters + .clone(); + + let ctx = TestContext::new_with_config(config).await; let payload: Vec = [op::ret(RegId::ONE)].into_iter().collect(); let (status, _blob_id) = ctx.new_blob(payload.clone()).await.unwrap(); assert!(matches!(status, TransactionStatus::Success { .. })); // When - let result = ctx.new_blob(payload).await; + // We want to submit blob directly to TxPool, because we test that TxPool + // will reject the blob if it's already was deployed. + let blob_id = BlobId::compute(&payload); + let mut builder = TransactionBuilder::blob(BlobBody { + id: blob_id, + witness_index: 0, + }); + builder.with_params(params); + builder.max_fee_limit(1_000_000_000); + builder.add_witness(payload.into()); + builder.add_fee_input(); + let blob = builder.finalize(); + let result = ctx.client.submit_and_await_commit(&blob.into()).await; // Then let err = result.expect_err("Should fail because of the same blob id"); @@ -193,24 +190,13 @@ async fn blob__accessing_nonexistent_blob_panics_vm() { let blob_id = BlobId::new([0; 32]); // Nonexistent // When - let script_tx = TransactionBuilder::script( - vec![ - op::gtf_args(0x11, RegId::ZERO, GTFArgs::ScriptData), - op::bsiz(0x20, 0x11), // This panics if blob doesn't exist - op::ret(RegId::ONE), - ] - .into_iter() - .collect(), - blob_id.to_bytes(), - ) - .script_gas_limit(1000000) - .add_fee_input() - .finalize_as_transaction(); - let tx_status = ctx - .client - .submit_and_await_commit(&script_tx) - .await - .unwrap(); + let script = vec![ + op::gtf_args(0x11, RegId::ZERO, GTFArgs::ScriptData), + op::bsiz(0x20, 0x11), // This panics if blob doesn't exist + op::ret(RegId::ONE), + ]; + let script_data = blob_id.to_bytes(); + let tx_status = ctx.run_script(script, script_data, None).await.unwrap(); // Then assert!(matches!(tx_status, TransactionStatus::Failure { .. })); @@ -219,7 +205,7 @@ async fn blob__accessing_nonexistent_blob_panics_vm() { #[tokio::test] async fn blob__can_be_queried_if_uploaded() { // Given - let mut ctx = TestContext::new().await; + let ctx = TestContext::new().await; let bytecode: Vec = [op::ret(RegId::ONE)].into_iter().collect(); let (status, blob_id) = ctx.new_blob(bytecode.clone()).await.unwrap(); assert!(matches!(status, TransactionStatus::Success { .. })); @@ -240,7 +226,7 @@ async fn blob__can_be_queried_if_uploaded() { #[tokio::test] async fn blob__exists_if_uploaded() { // Given - let mut ctx = TestContext::new().await; + let ctx = TestContext::new().await; let bytecode: Vec = [op::ret(RegId::ONE)].into_iter().collect(); let (status, blob_id) = ctx.new_blob(bytecode.clone()).await.unwrap(); assert!(matches!(status, TransactionStatus::Success { .. })); @@ -277,7 +263,7 @@ async fn predicate_can_load_blob() { let blob_predicate = vec![op::ret(RegId::ONE)].into_iter().collect::>(); // Use `LDC` with mode `1` to load the blob into the predicate. - let predicate = vec![ + let predicate_with_blob = vec![ // Take the pointer to the predicate data section // where the blob ID is stored op::gtf(0x10, ZERO, GTFArgs::InputCoinPredicateData as u16), @@ -298,58 +284,43 @@ async fn predicate_can_load_blob() { .into_iter() .collect::>(); - let owner = Input::predicate_owner(predicate.clone()); let blob_owner = Input::predicate_owner(blob_predicate.clone()); + let predicate_with_blob_owner = Input::predicate_owner(predicate_with_blob.clone()); let mut state = StateConfig::local_testnet(); state.coins[0].owner = blob_owner; - let blob_coin = state.coins[0].clone(); - let blob_input = Input::coin_predicate( - blob_coin.utxo_id(), - blob_owner, - blob_coin.amount, - blob_coin.asset_id, - Default::default(), - 0, - blob_predicate, - vec![], - ); + state.coins[1].owner = predicate_with_blob_owner; - state.coins[1].owner = owner; - let predicate_coin = state.coins[1].clone(); + let blob_predicate_account = SigningAccount::Predicate { + predicate: blob_predicate, + predicate_data: vec![], + }; let mut config = Config::local_node_with_state_config(state); config.debug = true; config.utxo_validation = true; + config.gas_price_config.min_exec_gas_price = 1000; - let mut ctx = TestContext::new_with_config(config).await; + let ctx = TestContext::new_with_config(config).await; let bytecode: Vec = [op::ret(RegId::ONE)].into_iter().collect(); let (status, blob_id) = ctx - .new_blob_with_input(bytecode.clone(), Some(blob_input)) + .new_blob_with_input(bytecode.clone(), Some(blob_predicate_account)) .await .unwrap(); assert!(matches!(status, TransactionStatus::Success { .. })); // Given - let predicate_data = blob_id.to_bytes(); - let predicate_input = Input::coin_predicate( - predicate_coin.utxo_id(), - owner, - predicate_coin.amount, - predicate_coin.asset_id, - Default::default(), - 0, - predicate, - predicate_data, - ); + let predicate_with_blob_account = SigningAccount::Predicate { + predicate: predicate_with_blob, + predicate_data: blob_id.to_bytes(), + }; // When - let mut builder = TransactionBuilder::script(vec![], vec![]); - builder.add_input(predicate_input); - let tx_with_blobed_predicate = builder.finalize(); - let result = ctx.submit(tx_with_blobed_predicate).await; + let result = ctx + .run_script(vec![], vec![], Some(predicate_with_blob_account)) + .await; // Then let status = result.expect("Transaction failed"); diff --git a/tests/tests/chain.rs b/tests/tests/chain.rs index ee9c73f0a7f..8096e14f314 100644 --- a/tests/tests/chain.rs +++ b/tests/tests/chain.rs @@ -22,14 +22,14 @@ use fuel_core_client::client::{ }; use fuel_core_types::{ fuel_crypto::SecretKey, - fuel_tx::{ - Input, - Output, - TransactionBuilder, - }, + fuel_tx::Input, fuel_types::ChainId, }; use rand::SeedableRng; +use test_helpers::assemble_tx::{ + AssembleAndRunTx, + SigningAccount, +}; #[tokio::test] async fn chain_info() { @@ -77,34 +77,19 @@ async fn network_operates_with_non_zero_chain_id() { // Given let chain_id = ChainId::new(0xDEAD); chain_config.consensus_parameters.set_chain_id(chain_id); - let node_config = Config { + let mut node_config = Config { debug: true, utxo_validation: true, ..Config::local_node_with_configs(chain_config, state_config) }; + node_config.gas_price_config.min_exec_gas_price = 1000; let srv = FuelService::new_node(node_config.clone()).await.unwrap(); let client = FuelClient::from(srv.bound_address); - let script = TransactionBuilder::script(vec![], vec![]) - .with_chain_id(chain_id) - .max_fee_limit(amount) - .add_unsigned_coin_input( - secret, - utxo_id, - amount, - AssetId::BASE, - Default::default(), - ) - .add_output(Output::Change { - to: owner, - amount, - asset_id: AssetId::BASE, - }) - .finalize_as_transaction(); // When let result = client - .submit_and_await_commit(&script) + .run_script(vec![], vec![], SigningAccount::Wallet(secret)) .await .expect("transaction should insert"); @@ -152,25 +137,10 @@ async fn network_operates_with_non_zero_base_asset_id() { let srv = FuelService::new_node(node_config.clone()).await.unwrap(); let client = FuelClient::from(srv.bound_address); - let script = TransactionBuilder::script(vec![], vec![]) - .max_fee_limit(amount) - .add_unsigned_coin_input( - secret, - utxo_id, - amount, - new_base_asset_id, - Default::default(), - ) - .add_output(Output::Change { - to: owner, - amount, - asset_id: new_base_asset_id, - }) - .finalize_as_transaction(); // When let result = client - .submit_and_await_commit(&script) + .run_script(vec![], vec![], SigningAccount::Wallet(secret)) .await .expect("transaction should insert"); diff --git a/tests/tests/coins.rs b/tests/tests/coins.rs index 0e90d22af78..12dccf15cb5 100644 --- a/tests/tests/coins.rs +++ b/tests/tests/coins.rs @@ -1,4 +1,3 @@ -use crate::helpers::TestContext; use fuel_core::{ chain_config::{ CoinConfig, @@ -20,6 +19,10 @@ use rand::{ prelude::StdRng, SeedableRng, }; +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + builder::TestContext, +}; mod coin { use super::*; @@ -28,8 +31,12 @@ mod coin { ChainConfig, }; use fuel_core_client::client::types::CoinType; - use fuel_core_types::fuel_crypto::SecretKey; + use fuel_core_types::{ + fuel_crypto::SecretKey, + fuel_tx::Address, + }; use rand::Rng; + use test_helpers::assemble_tx::SigningAccount; async fn setup( owner: Address, @@ -99,39 +106,29 @@ mod coin { let owner = Input::owner(&pk); let cp = ConsensusParameters::default(); let context = setup(owner, asset_id_a, asset_id_b, &cp).await; - // select all available coins to spend - let coins_per_asset = context + + let burn_address: Address = rng.gen(); + + let balance_a = context .client - .coins_to_spend( - &owner, - vec![(asset_id_a, 300, None), (asset_id_b, 300, None)], - None, - ) + .balance(&owner, Some(&asset_id_a)) .await .unwrap(); + let balance_b = context + .client + .balance(&owner, Some(&asset_id_b)) + .await + .unwrap(); + let recipients = vec![ + (burn_address, asset_id_a, balance_a), + (burn_address, asset_id_b, balance_b), + ]; - // spend all coins - let mut script = TransactionBuilder::script(vec![], vec![]); - - for asset_group in coins_per_asset { - for asset in asset_group { - if let CoinType::Coin(coin) = asset { - script.add_unsigned_coin_input( - secret_key, - coin.utxo_id, - coin.amount, - coin.asset_id, - Default::default(), - ); - } - } - } - // send change to different address - script.add_output(Output::change(rng.gen(), 0, asset_id_a)); - script.add_output(Output::change(rng.gen(), 0, asset_id_b)); - let tx = script.finalize_as_transaction(); - - context.client.submit_and_await_commit(&tx).await.unwrap(); + context + .client + .run_transfer(SigningAccount::Wallet(secret_key), recipients) + .await + .unwrap(); // select all available asset a coins to spend let remaining_coins_a = context @@ -286,8 +283,8 @@ mod coin { .coins_to_spend( &owner, vec![ - (asset_id_a, 300, Some(MAX as u32)), - (asset_id_b, 300, Some(MAX as u32)), + (asset_id_a, 300, Some(MAX as u16)), + (asset_id_b, 300, Some(MAX as u16)), ], None, ) @@ -312,6 +309,7 @@ mod message_coin { fuel_crypto::SecretKey, }; use rand::Rng; + use test_helpers::assemble_tx::SigningAccount; use super::*; @@ -378,31 +376,21 @@ mod message_coin { let owner = Input::owner(&pk); let (base_asset_id, context, _) = setup(owner).await; // select all available coins to spend - let coins_per_asset = context + let balance = context .client - .coins_to_spend(&owner, vec![(base_asset_id, 300, None)], None) + .balance(&owner, Some(&base_asset_id)) .await .unwrap(); + let burn_address: Address = rng.gen(); - // spend all coins - let mut script = TransactionBuilder::script(vec![], vec![]); - - coins_per_asset[0].iter().for_each(|coin| { - if let CoinType::MessageCoin(message) = coin { - script.add_unsigned_message_input( - secret_key, - message.sender, - message.nonce, - message.amount, - vec![], - ); - } - }); - // send change to different address - script.add_output(Output::change(rng.gen(), 0, base_asset_id)); - let tx = script.finalize_as_transaction(); - - context.client.submit_and_await_commit(&tx).await.unwrap(); + context + .client + .run_transfer( + SigningAccount::Wallet(secret_key), + vec![(burn_address, base_asset_id, balance)], + ) + .await + .unwrap(); // select all available coins to spend let remaining_coins = context @@ -507,7 +495,7 @@ mod message_coin { // not enough inputs let coins_per_asset = context .client - .coins_to_spend(&owner, vec![(base_asset_id, 300, Some(MAX as u32))], None) + .coins_to_spend(&owner, vec![(base_asset_id, 300, Some(MAX as u16))], None) .await; assert!(coins_per_asset.is_err()); assert_eq!( @@ -732,8 +720,8 @@ mod all_coins { .coins_to_spend( &owner, vec![ - (asset_id_a, 300, Some(MAX as u32)), - (asset_id_b, 300, Some(MAX as u32)), + (asset_id_a, 300, Some(MAX as u16)), + (asset_id_b, 300, Some(MAX as u16)), ], None, ) diff --git a/tests/tests/da_compression.rs b/tests/tests/da_compression.rs index 409d064a25b..039db6b1a3b 100644 --- a/tests/tests/da_compression.rs +++ b/tests/tests/da_compression.rs @@ -15,7 +15,6 @@ use fuel_core::{ }, }; use fuel_core_client::client::{ - pagination::PaginationRequest, types::TransactionStatus, FuelClient, }; @@ -35,11 +34,7 @@ use fuel_core_types::{ }, fuel_crypto::SecretKey, fuel_tx::{ - Address, - GasCosts, Input, - TransactionBuilder, - TxPointer, UniqueIdentifier, }, secrecy::Secret, @@ -50,15 +45,21 @@ use rand::{ SeedableRng, }; use std::str::FromStr; +use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, + }, + config_with_fee, +}; #[tokio::test] async fn can_fetch_da_compressed_block_from_graphql() { let mut rng = StdRng::seed_from_u64(10); let poa_secret = SecretKey::random(&mut rng); - let mut config = Config::local_node(); + let mut config = config_with_fee(); config.consensus_signer = SignMode::Key(Secret::new(poa_secret.into())); - config.utxo_validation = true; let compression_config = fuel_core_compression::Config { temporal_registry_retention: Duration::from_secs(3600), }; @@ -73,42 +74,15 @@ async fn can_fetch_da_compressed_block_from_graphql() { let wallet_secret = SecretKey::from_str(TESTNET_WALLET_SECRETS[1]).expect("Expected valid secret"); - let wallet_address = Address::from(*wallet_secret.public_key().hash()); - - let coins = client - .coins( - &wallet_address, - None, - PaginationRequest { - cursor: None, - results: 10, - direction: fuel_core_client::client::pagination::PageDirection::Forward, - }, + + let status = client + .run_script( + vec![op::ret(RegId::ONE)], + vec![], + SigningAccount::Wallet(wallet_secret), ) .await - .expect("Unable to get coins") - .results; - - let coin = coins - .into_iter() - .next() - .expect("Expected at least one coin"); - - let tx = - TransactionBuilder::script([op::ret(RegId::ONE)].into_iter().collect(), vec![]) - .max_fee_limit(0) - .script_gas_limit(1_000_000) - .with_gas_costs(GasCosts::free()) - .add_unsigned_coin_input( - wallet_secret, - coin.utxo_id, - coin.amount, - coin.asset_id, - TxPointer::new(coin.block_created.into(), coin.tx_created_idx), - ) - .finalize_as_transaction(); - - let status = client.submit_and_await_commit(&tx).await.unwrap(); + .unwrap(); let block_height = match status { TransactionStatus::Success { block_height, .. } => block_height, diff --git a/tests/tests/fee_collection_contract.rs b/tests/tests/fee_collection_contract.rs index 54426b5c293..f79c4a4be8f 100644 --- a/tests/tests/fee_collection_contract.rs +++ b/tests/tests/fee_collection_contract.rs @@ -1,12 +1,6 @@ #![allow(clippy::cast_possible_truncation)] #![allow(clippy::arithmetic_side_effects)] -use rand::{ - rngs::StdRng, - Rng, - SeedableRng, -}; - use fuel_core::service::{ Config, FuelService, @@ -21,7 +15,6 @@ use fuel_core_types::{ GTFArgs, RegId, }, - fuel_crypto::SecretKey, fuel_tx::{ Cacheable, Contract, @@ -41,6 +34,15 @@ use fuel_core_types::{ Salt, }, }; +use rand::{ + rngs::StdRng, + Rng, + SeedableRng, +}; +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + default_signing_wallet, +}; struct TestContext { address: Address, @@ -80,7 +82,7 @@ async fn setup(rng: &mut StdRng) -> TestContext { // Submit contract creation tx let tx_status = client - .submit_and_await_commit(&create_tx.into()) + .assemble_and_run_tx(&create_tx.into(), default_signing_wallet()) .await .unwrap(); assert!(matches!(tx_status, TransactionStatus::Success { .. })); @@ -101,7 +103,7 @@ async fn setup(rng: &mut StdRng) -> TestContext { /// This makes a block with a single transaction that has a fee, /// so that the coinbase fee is collected into the contract -async fn make_block_with_fee(rng: &mut StdRng, ctx: &TestContext) { +async fn make_block_with_fee(ctx: &TestContext) { let old_balance = ctx .client .contract_balance(&ctx.contract_id, None) @@ -111,18 +113,14 @@ async fn make_block_with_fee(rng: &mut StdRng, ctx: &TestContext) { // Run a script that does nothing, but will cause fee collection let tx = TransactionBuilder::script([op::ret(RegId::ONE)].into_iter().collect(), vec![]) - .max_fee_limit(AMOUNT) .tip(TIP) - .add_unsigned_coin_input( - SecretKey::random(rng), - rng.gen(), - AMOUNT, - Default::default(), - Default::default(), - ) - .script_gas_limit(1_000_000) .finalize_as_transaction(); - let tx_status = ctx.client.submit_and_await_commit(&tx).await.unwrap(); + + let tx_status = ctx + .client + .assemble_and_run_tx(&tx, default_signing_wallet()) + .await + .unwrap(); assert!(matches!(tx_status, TransactionStatus::Success { .. })); // Now the coinbase fee should be reflected in the contract balance @@ -142,7 +140,7 @@ async fn collect_fees(ctx: &TestContext) { } = ctx; let asset_id = AssetId::BASE; - let output_index = 1u64; + let output_index = 2u64; let call_struct_register = 0x10; // Now call the fee collection contract to withdraw the fees let script = vec![ @@ -157,33 +155,23 @@ async fn collect_fees(ctx: &TestContext) { op::ret(RegId::ONE), ]; - let tx = TransactionBuilder::script( - script.into_iter().collect(),asset_id.to_bytes().into_iter() - .chain(output_index.to_bytes().into_iter()) - .chain(contract_id - .to_bytes().into_iter()) - .chain(0u64.to_bytes().into_iter()) - .chain(0u64.to_bytes().into_iter()) - .collect(), - ) - .add_fee_input() // No coinbase fee for this block - .script_gas_limit(1_000_000) - .add_input(Input::contract( - Default::default(), - Default::default(), - Default::default(), - Default::default(), - *contract_id, - )) - .add_output(Output::contract(1, Default::default(), Default::default())) - .add_output(Output::variable( - Default::default(), - Default::default(), - Default::default(), - )) - .finalize_as_transaction(); + let script_data = asset_id + .to_bytes() + .into_iter() + .chain(output_index.to_bytes().into_iter()) + .chain(contract_id.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .chain(0u64.to_bytes().into_iter()) + .collect(); - let tx_status = client.submit_and_await_commit(&tx).await.unwrap(); + let tx = TransactionBuilder::script(script.into_iter().collect(), script_data) + .add_fee_input() + .finalize_as_transaction(); + + let tx_status = client + .assemble_and_run_tx(&tx, default_signing_wallet()) + .await + .unwrap(); assert!( matches!(tx_status, TransactionStatus::Success { .. }), "{tx_status:?}" @@ -197,7 +185,7 @@ async fn happy_path() { let ctx = setup(rng).await; for _ in 0..10 { - make_block_with_fee(rng, &ctx).await; + make_block_with_fee(&ctx).await; } // When @@ -269,7 +257,7 @@ async fn missing_variable_output() { let rng = &mut StdRng::seed_from_u64(0); let ctx = setup(rng).await; - make_block_with_fee(rng, &ctx).await; + make_block_with_fee(&ctx).await; let asset_id = AssetId::BASE; let output_index = 1u64; diff --git a/tests/tests/gas_price.rs b/tests/tests/gas_price.rs index 65443bf77f9..23144691eee 100644 --- a/tests/tests/gas_price.rs +++ b/tests/tests/gas_price.rs @@ -80,15 +80,11 @@ use std::{ ops::Deref, time::Duration, }; -use test_helpers::fuel_core_driver::FuelCoreDriver; - -fn tx_for_gas_limit(max_fee_limit: Word) -> Transaction { - TransactionBuilder::script(vec![], vec![]) - .max_fee_limit(max_fee_limit) - .add_fee_input() - .finalize() - .into() -} +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + default_signing_wallet, + fuel_core_driver::FuelCoreDriver, +}; fn infinite_loop_tx( max_fee_limit: Word, @@ -188,8 +184,10 @@ async fn latest_gas_price__for_single_block_should_be_starting_gas_price() { let client = FuelClient::from(srv.bound_address); // when - let tx = tx_for_gas_limit(1); - let _ = client.submit_and_await_commit(&tx).await.unwrap(); + let _ = client + .run_script(vec![], vec![], default_signing_wallet()) + .await + .unwrap(); let LatestGasPrice { gas_price, .. } = client.latest_gas_price().await.unwrap(); // then diff --git a/tests/tests/lib.rs b/tests/tests/lib.rs index 7b2fbb72115..70d0a07eb87 100644 --- a/tests/tests/lib.rs +++ b/tests/tests/lib.rs @@ -2,6 +2,8 @@ #![deny(unused_must_use)] #![deny(warnings)] +#[cfg(not(feature = "only-p2p"))] +mod assemble_tx; #[cfg(not(feature = "only-p2p"))] mod assets; #[cfg(not(feature = "only-p2p"))] diff --git a/tests/tests/messages.rs b/tests/tests/messages.rs index ef9ca682140..d7a3f181f3e 100644 --- a/tests/tests/messages.rs +++ b/tests/tests/messages.rs @@ -1,5 +1,3 @@ -#![allow(non_snake_case)] - use fuel_core::{ chain_config::{ MessageConfig, @@ -16,12 +14,15 @@ use fuel_core_client::client::{ PaginationRequest, }, types::{ + assemble_tx::{ + ChangePolicy, + RequiredBalance, + }, message::MessageStatus, TransactionStatus, }, FuelClient, }; -use fuel_core_storage::tables::Coins; use fuel_core_types::{ fuel_asm::{ op, @@ -40,6 +41,14 @@ use fuel_core_types::{ use itertools::Itertools; use rstest::rstest; use std::ops::Deref; +use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, + }, + config_with_fee, + default_signing_wallet, +}; mod relayer; @@ -288,18 +297,12 @@ async fn message_status__can_get_notfound() { #[tokio::test] async fn can_get_message_proof() { for n in [1, 2, 10] { - let config = Config::local_node(); + let config = config_with_fee(); + let base_asset_id = config.base_asset_id(); - let coin = config - .snapshot_reader - .read::() - .unwrap() - .into_iter() - .next() - .unwrap() - .unwrap()[0] - .value - .clone(); + // setup server & client + let srv = FuelService::new_node(config).await.unwrap(); + let client = FuelClient::from(srv.bound_address); struct MessageArgs { recipient_address: [u8; 32], @@ -353,8 +356,7 @@ async fn can_get_message_proof() { let output = Output::contract_created(id, state_root); // Create the contract deploy transaction. - let mut contract_deploy = TransactionBuilder::create(bytecode, salt, vec![]) - .add_fee_input() + let contract_deploy = TransactionBuilder::create(bytecode, salt, vec![]) .add_output(output) .finalize_as_transaction(); @@ -371,7 +373,7 @@ async fn can_get_message_proof() { // The message data .chain(arg.message_data.clone().into_iter()) })).collect(); - let script_data = AssetId::BASE + let script_data = base_asset_id .into_iter() .chain(smo_data.into_iter()) .collect(); @@ -380,7 +382,7 @@ async fn can_get_message_proof() { // Save the ptr to the script data to register 16. // This will be used to read the contract id + two // empty params. So 32 + 8 + 8. - let script = [ + let script = vec![ op::gtf_args(0x10, 0x00, GTFArgs::ScriptData), // load balance to forward to 0x11 op::movi(0x11, n as u32 * amount), @@ -391,75 +393,49 @@ async fn can_get_message_proof() { // Return. op::ret(RegId::ONE), ]; - let script: Vec = script - .iter() - .flat_map(|op| u32::from(*op).to_be_bytes()) - .collect(); - - let predicate = op::ret(RegId::ONE).to_bytes().to_vec(); - let owner = Input::predicate_owner(&predicate); - let coin_input = Input::coin_predicate( - Default::default(), - owner, - 1000, - *coin.asset_id(), - TxPointer::default(), - Default::default(), - predicate, - vec![], - ); - - // Set the contract input because we are calling a contract. - let inputs = vec![ - Input::contract( - UtxoId::new(Bytes32::zeroed(), 0), - Bytes32::zeroed(), - state_root, - TxPointer::default(), - id, - ), - coin_input, - ]; - // The transaction will output a contract output and message output. - let outputs = vec![Output::contract(0, Bytes32::zeroed(), Bytes32::zeroed())]; - - // Create the contract calling script. - let script = Transaction::script( - 1_000_000, - script, - script_data, - policies::Policies::new().with_max_fee(0), - inputs, - outputs, - vec![], - ); + let script_tx = + TransactionBuilder::script(script.into_iter().collect(), script_data) + .finalize_as_transaction(); - let transaction_id = script.id(&ChainId::default()); - - // setup server & client - let srv = FuelService::new_node(config).await.unwrap(); - let client = FuelClient::from(srv.bound_address); + let predicate_account = SigningAccount::Predicate { + predicate: op::ret(RegId::ONE).to_bytes().to_vec(), + predicate_data: vec![], + }; + let predicate_address = predicate_account.owner(); + // Send some funds to the predicate client - .estimate_predicates(&mut contract_deploy) + .run_transfer( + default_signing_wallet(), + vec![(predicate_address, base_asset_id, 1_000_000)], + ) .await - .expect("Should be able to estimate deploy tx"); + .unwrap(); // Deploy the contract. matches!( - client.submit_and_await_commit(&contract_deploy).await, + client + .assemble_and_run_tx(&contract_deploy, default_signing_wallet()) + .await, Ok(TransactionStatus::Success { .. }) ); - let mut script = script.into(); - client - .estimate_predicates(&mut script) - .await - .expect("Should be able to estimate script tx"); // Call the contract. + let money_for_smo = RequiredBalance { + asset_id: base_asset_id, + amount: amount as u64, + account: predicate_account.clone().into_account(), + change_policy: ChangePolicy::Change(predicate_account.owner()), + }; + let tx = client + .assemble_transaction(&script_tx, predicate_account, vec![money_for_smo]) + .await + .unwrap(); + + let transaction_id = tx.id(&ChainId::default()); matches!( - client.submit_and_await_commit(&script).await, + client.submit_and_await_commit(&tx).await, Ok(TransactionStatus::Success { .. }) ); diff --git a/tests/tests/messages/relayer.rs b/tests/tests/messages/relayer.rs index 103e842e6b0..0c75afd4c27 100644 --- a/tests/tests/messages/relayer.rs +++ b/tests/tests/messages/relayer.rs @@ -1,10 +1,32 @@ -use super::*; -use fuel_core_types::blockchain::primitives::DaBlockHeight; +use fuel_core::{ + chain_config::{ + MessageConfig, + StateConfig, + }, + service::{ + Config, + FuelService, + }, +}; +use fuel_core_client::client::{ + types::TransactionStatus, + FuelClient, +}; +use fuel_core_types::{ + blockchain::primitives::DaBlockHeight, + fuel_asm::op, + fuel_crypto::SecretKey, + fuel_tx::Input, +}; use rand::{ rngs::StdRng, Rng, SeedableRng, }; +use test_helpers::assemble_tx::{ + AssembleAndRunTx, + SigningAccount, +}; #[tokio::test] async fn can_submit_genesis_message() { @@ -21,31 +43,25 @@ async fn can_submit_genesis_message() { data: vec![], da_height: DaBlockHeight(0), }; - let tx1 = TransactionBuilder::script(vec![op::ret(0)].into_iter().collect(), vec![]) - .script_gas_limit(100000) - .add_unsigned_message_input( - secret_key, - msg1.sender, - msg1.nonce, - msg1.amount, - msg1.data.clone(), - ) - .finalize_as_transaction(); let state = StateConfig { messages: vec![msg1], ..Default::default() }; - let node_config = Config { + let mut node_config = Config { utxo_validation: true, ..Config::local_node_with_state_config(state) }; + node_config.gas_price_config.min_exec_gas_price = 1000; let srv = FuelService::new_node(node_config.clone()).await.unwrap(); let client = FuelClient::from(srv.bound_address); // verify tx is successful - let status = client.submit_and_await_commit(&tx1).await.unwrap(); + let status = client + .run_script(vec![op::ret(0)], vec![], SigningAccount::Wallet(secret_key)) + .await + .unwrap(); assert!( matches!(status, TransactionStatus::Success { .. }), "expected success, received {status:?}", diff --git a/tests/tests/regenesis.rs b/tests/tests/regenesis.rs index 3afc3cc6855..2d025c9a4e5 100644 --- a/tests/tests/regenesis.rs +++ b/tests/tests/regenesis.rs @@ -345,14 +345,7 @@ async fn test_regenesis_message_proofs_are_preserved() -> anyhow::Result<()> { let mut rng = StdRng::seed_from_u64(1234); let core = FuelCoreDriver::spawn_feeless(&["--debug", "--poa-instant", "true"]).await?; - let base_asset_id = *core - .node - .shared - .config - .snapshot_reader - .chain_config() - .consensus_parameters - .base_asset_id(); + let base_asset_id = core.node.shared.config.base_asset_id(); let secret = SecretKey::random(&mut rng); let public_key: PublicKey = (&secret).into(); diff --git a/tests/tests/relayer.rs b/tests/tests/relayer.rs index d62d01f5752..a574363a110 100644 --- a/tests/tests/relayer.rs +++ b/tests/tests/relayer.rs @@ -63,7 +63,6 @@ use hyper::{ }; use rand::{ prelude::StdRng, - Rng, SeedableRng, }; use serde_json::json; @@ -76,6 +75,13 @@ use std::{ sync::Arc, time::Duration, }; +use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, + }, + config_with_fee, +}; use tokio::sync::oneshot::Sender; enum MessageKind { @@ -150,7 +156,7 @@ async fn relayer_can_download_logs() { #[tokio::test(flavor = "multi_thread")] async fn messages_are_spendable_after_relayer_is_synced() { let mut rng = StdRng::seed_from_u64(1234); - let mut config = Config::local_node(); + let mut config = config_with_fee(); config.relayer = Some(relayer::Config::default()); let relayer_config = config.relayer.as_mut().expect("Expected relayer config"); let eth_node = MockMiddleware::default(); @@ -185,8 +191,6 @@ async fn messages_are_spendable_after_relayer_is_synced() { .try_into() .unwrap()]); - config.utxo_validation = true; - // setup fuel node with mocked eth url let db = Database::in_memory(); @@ -199,16 +203,7 @@ async fn messages_are_spendable_after_relayer_is_synced() { // wait for relayer to catch up to eth node srv.await_relayer_synced().await.unwrap(); // Wait for the block producer to create a block that targets the latest da height. - srv.shared - .poa_adapter - .manually_produce_blocks( - None, - Mode::Blocks { - number_of_blocks: 1, - }, - ) - .await - .unwrap(); + client.produce_blocks(1, None).await.unwrap(); // verify we have downloaded the message let query = client @@ -226,14 +221,8 @@ async fn messages_are_spendable_after_relayer_is_synced() { assert_eq!(query.results.len(), 1); // attempt to spend the message downloaded from the relayer - let tx = TransactionBuilder::script(vec![op::ret(0)].into_iter().collect(), vec![]) - .script_gas_limit(10_000) - .add_unsigned_message_input(secret_key, sender, nonce, amount, vec![]) - .add_output(Output::change(rng.gen(), 0, AssetId::BASE)) - .finalize(); - let status = client - .submit_and_await_commit(&tx.clone().into()) + .run_script(vec![op::ret(0)], vec![], SigningAccount::Wallet(secret_key)) .await .unwrap(); @@ -650,7 +639,7 @@ async fn balances_and_coins_to_spend_never_return_retryable_messages() { let query = client .coins_to_spend( &recipient, - vec![(base_asset_id, NON_RETRYABLE_AMOUNT, None)], + vec![(base_asset_id, NON_RETRYABLE_AMOUNT as u128, None)], None, ) .await @@ -673,7 +662,7 @@ async fn balances_and_coins_to_spend_never_return_retryable_messages() { let query = client .coins_to_spend( &recipient, - vec![(base_asset_id, NON_RETRYABLE_AMOUNT + 1, None)], + vec![(base_asset_id, (NON_RETRYABLE_AMOUNT + 1) as u128, None)], None, ) .await diff --git a/tests/tests/state_rewind.rs b/tests/tests/state_rewind.rs index 6e8a7f5b6bd..623b472e171 100644 --- a/tests/tests/state_rewind.rs +++ b/tests/tests/state_rewind.rs @@ -11,26 +11,20 @@ use fuel_core::{ }, }; use fuel_core_client::client::{ - pagination::PaginationRequest, - types::{ - Coin, - TransactionStatus as ClientTransactionStatus, - }, + types::TransactionStatus as ClientTransactionStatus, FuelClient, }; use fuel_core_storage::transactional::AtomicView; use fuel_core_types::{ + blockchain::transaction::TransactionExt, fuel_tx::{ - Address, AssetId, - GasCosts, Input, Output, Receipt, Transaction, TransactionBuilder, TxId, - TxPointer, UniqueIdentifier, }, fuel_types::BlockHeight, @@ -53,6 +47,11 @@ use std::{ }; use tempfile::TempDir; use test_helpers::{ + assemble_tx::{ + AssembleAndRunTx, + SigningAccount, + }, + config_with_fee, counter_contract, fuel_core_driver::FuelCoreDriver, produce_block_with_tx, @@ -403,69 +402,35 @@ async fn backup_and_restore__should_work_with_state_rewind() -> anyhow::Result<( Ok(()) } -/// Get arbitrary coin from a wallet -async fn get_wallet_coin(client: &FuelClient, wallet_address: &Address) -> Coin { - let coins = client - .coins( - wallet_address, - None, - PaginationRequest { - cursor: None, - results: 10, - direction: fuel_core_client::client::pagination::PageDirection::Forward, - }, - ) - .await - .expect("Unable to get coins") - .results; - coins - .into_iter() - .next() - .expect("Expected at least one coin") -} - #[tokio::test(flavor = "multi_thread")] async fn dry_run__correct_utxoid_state_in_past_blocks() -> anyhow::Result<()> { let mut rng = StdRng::seed_from_u64(1234); let poa_secret = SecretKey::random(&mut rng); - let mut config = fuel_core::service::Config::local_node(); + let mut config = config_with_fee(); config.consensus_signer = SignMode::Key(Secret::new(poa_secret.into())); - config.utxo_validation = true; + let base_asset_id = config.base_asset_id(); + let srv = FuelService::new_node(config).await.unwrap(); let client = FuelClient::from(srv.bound_address); // First, distribute one test wallet to multiple addresses let wallet_secret = SecretKey::from_str(TESTNET_WALLET_SECRETS[1]).expect("Expected valid secret"); - let wallet_address = Address::from(*wallet_secret.public_key().hash()); - let coin = get_wallet_coin(&client, &wallet_address).await; let wallets: Vec = (0..2).map(|_| SecretKey::random(&mut rng)).collect(); - let mut tx = TransactionBuilder::script(vec![], vec![]); - tx.max_fee_limit(0) - .script_gas_limit(1_000_000) - .with_gas_costs(GasCosts::free()) - .add_unsigned_coin_input( - wallet_secret, - coin.utxo_id, - coin.amount, - coin.asset_id, - TxPointer::new(coin.block_created.into(), coin.tx_created_idx), - ); - for key in wallets.iter() { - let public = key.public_key(); - let owner: [u8; Address::LEN] = public.hash().into(); - - tx.add_output(Output::Coin { - to: owner.into(), - amount: coin.amount / (wallets.len() as u64), - asset_id: coin.asset_id, - }); - } - let first_tx = tx.finalize_as_transaction(); - let status = client.submit_and_await_commit(&first_tx).await.unwrap(); + let recipients = wallets + .iter() + .map(|secret_key| { + let address = SigningAccount::Wallet(*secret_key).owner(); + (address, base_asset_id, 1_000_000_000) + }) + .collect(); + let status = client + .run_transfer(SigningAccount::Wallet(wallet_secret), recipients) + .await + .unwrap(); let ClientTransactionStatus::Success { block_height: bh_first, .. @@ -475,28 +440,28 @@ async fn dry_run__correct_utxoid_state_in_past_blocks() -> anyhow::Result<()> { }; // Then, transfer one of these coins to another wallet - let source_public = wallets[0].public_key(); - let source_owner: [u8; Address::LEN] = source_public.hash().into(); - let source_coin = get_wallet_coin(&client, &source_owner.into()).await; - let target_public = wallets[1].public_key(); - let target_owner: [u8; Address::LEN] = target_public.hash().into(); - let second_tx = TransactionBuilder::script(vec![], vec![]) - .max_fee_limit(0) - .script_gas_limit(1_000_000) - .with_gas_costs(GasCosts::free()) - .add_unsigned_coin_input( - wallets[0], - source_coin.utxo_id, - source_coin.amount, - source_coin.asset_id, - TxPointer::new(source_coin.block_created.into(), source_coin.tx_created_idx), + let source_wallet = wallets[0]; + let target_wallet = wallets[1]; + let target_address = SigningAccount::Wallet(target_wallet).owner(); + + let second_tx = client + .assemble_transfer( + SigningAccount::Wallet(source_wallet), + vec![(target_address, base_asset_id, 2)], ) - .add_output(Output::Coin { - to: target_owner.into(), - amount: source_coin.amount, - asset_id: source_coin.asset_id, - }) - .finalize_as_transaction(); + .await + .unwrap(); + // `third_tx` is similar to `second_tx`, but sends less coins(`1` instead of `2`) + // We want `third_tx` have a different TxId than `second_tx`. + // Both transactions use the same UtxoId + let third_tx = client + .assemble_transfer( + SigningAccount::Wallet(source_wallet), + vec![(target_address, base_asset_id, 1)], + ) + .await + .unwrap(); + assert_eq!(second_tx.inputs(), third_tx.inputs()); let status = client.submit_and_await_commit(&second_tx).await.unwrap(); let ClientTransactionStatus::Success { block_height: bh_second, @@ -535,24 +500,6 @@ async fn dry_run__correct_utxoid_state_in_past_blocks() -> anyhow::Result<()> { assert!(err.contains("Transaction id was already used")); // At latest height, a similar transaction with a different TxId should still fail - let third_tx = TransactionBuilder::script(vec![], vec![]) - .max_fee_limit(0) - .script_gas_limit(1_000_001) // changed to make it a different transaction - .with_gas_costs(GasCosts::free()) - .add_unsigned_coin_input( - wallets[0], - source_coin.utxo_id, - source_coin.amount, - source_coin.asset_id, - TxPointer::new(source_coin.block_created.into(), source_coin.tx_created_idx), - ) - .add_output(Output::Coin { - to: target_owner.into(), - amount: source_coin.amount, - asset_id: source_coin.asset_id, - }) - .finalize_as_transaction(); - let err = client .dry_run_opt(&[third_tx.clone()], None, None, None) .await diff --git a/tests/tests/trigger_integration/instant.rs b/tests/tests/trigger_integration/instant.rs index 0b6cda1512e..bb8c95f3432 100644 --- a/tests/tests/trigger_integration/instant.rs +++ b/tests/tests/trigger_integration/instant.rs @@ -1,10 +1,4 @@ -use fuel_core::{ - database::Database, - service::{ - Config, - FuelService, - }, -}; +use fuel_core::service::FuelService; use fuel_core_client::client::{ pagination::{ PageDirection, @@ -16,7 +10,6 @@ use fuel_core_poa::Trigger; use fuel_core_types::{ fuel_asm::*, fuel_crypto::SecretKey, - fuel_tx::TransactionBuilder, secrecy::Secret, signer::SignMode, }; @@ -24,33 +17,31 @@ use rand::{ rngs::StdRng, SeedableRng, }; +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + config_with_fee, + default_signing_wallet, +}; -#[tokio::test(start_paused = true)] +#[tokio::test] async fn poa_instant_trigger_is_produces_instantly() { let mut rng = StdRng::seed_from_u64(10); - let db = Database::default(); - let mut config = Config::local_node(); + let mut config = config_with_fee(); config.consensus_signer = SignMode::Key(Secret::new(SecretKey::random(&mut rng).into())); config.block_production = Trigger::Instant; - let srv = FuelService::from_database(db.clone(), config) - .await - .unwrap(); + let srv = FuelService::new_node(config).await.unwrap(); let client = FuelClient::from(srv.bound_address); for i in 0..10usize { - let tx = TransactionBuilder::script( - [op::movi(0x10, i.try_into().unwrap())] - .into_iter() - .collect(), - vec![], - ) - .add_fee_input() - .finalize_as_transaction(); - let _tx_id = client.submit_and_await_commit(&tx).await.unwrap(); + let script = vec![op::movi(0x10, i.try_into().unwrap())]; + client + .run_script(script, vec![], default_signing_wallet()) + .await + .unwrap(); let count = client .blocks(PaginationRequest { cursor: None, diff --git a/tests/tests/tx.rs b/tests/tests/tx.rs index c760a81dbc0..901e3900a68 100644 --- a/tests/tests/tx.rs +++ b/tests/tests/tx.rs @@ -52,6 +52,11 @@ use std::{ io::ErrorKind::NotFound, time::Duration, }; +use test_helpers::{ + assemble_tx::AssembleAndRunTx, + config_with_fee, + default_signing_wallet, +}; mod predicates; mod tx_pointer; @@ -335,28 +340,20 @@ async fn transaction_selector_can_select_a_transaction_that_fits_the_block_size_ #[tokio::test] async fn submit() { - let srv = FuelService::new_node(Config::local_node()).await.unwrap(); + let srv = FuelService::new_node(config_with_fee()).await.unwrap(); let client = FuelClient::from(srv.bound_address); - let gas_limit = 1_000_000; - let maturity = Default::default(); - - let script = [ + let script = vec![ op::addi(0x10, RegId::ZERO, 0xca), op::addi(0x11, RegId::ZERO, 0xba), op::log(0x10, 0x11, RegId::ZERO, RegId::ZERO), op::ret(RegId::ONE), ]; - let script: Vec = script - .iter() - .flat_map(|op| u32::from(*op).to_be_bytes()) - .collect(); - let tx = TransactionBuilder::script(script, vec![]) - .script_gas_limit(gas_limit) - .maturity(maturity) - .add_fee_input() - .finalize_as_transaction(); + let tx = client + .assemble_script(script, vec![], default_signing_wallet()) + .await + .unwrap(); client.submit_and_await_commit(&tx).await.unwrap(); // verify that the tx returned from the api matches the submitted tx @@ -373,28 +370,19 @@ async fn submit() { #[tokio::test] async fn submit_and_await_status() { - let srv = FuelService::new_node(Config::local_node()).await.unwrap(); + let srv = FuelService::new_node(config_with_fee()).await.unwrap(); let client = FuelClient::from(srv.bound_address); - let gas_limit = 1_000_000; - let maturity = Default::default(); - - let script = [ + let script = vec![ op::addi(0x10, RegId::ZERO, 0xca), op::addi(0x11, RegId::ZERO, 0xba), op::log(0x10, 0x11, RegId::ZERO, RegId::ZERO), op::ret(RegId::ONE), ]; - let script: Vec = script - .iter() - .flat_map(|op| u32::from(*op).to_be_bytes()) - .collect(); - - let tx = TransactionBuilder::script(script, vec![]) - .script_gas_limit(gas_limit) - .maturity(maturity) - .add_fee_input() - .finalize_as_transaction(); + let tx = client + .assemble_script(script, vec![], default_signing_wallet()) + .await + .unwrap(); let mut status_stream = client.submit_and_await_status(&tx).await.unwrap(); let intermediate_status = status_stream.next().await.unwrap().unwrap(); @@ -413,29 +401,19 @@ async fn dry_run_transaction_should_use_latest_block_time() { let block_production_interval_seconds = 10; let number_of_blocks_to_produce_manually = 5; - let mut config = Config::local_node(); + let mut config = config_with_fee(); config.block_production = Trigger::Interval { block_time: Duration::from_secs(block_production_interval_seconds), }; let srv = FuelService::new_node(config).await.unwrap(); let client = FuelClient::from(srv.bound_address); - let gas_limit = 1_000_000; - let maturity = Default::default(); - - let get_block_timestamp_script = - [op::bhei(0x10), op::time(0x11, 0x10), op::ret(0x11)]; + let script = vec![op::bhei(0x10), op::time(0x11, 0x10), op::ret(0x11)]; - let script: Vec = get_block_timestamp_script - .iter() - .flat_map(|op| u32::from(*op).to_be_bytes()) - .collect(); - - let tx = TransactionBuilder::script(script, vec![]) - .script_gas_limit(gas_limit) - .maturity(maturity) - .add_fee_input() - .finalize_as_transaction(); + let tx = client + .assemble_script(script, vec![], default_signing_wallet()) + .await + .unwrap(); // When client @@ -463,17 +441,6 @@ async fn dry_run_transaction_should_use_latest_block_time() { assert_eq!(expected_returned_timestamp, returned_timestamp); } -#[ignore] -#[tokio::test] -async fn transaction_status_submitted() { - // This test should ensure a transaction's status is Submitted while it is in the mempool - // This test should also ensure a transaction's time of submission is correct in the returned status - // Currently blocked until https://github.com/FuelLabs/fuel-core/issues/50 is resolved - // as execution must be separate from submission for a tx to persist inside of the txpool - // Merge with the submit_utxo_verified_tx test once utxo_verification is the default - todo!(); -} - #[tokio::test] async fn receipts() { let transaction = Transaction::default_test_tx();