diff --git a/Cargo.toml b/Cargo.toml index 3487aefa18..0dc65ae43d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ bytes = { version = "1.5.0", default-features = false } chrono = "0.4.31" cynic = { version = "2.2", default-features = false } elliptic-curve = { version = "0.13.8", default-features = false } +test-case = { version = "3.3", default-features = false } eth-keystore = "0.5.0" flate2 = { version = "1.0", default-features = false } fuel-abi-types = "0.8.0" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 252949af3e..a6f2646614 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -76,6 +76,7 @@ - [Transfer all assets](./cookbook/transfer-all-assets.md) - [Debugging](./debugging/index.md) - [The Function selector](./debugging/function-selector.md) + - [Decoding script transactions](./debugging/decoding-script-transactions.md) - [Glossary](./glossary.md) - [Contributing](./contributing/CONTRIBUTING.md) - [Integration tests structure](./contributing/tests-structure.md) diff --git a/docs/src/debugging/decoding-script-transactions.md b/docs/src/debugging/decoding-script-transactions.md new file mode 100644 index 0000000000..d02af92af5 --- /dev/null +++ b/docs/src/debugging/decoding-script-transactions.md @@ -0,0 +1,24 @@ +# Decoding script transactions + +The SDK offers some tools that can help you make fuel script transactions more +human readable. You can determine whether the script transaction is: + +* calling a contract method(s), +* is a loader script and you can see the blob id +* is neither of the above + +In the case of contract call(s), if you have the ABI file, you can also decode +the arguments to the function by making use of the `AbiFormatter`: + +```rust,ignore +{{#include ../../../examples/contracts/src/lib.rs:decoding_script_transactions}} +``` + +prints: + +```text +The script called: initialize_counter(42) +``` + +The `AbiFormatter` can also decode configurables, refer to the rust docs for +more information. diff --git a/e2e/sway/scripts/script_struct/src/main.sw b/e2e/sway/scripts/script_struct/src/main.sw index 57fa107db0..a101d4eef0 100644 --- a/e2e/sway/scripts/script_struct/src/main.sw +++ b/e2e/sway/scripts/script_struct/src/main.sw @@ -1,14 +1,19 @@ script; +configurable { + MY_STRUCT: MyStruct = MyStruct { + number: 10, + boolean: true, + }, + A_NUMBER: u64 = 11, +} + struct MyStruct { number: u64, boolean: bool, } -fn main(my_struct: MyStruct) -> u64 { - if my_struct.boolean { - my_struct.number - } else { - 0 - } +fn main(arg: MyStruct) -> u64 { + let _calc = MY_STRUCT.number + A_NUMBER; + if arg.boolean { arg.number } else { 0 } } diff --git a/e2e/sway/types/contracts/nested_structs/src/main.sw b/e2e/sway/types/contracts/nested_structs/src/main.sw index ec80e30a3c..6aec2bab53 100644 --- a/e2e/sway/types/contracts/nested_structs/src/main.sw +++ b/e2e/sway/types/contracts/nested_structs/src/main.sw @@ -25,7 +25,10 @@ struct MemoryAddress { abi MyContract { fn get_struct() -> AllStruct; + #[payable] fn check_struct_integrity(arg: AllStruct) -> bool; + #[payable] + fn i_am_called_differently(_arg1: AllStruct, _arg2: MemoryAddress); fn nested_struct_with_reserved_keyword_substring(call_data: CallData) -> CallData; } @@ -38,10 +41,15 @@ impl MyContract for Contract { }, } } + + #[payable] fn check_struct_integrity(arg: AllStruct) -> bool { arg.some_struct.field == 12345u32 && arg.some_struct.field_2 == true } + #[payable] + fn i_am_called_differently(_arg1: AllStruct, _arg2: MemoryAddress) {} + fn nested_struct_with_reserved_keyword_substring(call_data: CallData) -> CallData { call_data } diff --git a/e2e/tests/debug_utils.rs b/e2e/tests/debug_utils.rs new file mode 100644 index 0000000000..c834069249 --- /dev/null +++ b/e2e/tests/debug_utils.rs @@ -0,0 +1,500 @@ +use fuels::{ + core::{ + codec::{ABIEncoder, ABIFormatter}, + traits::Tokenizable, + }, + prelude::*, + programs::{debug::ScriptType, executable::Executable}, +}; + +#[tokio::test] +async fn can_debug_single_call_tx() -> Result<()> { + setup_program_test!( + Wallets("wallet"), + Abigen(Contract( + name = "MyContract", + project = "e2e/sway/types/contracts/nested_structs" + )) + ); + let contract_id = Contract::load_from( + "sway/types/contracts/nested_structs/out/release/nested_structs.bin", + Default::default(), + )? + .contract_id(); + + let call_handler = MyContract::new(contract_id, wallet) + .methods() + .check_struct_integrity(AllStruct { + some_struct: SomeStruct { + field: 2, + field_2: true, + }, + }); + + let abi = std::fs::read_to_string( + "./sway/types/contracts/nested_structs/out/release/nested_structs-abi.json", + ) + .unwrap(); + let decoder = ABIFormatter::from_json_abi(&abi)?; + + // without gas forwarding + { + let tb = call_handler + .clone() + .call_params(CallParameters::default().with_amount(10)) + .unwrap() + .transaction_builder() + .await + .unwrap(); + + let script = tb.script; + let script_data = tb.script_data; + + let ScriptType::ContractCall(call_descriptions) = + ScriptType::detect(&script, &script_data)? + else { + panic!("expected a contract call") + }; + + assert_eq!(call_descriptions.len(), 1); + let call_description = &call_descriptions[0]; + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 10); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "check_struct_integrity" + ); + assert!(call_description.gas_forwarded.is_none()); + + assert_eq!( + decoder.decode_fn_args( + &call_description.decode_fn_selector().unwrap(), + &call_description.encoded_args + )?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }"] + ); + } + + // with gas forwarding + { + let tb = call_handler + .clone() + .call_params( + CallParameters::default() + .with_amount(10) + .with_gas_forwarded(20), + ) + .unwrap() + .transaction_builder() + .await + .unwrap(); + + let script = tb.script; + let script_data = tb.script_data; + + let ScriptType::ContractCall(call_descriptions) = + ScriptType::detect(&script, &script_data)? + else { + panic!("expected a contract call") + }; + + assert_eq!(call_descriptions.len(), 1); + let call_description = &call_descriptions[0]; + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 10); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "check_struct_integrity" + ); + assert_eq!(call_description.gas_forwarded, Some(20)); + + assert_eq!( + decoder.decode_fn_args( + &call_description.decode_fn_selector().unwrap(), + &call_description.encoded_args + )?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }"] + ); + } + + Ok(()) +} + +#[tokio::test] +async fn can_debug_multi_call_tx() -> Result<()> { + setup_program_test!( + Wallets("wallet"), + Abigen(Contract( + name = "MyContract", + project = "e2e/sway/types/contracts/nested_structs" + )) + ); + let contract_id = Contract::load_from( + "sway/types/contracts/nested_structs/out/release/nested_structs.bin", + Default::default(), + )? + .contract_id(); + + let call1 = MyContract::new(contract_id, wallet.clone()) + .methods() + .check_struct_integrity(AllStruct { + some_struct: SomeStruct { + field: 2, + field_2: true, + }, + }); + + let call2 = MyContract::new(contract_id, wallet.clone()) + .methods() + .i_am_called_differently( + AllStruct { + some_struct: SomeStruct { + field: 2, + field_2: true, + }, + }, + MemoryAddress { + contract_id, + function_selector: 123, + function_data: 456, + }, + ); + + let abi = std::fs::read_to_string( + "./sway/types/contracts/nested_structs/out/release/nested_structs-abi.json", + ) + .unwrap(); + let decoder = ABIFormatter::from_json_abi(&abi)?; + + // without gas forwarding + { + let first_call = call1 + .clone() + .call_params(CallParameters::default().with_amount(10)) + .unwrap(); + + let second_call = call2 + .clone() + .call_params(CallParameters::default().with_amount(20)) + .unwrap(); + + let tb = CallHandler::new_multi_call(wallet.clone()) + .add_call(first_call) + .add_call(second_call) + .transaction_builder() + .await + .unwrap(); + + let script = tb.script; + let script_data = tb.script_data; + + let ScriptType::ContractCall(call_descriptions) = + ScriptType::detect(&script, &script_data)? + else { + panic!("expected a contract call") + }; + + assert_eq!(call_descriptions.len(), 2); + + let call_description = &call_descriptions[0]; + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 10); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "check_struct_integrity" + ); + assert!(call_description.gas_forwarded.is_none()); + + assert_eq!( + decoder.decode_fn_args( + &call_description.decode_fn_selector().unwrap(), + &call_description.encoded_args + )?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }"] + ); + + let call_description = &call_descriptions[1]; + let fn_selector = call_description.decode_fn_selector().unwrap(); + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 20); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!(fn_selector, "i_am_called_differently"); + assert!(call_description.gas_forwarded.is_none()); + + assert_eq!( + decoder.decode_fn_args(&fn_selector, &call_description.encoded_args)?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }", "MemoryAddress { contract_id: std::contract_id::ContractId { bits: Bits256([77, 127, 224, 17, 182, 42, 211, 241, 46, 156, 74, 204, 31, 156, 188, 77, 183, 63, 55, 80, 119, 142, 192, 75, 130, 205, 208, 253, 25, 104, 22, 171]) }, function_selector: 123, function_data: 456 }"] + ); + } + + // with gas forwarding + { + let first_call = call1 + .clone() + .call_params( + CallParameters::default() + .with_amount(10) + .with_gas_forwarded(15), + ) + .unwrap(); + + let second_call = call2 + .clone() + .call_params( + CallParameters::default() + .with_amount(20) + .with_gas_forwarded(25), + ) + .unwrap(); + + let tb = CallHandler::new_multi_call(wallet.clone()) + .add_call(first_call) + .add_call(second_call) + .transaction_builder() + .await + .unwrap(); + + let script = tb.script; + let script_data = tb.script_data; + + let ScriptType::ContractCall(call_descriptions) = + ScriptType::detect(&script, &script_data)? + else { + panic!("expected a contract call") + }; + + assert_eq!(call_descriptions.len(), 2); + + let call_description = &call_descriptions[0]; + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 10); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "check_struct_integrity" + ); + assert_eq!(call_description.gas_forwarded, Some(15)); + + assert_eq!( + decoder.decode_fn_args( + &call_description.decode_fn_selector().unwrap(), + &call_description.encoded_args + )?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }"] + ); + + let call_description = &call_descriptions[1]; + + assert_eq!(call_description.contract_id, contract_id); + assert_eq!(call_description.amount, 20); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "i_am_called_differently" + ); + assert_eq!(call_description.gas_forwarded, Some(25)); + + assert_eq!( + decoder.decode_fn_args(&call_description.decode_fn_selector().unwrap(), &call_description.encoded_args)?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }", "MemoryAddress { contract_id: std::contract_id::ContractId { bits: Bits256([77, 127, 224, 17, 182, 42, 211, 241, 46, 156, 74, 204, 31, 156, 188, 77, 183, 63, 55, 80, 119, 142, 192, 75, 130, 205, 208, 253, 25, 104, 22, 171]) }, function_selector: 123, function_data: 456 }"] + ); + } + + Ok(()) +} + +#[tokio::test] +async fn can_debug_sway_script() -> Result<()> { + let wallet = WalletUnlocked::new_random(None); + setup_program_test!( + Abigen(Script( + name = "MyScript", + project = "e2e/sway/scripts/script_struct" + )), + LoadScript( + name = "script_instance", + script = "MyScript", + wallet = "wallet" + ) + ); + + let tb = script_instance + .main(MyStruct { + number: 10, + boolean: false, + }) + .transaction_builder() + .await + .unwrap(); + + let abi = + std::fs::read_to_string("./sway/scripts/script_struct/out/release/script_struct-abi.json")?; + + let decoder = ABIFormatter::from_json_abi(abi)?; + + let ScriptType::Other(desc) = ScriptType::detect(&tb.script, &tb.script_data).unwrap() else { + panic!("expected a script") + }; + + assert_eq!( + decoder.decode_fn_args("main", &desc.data)?, + vec!["MyStruct { number: 10, boolean: false }"] + ); + + assert_eq!( + decoder + .decode_configurables(desc.data_section().unwrap()) + .unwrap(), + vec![ + ("A_NUMBER".to_owned(), "11".to_owned()), + ( + "MY_STRUCT".to_owned(), + "MyStruct { number: 10, boolean: true }".to_owned() + ), + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn debugs_sway_script_with_no_configurables() -> Result<()> { + let wallet = WalletUnlocked::new_random(None); + setup_program_test!( + Abigen(Script( + name = "MyScript", + project = "e2e/sway/scripts/basic_script" + )), + LoadScript( + name = "script_instance", + script = "MyScript", + wallet = "wallet" + ) + ); + + let tb = script_instance + .main(10, 11) + .transaction_builder() + .await + .unwrap(); + + let abi = + std::fs::read_to_string("./sway/scripts/basic_script/out/release/basic_script-abi.json")?; + + let decoder = ABIFormatter::from_json_abi(abi)?; + + let ScriptType::Other(desc) = ScriptType::detect(&tb.script, &tb.script_data).unwrap() else { + panic!("expected a script") + }; + + assert_eq!( + decoder + .decode_configurables(desc.data_section().unwrap()) + .unwrap(), + vec![] + ); + + Ok(()) +} + +#[tokio::test] +async fn data_section_offset_not_set_if_out_of_bounds() -> Result<()> { + let mut custom_script = vec![0; 1000]; + custom_script[8..16].copy_from_slice(&u64::MAX.to_be_bytes()); + + let ScriptType::Other(desc) = ScriptType::detect(&custom_script, &[]).unwrap() else { + panic!("expected a script") + }; + + assert!(desc.data_section_offset.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn can_detect_a_loader_script_w_data_section() -> Result<()> { + setup_program_test!(Abigen(Script( + name = "MyScript", + project = "e2e/sway/scripts/script_struct" + ))); + + let script_data = ABIEncoder::default() + .encode(&[MyStruct { + number: 10, + boolean: false, + } + .into_token()]) + .unwrap(); + + let executable = + Executable::load_from("sway/scripts/script_struct/out/release/script_struct.bin") + .unwrap() + .convert_to_loader() + .unwrap(); + + let expected_blob_id = executable.blob().id(); + let script = executable.code(); + + let ScriptType::Loader { script, blob_id } = ScriptType::detect(&script, &script_data).unwrap() + else { + panic!("expected a loader script") + }; + + assert_eq!(blob_id, expected_blob_id); + + let decoder = ABIFormatter::from_json_abi(std::fs::read_to_string( + "./sway/scripts/script_struct/out/release/script_struct-abi.json", + )?)?; + + assert_eq!( + decoder.decode_fn_args("main", &script.data)?, + vec!["MyStruct { number: 10, boolean: false }"] + ); + + assert_eq!( + decoder + .decode_configurables(script.data_section().unwrap()) + .unwrap(), + vec![ + ("A_NUMBER".to_owned(), "11".to_owned()), + ( + "MY_STRUCT".to_owned(), + "MyStruct { number: 10, boolean: true }".to_owned() + ), + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn can_detect_a_loader_script_wo_data_section() -> Result<()> { + setup_program_test!(Abigen(Script( + name = "MyScript", + project = "e2e/sway/scripts/empty" + ))); + + let executable = Executable::load_from("sway/scripts/empty/out/release/empty.bin") + .unwrap() + .convert_to_loader() + .unwrap(); + + let expected_blob_id = executable.blob().id(); + let script = executable.code(); + + let ScriptType::Loader { blob_id, .. } = ScriptType::detect(&script, &[]).unwrap() else { + panic!("expected a loader script") + }; + + assert_eq!(blob_id, expected_blob_id); + + Ok(()) +} diff --git a/examples/contracts/src/lib.rs b/examples/contracts/src/lib.rs index 87c3330508..1698ae3cc8 100644 --- a/examples/contracts/src/lib.rs +++ b/examples/contracts/src/lib.rs @@ -3,9 +3,10 @@ mod tests { use std::{collections::HashSet, time::Duration}; use fuels::{ - core::codec::{encode_fn_selector, DecoderConfig, EncoderConfig}, + core::codec::{encode_fn_selector, ABIFormatter, DecoderConfig, EncoderConfig}, crypto::SecretKey, prelude::{LoadConfiguration, NodeConfig, StorageConfiguration}, + programs::debug::ScriptType, test_helpers::{ChainConfig, StateConfig}, types::{ errors::{transaction::Reason, Result}, @@ -1137,4 +1138,65 @@ mod tests { Ok(()) } + + #[tokio::test] + #[allow(unused_variables)] + async fn decoding_script_transactions() -> Result<()> { + use fuels::prelude::*; + + setup_program_test!( + Abigen(Contract( + name = "MyContract", + project = "e2e/sway/contracts/contract_test" + )), + Wallets("wallet"), + Deploy( + name = "contract_instance", + contract = "MyContract", + wallet = "wallet" + ) + ); + + let tx_id = contract_instance + .methods() + .initialize_counter(42) + .call() + .await? + .tx_id + .unwrap(); + + let provider: &Provider = wallet.try_provider()?; + + // ANCHOR: decoding_script_transactions + let TransactionType::Script(tx) = provider + .get_transaction_by_id(&tx_id) + .await? + .unwrap() + .transaction + else { + panic!("Transaction is not a script transaction"); + }; + + let ScriptType::ContractCall(calls) = ScriptType::detect(tx.script(), tx.script_data())? + else { + panic!("Script is not a contract call"); + }; + + let json_abi = std::fs::read_to_string( + "../../e2e/sway/contracts/contract_test/out/release/contract_test-abi.json", + )?; + let abi_formatter = ABIFormatter::from_json_abi(json_abi)?; + + let call = &calls[0]; + let fn_selector = call.decode_fn_selector()?; + let decoded_args = abi_formatter.decode_fn_args(&fn_selector, &call.encoded_args)?; + + eprintln!( + "The script called: {fn_selector}({})", + decoded_args.join(", ") + ); + + // ANCHOR_END: decoding_script_transactions + Ok(()) + } } diff --git a/packages/fuels-core/src/codec.rs b/packages/fuels-core/src/codec.rs index f200c70ead..af83cbf11a 100644 --- a/packages/fuels-core/src/codec.rs +++ b/packages/fuels-core/src/codec.rs @@ -1,11 +1,13 @@ mod abi_decoder; mod abi_encoder; +mod abi_formatter; mod function_selector; mod logs; mod utils; pub use abi_decoder::*; pub use abi_encoder::*; +pub use abi_formatter::*; pub use function_selector::*; pub use logs::*; diff --git a/packages/fuels-core/src/codec/abi_decoder.rs b/packages/fuels-core/src/codec/abi_decoder.rs index 8c8b05480f..6ee0740a2e 100644 --- a/packages/fuels-core/src/codec/abi_decoder.rs +++ b/packages/fuels-core/src/codec/abi_decoder.rs @@ -106,6 +106,19 @@ impl ABIDecoder { let token = BoundedDecoder::new(self.config).decode(param_type, bytes)?; decode_as_debug_str(param_type, &token) } + + pub fn decode_multiple_as_debug_str( + &self, + param_types: &[ParamType], + bytes: &[u8], + ) -> Result> { + let token = BoundedDecoder::new(self.config).decode_multiple(param_types, bytes)?; + token + .into_iter() + .zip(param_types) + .map(|(token, param_type)| decode_as_debug_str(param_type, &token)) + .collect() + } } #[cfg(test)] diff --git a/packages/fuels-core/src/codec/abi_formatter.rs b/packages/fuels-core/src/codec/abi_formatter.rs new file mode 100644 index 0000000000..42a19d3c67 --- /dev/null +++ b/packages/fuels-core/src/codec/abi_formatter.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; + +use fuel_abi_types::abi::unified_program::UnifiedProgramABI; +use itertools::Itertools; + +use crate::{error, types::param_types::ParamType, Result}; + +use super::{ABIDecoder, DecoderConfig}; + +pub struct ABIFormatter { + functions: HashMap>, + configurables: Vec<(String, ParamType)>, + decoder: ABIDecoder, +} + +impl ABIFormatter { + pub fn has_fn(&self, fn_name: &str) -> bool { + self.functions.contains_key(fn_name) + } + + pub fn with_decoder_config(mut self, config: DecoderConfig) -> Self { + self.decoder = ABIDecoder::new(config); + self + } + + pub fn from_abi(abi: UnifiedProgramABI) -> Result { + let functions = abi + .functions + .iter() + .map(|fun| (fun.name.clone(), fun.clone())) + .collect::>(); + + let type_lookup = abi + .types + .iter() + .map(|decl| (decl.type_id, decl.clone())) + .collect::>(); + + let functions = functions + .into_iter() + .map(|(name, fun)| { + let args = fun + .inputs + .iter() + .map(|type_application| { + ParamType::try_from_type_application(type_application, &type_lookup) + }) + .collect::>>()?; + Ok((name.clone(), args)) + }) + .collect::>>()?; + + let configurables = abi + .configurables + .into_iter() + .flatten() + .sorted_by_key(|c| c.offset) + .map(|c| { + let param_type = + ParamType::try_from_type_application(&c.application, &type_lookup)?; + + Ok((c.name, param_type)) + }) + .collect::>>()?; + + Ok(Self { + functions, + decoder: ABIDecoder::default(), + configurables, + }) + } + + pub fn from_json_abi(abi: impl AsRef) -> Result { + let parsed_abi = UnifiedProgramABI::from_json_abi(abi.as_ref())?; + Self::from_abi(parsed_abi) + } + + pub fn decode_fn_args(&self, fn_name: &str, data: &[u8]) -> Result> { + let args = self + .functions + .get(fn_name) + .ok_or_else(|| error!(Codec, "Function '{}' not found in the ABI", fn_name))?; + + self.decoder.decode_multiple_as_debug_str(args, data) + } + + pub fn decode_configurables(&self, configurable_data: &[u8]) -> Result> { + let param_types = self + .configurables + .iter() + .map(|(_, param_type)| param_type) + .cloned() + .collect::>(); + + let decoded = self + .decoder + .decode_multiple_as_debug_str(¶m_types, configurable_data)? + .into_iter() + .zip(&self.configurables) + .map(|(value, (name, _))| (name.clone(), value)) + .collect(); + + Ok(decoded) + } +} + +#[cfg(test)] +mod tests { + use crate::types::errors::Error; + + use super::*; + + #[test] + fn gracefully_handles_missing_fn() { + // given + let decoder = ABIFormatter::from_abi(UnifiedProgramABI::default()).unwrap(); + + // when + let err = decoder.decode_fn_args("non_existent_fn", &[]).unwrap_err(); + + // then + let Error::Codec(err) = err else { + panic!("Expected Codec error, got {:?}", err); + }; + + assert_eq!(err, "Function 'non_existent_fn' not found in the ABI"); + } +} diff --git a/packages/fuels-programs/Cargo.toml b/packages/fuels-programs/Cargo.toml index 3799f8b085..cbea44bb68 100644 --- a/packages/fuels-programs/Cargo.toml +++ b/packages/fuels-programs/Cargo.toml @@ -23,6 +23,7 @@ serde_json = { workspace = true } tokio = { workspace = true } [dev-dependencies] +test-case = { workspace = true } tempfile = "3.8.1" [features] diff --git a/packages/fuels-programs/src/assembly.rs b/packages/fuels-programs/src/assembly.rs new file mode 100644 index 0000000000..d31c9444e8 --- /dev/null +++ b/packages/fuels-programs/src/assembly.rs @@ -0,0 +1,3 @@ +pub mod contract_call; +pub mod cursor; +pub mod script_and_predicate_loader; diff --git a/packages/fuels-programs/src/assembly/contract_call.rs b/packages/fuels-programs/src/assembly/contract_call.rs new file mode 100644 index 0000000000..9611907f95 --- /dev/null +++ b/packages/fuels-programs/src/assembly/contract_call.rs @@ -0,0 +1,329 @@ +use fuel_asm::{op, Instruction, RegId, Word}; +use fuel_tx::{AssetId, ContractId}; +use fuels_core::{constants::WORD_SIZE, error, types::errors::Result}; + +use super::cursor::WasmFriendlyCursor; +pub struct ContractCallInstructions { + instructions: Vec, + gas_fwd: bool, +} + +impl IntoIterator for ContractCallInstructions { + type Item = Instruction; + type IntoIter = std::vec::IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.instructions.into_iter() + } +} + +impl ContractCallInstructions { + pub fn new(opcode_params: CallOpcodeParamsOffset) -> Self { + Self { + gas_fwd: opcode_params.gas_forwarded_offset.is_some(), + instructions: Self::generate_instructions(opcode_params), + } + } + + pub fn into_bytes(self) -> impl Iterator { + self.instructions + .into_iter() + .flat_map(|instruction| instruction.to_bytes()) + } + + /// Returns the VM instructions for calling a contract method + /// We use the [`Opcode`] to call a contract: [`CALL`](Opcode::CALL) + /// pointing at the following registers: + /// + /// 0x10 Script data offset + /// 0x11 Coin amount + /// 0x12 Asset ID + /// 0x13 Gas forwarded + /// + /// Note that these are soft rules as we're picking this addresses simply because they + /// non-reserved register. + fn generate_instructions(offsets: CallOpcodeParamsOffset) -> Vec { + let call_data_offset = offsets + .call_data_offset + .try_into() + .expect("call_data_offset out of range"); + let amount_offset = offsets + .amount_offset + .try_into() + .expect("amount_offset out of range"); + let asset_id_offset = offsets + .asset_id_offset + .try_into() + .expect("asset_id_offset out of range"); + + let mut instructions = [ + op::movi(0x10, call_data_offset), + op::movi(0x11, amount_offset), + op::lw(0x11, 0x11, 0), + op::movi(0x12, asset_id_offset), + ] + .to_vec(); + + match offsets.gas_forwarded_offset { + Some(gas_forwarded_offset) => { + let gas_forwarded_offset = gas_forwarded_offset + .try_into() + .expect("gas_forwarded_offset out of range"); + + instructions.extend(&[ + op::movi(0x13, gas_forwarded_offset), + op::lw(0x13, 0x13, 0), + op::call(0x10, 0x11, 0x12, 0x13), + ]); + } + // if `gas_forwarded` was not set use `REG_CGAS` + None => instructions.push(op::call(0x10, 0x11, 0x12, RegId::CGAS)), + }; + + instructions + } + + fn extract_normal_variant(instructions: &[Instruction]) -> Option<&[Instruction]> { + let normal_instructions = Self::generate_instructions(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: None, + }); + Self::extract_if_match(instructions, &normal_instructions) + } + + fn extract_gas_fwd_variant(instructions: &[Instruction]) -> Option<&[Instruction]> { + let gas_fwd_instructions = Self::generate_instructions(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(0), + }); + Self::extract_if_match(instructions, &gas_fwd_instructions) + } + + pub fn extract_from(instructions: &[Instruction]) -> Option { + if let Some(instructions) = Self::extract_normal_variant(instructions) { + return Some(Self { + instructions: instructions.to_vec(), + gas_fwd: false, + }); + } + + Self::extract_gas_fwd_variant(instructions).map(|instructions| Self { + instructions: instructions.to_vec(), + gas_fwd: true, + }) + } + + pub fn len(&self) -> usize { + self.instructions.len() + } + + pub fn call_data_offset(&self) -> u32 { + let Instruction::MOVI(movi) = self.instructions[0] else { + panic!("should have validated the first instruction is a MOVI"); + }; + + movi.imm18().into() + } + + pub fn is_gas_fwd_variant(&self) -> bool { + self.gas_fwd + } + + fn extract_if_match<'a>( + unknown: &'a [Instruction], + correct: &[Instruction], + ) -> Option<&'a [Instruction]> { + if unknown.len() < correct.len() { + return None; + } + + unknown + .iter() + .zip(correct) + .all(|(expected, actual)| expected.opcode() == actual.opcode()) + .then(|| &unknown[..correct.len()]) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractCallData { + pub amount: u64, + pub asset_id: AssetId, + pub contract_id: ContractId, + pub fn_selector_encoded: Vec, + pub encoded_args: Vec, + pub gas_forwarded: Option, +} + +impl ContractCallData { + pub fn decode_fn_selector(&self) -> Result { + String::from_utf8(self.fn_selector_encoded.clone()) + .map_err(|e| error!(Codec, "cannot decode function selector: {}", e)) + } + + /// Encodes as script data, consisting of the following items in the given order: + /// 1. Amount to be forwarded `(1 * `[`WORD_SIZE`]`)` + /// 2. Asset ID to be forwarded ([`AssetId::LEN`]) + /// 3. Contract ID ([`ContractId::LEN`]); + /// 4. Function selector offset `(1 * `[`WORD_SIZE`]`)` + /// 5. Calldata offset `(1 * `[`WORD_SIZE`]`)` + /// 6. Encoded function selector - method name + /// 7. Encoded arguments + /// 8. Gas to be forwarded `(1 * `[`WORD_SIZE`]`)` - Optional + pub fn encode(&self, memory_offset: usize, buffer: &mut Vec) -> CallOpcodeParamsOffset { + let amount_offset = memory_offset; + let asset_id_offset = amount_offset + WORD_SIZE; + let call_data_offset = asset_id_offset + AssetId::LEN; + let encoded_selector_offset = call_data_offset + ContractId::LEN + 2 * WORD_SIZE; + let encoded_args_offset = encoded_selector_offset + self.fn_selector_encoded.len(); + + buffer.extend(self.amount.to_be_bytes()); // 1. Amount + + let asset_id = self.asset_id; + buffer.extend(asset_id.iter()); // 2. Asset ID + + buffer.extend(self.contract_id.as_ref()); // 3. Contract ID + + buffer.extend((encoded_selector_offset as Word).to_be_bytes()); // 4. Fun. selector offset + + buffer.extend((encoded_args_offset as Word).to_be_bytes()); // 5. Calldata offset + + buffer.extend(&self.fn_selector_encoded); // 6. Encoded function selector + + let encoded_args_len = self.encoded_args.len(); + + buffer.extend(&self.encoded_args); // 7. Encoded arguments + + let gas_forwarded_offset = self.gas_forwarded.map(|gf| { + buffer.extend((gf as Word).to_be_bytes()); // 8. Gas to be forwarded - Optional + + encoded_args_offset + encoded_args_len + }); + + CallOpcodeParamsOffset { + amount_offset, + asset_id_offset, + gas_forwarded_offset, + call_data_offset, + } + } + + pub fn decode(data: &[u8], gas_fwd: bool) -> Result { + let mut data = WasmFriendlyCursor::new(data); + + let amount = u64::from_be_bytes(data.consume_fixed("amount")?); + + let asset_id = AssetId::new(data.consume_fixed("asset id")?); + + let contract_id = ContractId::new(data.consume_fixed("contract id")?); + + let _ = data.consume(8, "function selector offset")?; + + let _ = data.consume(8, "encoded args offset")?; + + let fn_selector = { + let fn_selector_len = { + let bytes = data.consume_fixed("function selector length")?; + u64::from_be_bytes(bytes) as usize + }; + data.consume(fn_selector_len, "function selector")?.to_vec() + }; + + let (encoded_args, gas_forwarded) = if gas_fwd { + let encoded_args = data + .consume(data.unconsumed().saturating_sub(WORD_SIZE), "encoded_args")? + .to_vec(); + + let gas_fwd = { u64::from_be_bytes(data.consume_fixed("forwarded gas")?) }; + + (encoded_args, Some(gas_fwd)) + } else { + (data.consume_all().to_vec(), None) + }; + + Ok(ContractCallData { + amount, + asset_id, + contract_id, + fn_selector_encoded: fn_selector, + encoded_args, + gas_forwarded, + }) + } +} + +#[derive(Default)] +/// Specifies offsets of [`Opcode::CALL`][`fuel_asm::Opcode::CALL`] parameters stored in the script +/// data from which they can be loaded into registers +pub struct CallOpcodeParamsOffset { + pub call_data_offset: usize, + pub amount_offset: usize, + pub asset_id_offset: usize, + pub gas_forwarded_offset: Option, +} + +// Creates a contract that loads the specified blobs into memory and delegates the call to the code contained in the blobs. +pub fn loader_contract_asm(blob_ids: &[[u8; 32]]) -> Result> { + const BLOB_ID_SIZE: u16 = 32; + let get_instructions = |num_of_instructions, num_of_blobs| { + // There are 2 main steps: + // 1. Load the blob contents into memory + // 2. Jump to the beginning of the memory where the blobs were loaded + // After that the execution continues normally with the loaded contract reading our + // prepared fn selector and jumps to the selected contract method. + [ + // 1. Load the blob contents into memory + // Find the start of the hardcoded blob IDs, which are located after the code ends. + op::move_(0x10, RegId::PC), + // 0x10 to hold the address of the current blob ID. + op::addi(0x10, 0x10, num_of_instructions * Instruction::SIZE as u16), + // The contract is going to be loaded from the current value of SP onwards, save + // the location into 0x16 so we can jump into it later on. + op::move_(0x16, RegId::SP), + // Loop counter. + op::movi(0x13, num_of_blobs), + // LOOP starts here. + // 0x11 to hold the size of the current blob. + op::bsiz(0x11, 0x10), + // Push the blob contents onto the stack. + op::ldc(0x10, 0, 0x11, 1), + // Move on to the next blob. + op::addi(0x10, 0x10, BLOB_ID_SIZE), + // Decrement the loop counter. + op::subi(0x13, 0x13, 1), + // Jump backwards (3+1) instructions if the counter has not reached 0. + op::jnzb(0x13, RegId::ZERO, 3), + // 2. Jump into the memory where the contract is loaded. + // What follows is called _jmp_mem by the sway compiler. + // Subtract the address contained in IS because jmp will add it back. + op::sub(0x16, 0x16, RegId::IS), + // jmp will multiply by 4, so we need to divide to cancel that out. + op::divi(0x16, 0x16, 4), + // Jump to the start of the contract we loaded. + op::jmp(0x16), + ] + }; + + let num_of_instructions = u16::try_from(get_instructions(0, 0).len()) + .expect("to never have more than u16::MAX instructions"); + + let num_of_blobs = u32::try_from(blob_ids.len()).map_err(|_| { + error!( + Other, + "the number of blobs ({}) exceeds the maximum number of blobs supported: {}", + blob_ids.len(), + u32::MAX + ) + })?; + + let instruction_bytes = get_instructions(num_of_instructions, num_of_blobs) + .into_iter() + .flat_map(|instruction| instruction.to_bytes()); + + let blob_bytes = blob_ids.iter().flatten().copied(); + + Ok(instruction_bytes.chain(blob_bytes).collect()) +} diff --git a/packages/fuels-programs/src/assembly/cursor.rs b/packages/fuels-programs/src/assembly/cursor.rs new file mode 100644 index 0000000000..327e9697ca --- /dev/null +++ b/packages/fuels-programs/src/assembly/cursor.rs @@ -0,0 +1,50 @@ +use fuels_core::{error, types::errors::Result}; + +pub struct WasmFriendlyCursor<'a> { + data: &'a [u8], +} + +impl<'a> WasmFriendlyCursor<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data } + } + + pub fn consume(&mut self, amount: usize, ctx: &'static str) -> Result<&'a [u8]> { + if self.data.len() < amount { + Err(error!( + Other, + "while decoding {ctx}: not enough data, available: {}, requested: {}", + self.data.len(), + amount + )) + } else { + let data = &self.data[..amount]; + self.data = &self.data[amount..]; + Ok(data) + } + } + + pub fn consume_fixed( + &mut self, + ctx: &'static str, + ) -> Result<[u8; AMOUNT]> { + let data = self + .consume(AMOUNT, ctx)? + .try_into() + .expect("should have failed if not enough data"); + + Ok(data) + } + + pub fn consume_all(&mut self) -> &'a [u8] { + let data = self.data; + + self.data = &[]; + + data + } + + pub fn unconsumed(&self) -> usize { + self.data.len() + } +} diff --git a/packages/fuels-programs/src/assembly/script_and_predicate_loader.rs b/packages/fuels-programs/src/assembly/script_and_predicate_loader.rs new file mode 100644 index 0000000000..7909c3c4ae --- /dev/null +++ b/packages/fuels-programs/src/assembly/script_and_predicate_loader.rs @@ -0,0 +1,301 @@ +use fuel_asm::{op, Instruction, RegId}; +use fuels_core::{constants::WORD_SIZE, types::errors::Result}; +use itertools::Itertools; + +use crate::assembly::cursor::WasmFriendlyCursor; + +pub struct LoaderCode { + blob_id: [u8; 32], + code: Vec, + data_offset: usize, +} + +impl LoaderCode { + // std gated because of Blob usage which is in transaction_builders which are currently not + // nostd friendly + #[cfg(feature = "std")] + pub fn from_normal_binary(binary: Vec) -> Result { + let (original_code, data_section) = split_at_data_offset(&binary)?; + + let blob_id = + fuels_core::types::transaction_builders::Blob::from(original_code.to_vec()).id(); + let (loader_code, data_offset) = Self::generate_loader_code(blob_id, data_section); + + Ok(Self { + blob_id, + code: loader_code, + data_offset, + }) + } + + pub fn from_loader_binary(binary: &[u8]) -> Result> { + if let Some((blob_id, data_section_offset)) = extract_blob_id_and_data_offset(binary)? { + Ok(Some(Self { + data_offset: data_section_offset, + code: binary.to_vec(), + blob_id, + })) + } else { + Ok(None) + } + } + + #[cfg(feature = "std")] + pub fn extract_blob(binary: &[u8]) -> Result { + let (code, _) = split_at_data_offset(binary)?; + Ok(code.to_vec().into()) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.code + } + + pub fn data_section_offset(&self) -> usize { + self.data_offset + } + + fn generate_loader_code(blob_id: [u8; 32], data_section: &[u8]) -> (Vec, usize) { + if !data_section.is_empty() { + generate_loader_w_data_section(blob_id, data_section) + } else { + generate_loader_wo_data_section(blob_id) + } + } + + pub fn blob_id(&self) -> [u8; 32] { + self.blob_id + } +} + +fn extract_blob_id_and_data_offset(binary: &[u8]) -> Result> { + let (has_data_section, mut cursor) = + if let Some(cursor) = consume_instructions(binary, &loader_instructions_w_data_section()) { + (true, cursor) + } else if let Some(cursor) = + consume_instructions(binary, &loader_instructions_no_data_section()) + { + (false, cursor) + } else { + return Ok(None); + }; + + let blob_id = cursor.consume_fixed("blob id")?; + if has_data_section { + let _data_section_len = cursor.consume(WORD_SIZE, "data section len")?; + } + + let data_section_offset = binary + .len() + .checked_sub(cursor.unconsumed()) + .expect("must be less or eq"); + + Ok(Some((blob_id, data_section_offset))) +} + +fn consume_instructions<'a>( + binary: &'a [u8], + expected_instructions: &[Instruction], +) -> Option> { + let loader_instructions_byte_size = expected_instructions.len() * Instruction::SIZE; + + let mut script_cursor = WasmFriendlyCursor::new(binary); + let instruction_bytes = script_cursor + .consume(loader_instructions_byte_size, "loader instructions") + .ok()?; + + let instructions = fuel_asm::from_bytes(instruction_bytes.to_vec()) + .collect::, _>>() + .ok()?; + + instructions + .iter() + .zip(expected_instructions.iter()) + .all(|(actual, expected)| actual == expected) + .then_some(script_cursor) +} + +fn generate_loader_wo_data_section(blob_id: [u8; 32]) -> (Vec, usize) { + let instruction_bytes = loader_instructions_no_data_section() + .into_iter() + .flat_map(|instruction| instruction.to_bytes()); + + let code = instruction_bytes + .chain(blob_id.iter().copied()) + .collect_vec(); + // there is no data section, so we point the offset to the end of the file + let new_data_section_offset = code.len(); + + (code, new_data_section_offset) +} + +fn generate_loader_w_data_section(blob_id: [u8; 32], data_section: &[u8]) -> (Vec, usize) { + // The final code is going to have this structure: + // 1. loader instructions + // 2. blob id + // 3. length_of_data_section + // 4. the data_section (updated with configurables as needed) + + let instruction_bytes = loader_instructions_w_data_section() + .into_iter() + .flat_map(|instruction| instruction.to_bytes()) + .collect_vec(); + + let blob_bytes = blob_id.iter().copied().collect_vec(); + + let original_data_section_len_encoded = u64::try_from(data_section.len()) + .expect("data section to be less than u64::MAX") + .to_be_bytes(); + + // The data section is placed after all of the instructions, the BlobId, and the number representing + // how big the data section is. + let new_data_section_offset = + instruction_bytes.len() + blob_bytes.len() + original_data_section_len_encoded.len(); + + let code = instruction_bytes + .into_iter() + .chain(blob_bytes) + .chain(original_data_section_len_encoded) + .chain(data_section.to_vec()) + .collect(); + + (code, new_data_section_offset) +} + +fn loader_instructions_no_data_section() -> [Instruction; 8] { + const REG_ADDRESS_OF_DATA_AFTER_CODE: u8 = 0x10; + const REG_START_OF_LOADED_CODE: u8 = 0x11; + const REG_GENERAL_USE: u8 = 0x12; + + const NUM_OF_INSTRUCTIONS: u16 = 8; + + // There are 2 main steps: + // 1. Load the blob content into memory + // 2. Jump to the beginning of the memory where the blob was loaded + let instructions = [ + // 1. Load the blob content into memory + // Find the start of the hardcoded blob ID, which is located after the loader code ends. + op::move_(REG_ADDRESS_OF_DATA_AFTER_CODE, RegId::PC), + // hold the address of the blob ID. + op::addi( + REG_ADDRESS_OF_DATA_AFTER_CODE, + REG_ADDRESS_OF_DATA_AFTER_CODE, + NUM_OF_INSTRUCTIONS * Instruction::SIZE as u16, + ), + // The code is going to be loaded from the current value of SP onwards, save + // the location into REG_START_OF_LOADED_CODE so we can jump into it at the end. + op::move_(REG_START_OF_LOADED_CODE, RegId::SP), + // REG_GENERAL_USE to hold the size of the blob. + op::bsiz(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE), + // Push the blob contents onto the stack. + op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 1), + // Jump into the memory where the contract is loaded. + // What follows is called _jmp_mem by the sway compiler. + // Subtract the address contained in IS because jmp will add it back. + op::sub( + REG_START_OF_LOADED_CODE, + REG_START_OF_LOADED_CODE, + RegId::IS, + ), + // jmp will multiply by 4, so we need to divide to cancel that out. + op::divi(REG_START_OF_LOADED_CODE, REG_START_OF_LOADED_CODE, 4), + // Jump to the start of the contract we loaded. + op::jmp(REG_START_OF_LOADED_CODE), + ]; + + debug_assert_eq!(instructions.len(), NUM_OF_INSTRUCTIONS as usize); + + instructions +} + +pub fn loader_instructions_w_data_section() -> [Instruction; 12] { + const BLOB_ID_SIZE: u16 = 32; + const REG_ADDRESS_OF_DATA_AFTER_CODE: u8 = 0x10; + const REG_START_OF_LOADED_CODE: u8 = 0x11; + const REG_GENERAL_USE: u8 = 0x12; + + // extract the length of the NoDataSectionLoaderInstructions type + const NUM_OF_INSTRUCTIONS: u16 = 12; + + // There are 3 main steps: + // 1. Load the blob content into memory + // 2. Load the data section right after the blob + // 3. Jump to the beginning of the memory where the blob was loaded + let instructions = [ + // 1. Load the blob content into memory + // Find the start of the hardcoded blob ID, which is located after the loader code ends. + op::move_(REG_ADDRESS_OF_DATA_AFTER_CODE, RegId::PC), + // hold the address of the blob ID. + op::addi( + REG_ADDRESS_OF_DATA_AFTER_CODE, + REG_ADDRESS_OF_DATA_AFTER_CODE, + NUM_OF_INSTRUCTIONS * Instruction::SIZE as u16, + ), + // The code is going to be loaded from the current value of SP onwards, save + // the location into REG_START_OF_LOADED_CODE so we can jump into it at the end. + op::move_(REG_START_OF_LOADED_CODE, RegId::SP), + // REG_GENERAL_USE to hold the size of the blob. + op::bsiz(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE), + // Push the blob contents onto the stack. + op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 1), + // Move on to the data section length + op::addi( + REG_ADDRESS_OF_DATA_AFTER_CODE, + REG_ADDRESS_OF_DATA_AFTER_CODE, + BLOB_ID_SIZE, + ), + // load the size of the data section into REG_GENERAL_USE + op::lw(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE, 0), + // after we have read the length of the data section, we move the pointer to the actual + // data by skipping WORD_SIZE B. + op::addi( + REG_ADDRESS_OF_DATA_AFTER_CODE, + REG_ADDRESS_OF_DATA_AFTER_CODE, + WORD_SIZE as u16, + ), + // load the data section of the executable + op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 2), + // Jump into the memory where the contract is loaded. + // What follows is called _jmp_mem by the sway compiler. + // Subtract the address contained in IS because jmp will add it back. + op::sub( + REG_START_OF_LOADED_CODE, + REG_START_OF_LOADED_CODE, + RegId::IS, + ), + // jmp will multiply by 4, so we need to divide to cancel that out. + op::divi(REG_START_OF_LOADED_CODE, REG_START_OF_LOADED_CODE, 4), + // Jump to the start of the contract we loaded. + op::jmp(REG_START_OF_LOADED_CODE), + ]; + + debug_assert_eq!(instructions.len(), NUM_OF_INSTRUCTIONS as usize); + + instructions +} + +pub fn extract_data_offset(binary: &[u8]) -> Result { + if binary.len() < 16 { + return Err(fuels_core::error!( + Other, + "given binary is too short to contain a data offset, len: {}", + binary.len() + )); + } + + let data_offset: [u8; 8] = binary[8..16].try_into().expect("checked above"); + + Ok(u64::from_be_bytes(data_offset) as usize) +} + +pub fn split_at_data_offset(binary: &[u8]) -> Result<(&[u8], &[u8])> { + let offset = extract_data_offset(binary)?; + if binary.len() < offset { + return Err(fuels_core::error!( + Other, + "data section offset is out of bounds, offset: {offset}, binary len: {}", + binary.len() + )); + } + + Ok(binary.split_at(offset)) +} diff --git a/packages/fuels-programs/src/calls/call_handler.rs b/packages/fuels-programs/src/calls/call_handler.rs index 637f41fd05..dcc7a4edb9 100644 --- a/packages/fuels-programs/src/calls/call_handler.rs +++ b/packages/fuels-programs/src/calls/call_handler.rs @@ -1,4 +1,4 @@ -use std::{fmt::Debug, marker::PhantomData}; +use core::{fmt::Debug, marker::PhantomData}; use fuel_tx::{AssetId, Bytes32, Receipt}; use fuels_accounts::{provider::TransactionCost, Account}; diff --git a/packages/fuels-programs/src/calls/contract_call.rs b/packages/fuels-programs/src/calls/contract_call.rs index 70f7ce38f8..f307d988d9 100644 --- a/packages/fuels-programs/src/calls/contract_call.rs +++ b/packages/fuels-programs/src/calls/contract_call.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, fmt::Debug}; use fuel_tx::AssetId; use fuels_core::{ constants::DEFAULT_CALL_PARAMS_AMOUNT, + error, types::{ bech32::{Bech32Address, Bech32ContractId}, errors::Result, @@ -11,7 +12,7 @@ use fuels_core::{ }, }; -use crate::calls::utils::sealed; +use crate::{assembly::contract_call::ContractCallData, calls::utils::sealed}; #[derive(Debug, Clone)] /// Contains all data relevant to a single contract call @@ -27,6 +28,23 @@ pub struct ContractCall { } impl ContractCall { + pub(crate) fn data(&self, base_asset_id: AssetId) -> Result { + let encoded_args = self + .encoded_args + .as_ref() + .map_err(|e| error!(Codec, "cannot encode contract call arguments: {e}"))? + .to_owned(); + + Ok(ContractCallData { + amount: self.call_parameters.amount(), + asset_id: self.call_parameters.asset_id().unwrap_or(base_asset_id), + contract_id: self.contract_id.clone().into(), + fn_selector_encoded: self.encoded_selector.clone(), + encoded_args, + gas_forwarded: self.call_parameters.gas_forwarded, + }) + } + pub fn with_contract_id(self, contract_id: Bech32ContractId) -> Self { ContractCall { contract_id, diff --git a/packages/fuels-programs/src/calls/utils.rs b/packages/fuels-programs/src/calls/utils.rs index bd75ad73d5..e071506a24 100644 --- a/packages/fuels-programs/src/calls/utils.rs +++ b/packages/fuels-programs/src/calls/utils.rs @@ -3,17 +3,13 @@ use std::{collections::HashSet, iter, vec}; use fuel_abi_types::error_codes::FAILED_TRANSFER_TO_ADDRESS_SIGNAL; use fuel_asm::{op, RegId}; use fuel_tx::{AssetId, Bytes32, ContractId, Output, PanicReason, Receipt, TxPointer, UtxoId}; -use fuel_types::Word; use fuels_accounts::Account; use fuels_core::{ - constants::WORD_SIZE, - error, offsets::call_script_data_offset, types::{ bech32::{Bech32Address, Bech32ContractId}, errors::Result, input::Input, - param_types::ParamType, transaction::{ScriptTransaction, TxPolicies}, transaction_builders::{ BuildableTransaction, ScriptTransactionBuilder, TransactionBuilder, @@ -23,17 +19,10 @@ use fuels_core::{ }; use itertools::{chain, Itertools}; -use crate::calls::ContractCall; - -#[derive(Default)] -/// Specifies offsets of [`Opcode::CALL`][`fuel_asm::Opcode::CALL`] parameters stored in the script -/// data from which they can be loaded into registers -pub(crate) struct CallOpcodeParamsOffset { - pub call_data_offset: usize, - pub amount_offset: usize, - pub asset_id_offset: usize, - pub gas_forwarded_offset: Option, -} +use crate::{ + assembly::contract_call::{CallOpcodeParamsOffset, ContractCallInstructions}, + calls::ContractCall, +}; pub(crate) mod sealed { pub trait Sealed {} @@ -46,14 +35,14 @@ pub(crate) async fn transaction_builder_from_contract_calls( variable_outputs: VariableOutputPolicy, account: &impl Account, ) -> Result { - let calls_instructions_len = compute_calls_instructions_len(calls)?; + let calls_instructions_len = compute_calls_instructions_len(calls); let provider = account.try_provider()?; let consensus_parameters = provider.consensus_parameters(); let data_offset = call_script_data_offset(consensus_parameters, calls_instructions_len)?; let (script_data, call_param_offsets) = build_script_data_from_contract_calls(calls, data_offset, *provider.base_asset_id())?; - let script = get_instructions(calls, call_param_offsets)?; + let script = get_instructions(call_param_offsets); let required_asset_amounts = calculate_required_asset_amounts(calls, *provider.base_asset_id()); @@ -114,24 +103,23 @@ pub(crate) async fn build_tx_from_contract_calls( /// Compute the length of the calling scripts for the two types of contract calls: those that return /// a heap type, and those that don't. -fn compute_calls_instructions_len(calls: &[ContractCall]) -> Result { +fn compute_calls_instructions_len(calls: &[ContractCall]) -> usize { calls .iter() .map(|c| { // Use placeholder for `call_param_offsets` and `output_param_type`, because the length of // the calling script doesn't depend on the underlying type, just on whether or not - // gas was forwarded or contract call output type is a heap type. - - let mut call_opcode_params = CallOpcodeParamsOffset::default(); - - if c.call_parameters.gas_forwarded().is_some() { - call_opcode_params.gas_forwarded_offset = Some(0); - } + // gas was forwarded. + let call_opcode_params = CallOpcodeParamsOffset { + gas_forwarded_offset: c.call_parameters.gas_forwarded().map(|_| 0), + ..CallOpcodeParamsOffset::default() + }; - get_single_call_instructions(&call_opcode_params, &c.output_param) - .map(|instructions| instructions.len()) + ContractCallInstructions::new(call_opcode_params) + .into_bytes() + .count() }) - .process_results(|c| c.sum()) + .sum() } /// Compute how much of each asset is required based on all `CallParameters` of the `ContractCalls` @@ -183,139 +171,31 @@ fn sum_up_amounts_for_each_asset_id( } /// Given a list of contract calls, create the actual opcodes used to call the contract -pub(crate) fn get_instructions( - calls: &[ContractCall], - offsets: Vec, -) -> Result> { - calls - .iter() - .zip(&offsets) - .map(|(call, offset)| get_single_call_instructions(offset, &call.output_param)) - .process_results(|iter| iter.flatten().collect::>()) - .map(|mut bytes| { - bytes.extend(op::ret(RegId::ONE).to_bytes()); - bytes - }) +pub(crate) fn get_instructions(offsets: Vec) -> Vec { + offsets + .into_iter() + .flat_map(|offset| ContractCallInstructions::new(offset).into_bytes()) + .chain(op::ret(RegId::ONE).to_bytes()) + .collect() } -/// Returns script data, consisting of the following items in the given order: -/// 1. Amount to be forwarded `(1 * `[`WORD_SIZE`]`)` -/// 2. Asset ID to be forwarded ([`AssetId::LEN`]) -/// 3. Contract ID ([`ContractId::LEN`]); -/// 4. Function selector offset `(1 * `[`WORD_SIZE`]`)` -/// 5. Calldata offset `(1 * `[`WORD_SIZE`]`)` -/// 6. Encoded function selector - method name -/// 7. Encoded arguments -/// 8. Gas to be forwarded `(1 * `[`WORD_SIZE`]`)` - Optional pub(crate) fn build_script_data_from_contract_calls( calls: &[ContractCall], data_offset: usize, base_asset_id: AssetId, ) -> Result<(Vec, Vec)> { - let mut script_data = vec![]; - let mut param_offsets = vec![]; - - // The data for each call is ordered into segments - let mut segment_offset = data_offset; - - for call in calls { - let amount_offset = segment_offset; - let asset_id_offset = amount_offset + WORD_SIZE; - let call_data_offset = asset_id_offset + AssetId::LEN; - let encoded_selector_offset = call_data_offset + ContractId::LEN + 2 * WORD_SIZE; - let encoded_args_offset = encoded_selector_offset + call.encoded_selector.len(); - - script_data.extend(call.call_parameters.amount().to_be_bytes()); // 1. Amount - let asset_id = call.call_parameters.asset_id().unwrap_or(base_asset_id); - script_data.extend(asset_id.iter()); // 2. Asset ID - script_data.extend(call.contract_id.hash().as_ref()); // 3. Contract ID - script_data.extend((encoded_selector_offset as Word).to_be_bytes()); // 4. Fun. selector offset - script_data.extend((encoded_args_offset as Word).to_be_bytes()); // 5. Calldata offset - script_data.extend(call.encoded_selector.clone()); // 6. Encoded function selector - - let encoded_args = call - .encoded_args - .as_ref() - .map_err(|e| error!(Codec, "cannot encode contract call arguments: {e}"))?; - let encoded_args_len = encoded_args.len(); - - script_data.extend(encoded_args); // 7. Encoded arguments - - let gas_forwarded_offset = call.call_parameters.gas_forwarded().map(|gf| { - script_data.extend((gf as Word).to_be_bytes()); // 8. Gas to be forwarded - Optional - - encoded_args_offset + encoded_args_len - }); - - param_offsets.push(CallOpcodeParamsOffset { - amount_offset, - asset_id_offset, - gas_forwarded_offset, - call_data_offset, - }); - - // the data segment that holds the parameters for the next call - // begins at the original offset + the data we added so far - segment_offset = data_offset + script_data.len(); - } - - Ok((script_data, param_offsets)) -} - -/// Returns the VM instructions for calling a contract method -/// We use the [`Opcode`] to call a contract: [`CALL`](Opcode::CALL) -/// pointing at the following registers: -/// -/// 0x10 Script data offset -/// 0x11 Coin amount -/// 0x12 Asset ID -/// 0x13 Gas forwarded -/// -/// Note that these are soft rules as we're picking this addresses simply because they -/// non-reserved register. -pub(crate) fn get_single_call_instructions( - offsets: &CallOpcodeParamsOffset, - _output_param_type: &ParamType, -) -> Result> { - let call_data_offset = offsets - .call_data_offset - .try_into() - .expect("call_data_offset out of range"); - let amount_offset = offsets - .amount_offset - .try_into() - .expect("amount_offset out of range"); - let asset_id_offset = offsets - .asset_id_offset - .try_into() - .expect("asset_id_offset out of range"); - - let mut instructions = [ - op::movi(0x10, call_data_offset), - op::movi(0x11, amount_offset), - op::lw(0x11, 0x11, 0), - op::movi(0x12, asset_id_offset), - ] - .to_vec(); - - match offsets.gas_forwarded_offset { - Some(gas_forwarded_offset) => { - let gas_forwarded_offset = gas_forwarded_offset - .try_into() - .expect("gas_forwarded_offset out of range"); - - instructions.extend(&[ - op::movi(0x13, gas_forwarded_offset), - op::lw(0x13, 0x13, 0), - op::call(0x10, 0x11, 0x12, 0x13), - ]); - } - // if `gas_forwarded` was not set use `REG_CGAS` - None => instructions.push(op::call(0x10, 0x11, 0x12, RegId::CGAS)), - }; - - #[allow(clippy::iter_cloned_collect)] - Ok(instructions.into_iter().collect::>()) + calls.iter().try_fold( + (vec![], vec![]), + |(mut script_data, mut param_offsets), call| { + let segment_offset = data_offset + script_data.len(); + let offset = call + .data(base_asset_id)? + .encode(segment_offset, &mut script_data); + + param_offsets.push(offset); + Ok((script_data, param_offsets)) + }, + ) } /// Returns the assets and contracts that will be consumed ([`Input`]s) @@ -453,6 +333,7 @@ mod test { use fuels_core::types::{ coin::{Coin, CoinStatus}, coin_type::CoinType, + param_types::ParamType, }; use rand::Rng; @@ -705,7 +586,7 @@ mod test { #[test] fn test_simple() { let call = new_contract_call_with_random_id(); - let instructions_len = compute_calls_instructions_len(&[call]).unwrap(); + let instructions_len = compute_calls_instructions_len(&[call]); assert_eq!(instructions_len, Instruction::SIZE * BASE_INSTRUCTION_COUNT); } @@ -713,7 +594,7 @@ mod test { fn test_with_gas_offset() { let mut call = new_contract_call_with_random_id(); call.call_parameters = call.call_parameters.with_gas_forwarded(0); - let instructions_len = compute_calls_instructions_len(&[call]).unwrap(); + let instructions_len = compute_calls_instructions_len(&[call]); assert_eq!( instructions_len, Instruction::SIZE * (BASE_INSTRUCTION_COUNT + GAS_OFFSET_INSTRUCTION_COUNT) @@ -732,7 +613,7 @@ mod test { .unwrap(), generics: Vec::new(), }; - let instructions_len = compute_calls_instructions_len(&[call]).unwrap(); + let instructions_len = compute_calls_instructions_len(&[call]); assert_eq!( instructions_len, // no extra instructions if there are no heap type variants diff --git a/packages/fuels-programs/src/contract.rs b/packages/fuels-programs/src/contract.rs index f6bc092680..ccc163305b 100644 --- a/packages/fuels-programs/src/contract.rs +++ b/packages/fuels-programs/src/contract.rs @@ -41,6 +41,8 @@ mod regular; pub use regular::*; mod loader; +// reexported to avoid doing a breaking change +pub use crate::assembly::contract_call::loader_contract_asm; pub use loader::*; fn compute_contract_id_and_state_root( @@ -67,6 +69,8 @@ mod tests { }; use tempfile::tempdir; + use crate::assembly::contract_call::loader_contract_asm; + use super::*; #[test] diff --git a/packages/fuels-programs/src/contract/loader.rs b/packages/fuels-programs/src/contract/loader.rs index 5405f68c7d..4744b21757 100644 --- a/packages/fuels-programs/src/contract/loader.rs +++ b/packages/fuels-programs/src/contract/loader.rs @@ -1,6 +1,5 @@ use std::collections::HashSet; -use fuel_asm::{op, Instruction, RegId}; use fuel_tx::{Bytes32, ContractId, Salt, StorageSlot}; use fuels_accounts::Account; use fuels_core::{ @@ -13,70 +12,9 @@ use fuels_core::{ }, }; -use super::{compute_contract_id_and_state_root, Contract, Regular}; +use crate::assembly::contract_call::loader_contract_asm; -// Creates a contract that loads the specified blobs into memory and delegates the call to the code contained in the blobs. -pub fn loader_contract_asm(blob_ids: &[BlobId]) -> Result> { - const BLOB_ID_SIZE: u16 = 32; - let get_instructions = |num_of_instructions, num_of_blobs| { - // There are 2 main steps: - // 1. Load the blob contents into memory - // 2. Jump to the beginning of the memory where the blobs were loaded - // After that the execution continues normally with the loaded contract reading our - // prepared fn selector and jumps to the selected contract method. - [ - // 1. Load the blob contents into memory - // Find the start of the hardcoded blob IDs, which are located after the code ends. - op::move_(0x10, RegId::PC), - // 0x10 to hold the address of the current blob ID. - op::addi(0x10, 0x10, num_of_instructions * Instruction::SIZE as u16), - // The contract is going to be loaded from the current value of SP onwards, save - // the location into 0x16 so we can jump into it later on. - op::move_(0x16, RegId::SP), - // Loop counter. - op::movi(0x13, num_of_blobs), - // LOOP starts here. - // 0x11 to hold the size of the current blob. - op::bsiz(0x11, 0x10), - // Push the blob contents onto the stack. - op::ldc(0x10, 0, 0x11, 1), - // Move on to the next blob. - op::addi(0x10, 0x10, BLOB_ID_SIZE), - // Decrement the loop counter. - op::subi(0x13, 0x13, 1), - // Jump backwards (3+1) instructions if the counter has not reached 0. - op::jnzb(0x13, RegId::ZERO, 3), - // 2. Jump into the memory where the contract is loaded. - // What follows is called _jmp_mem by the sway compiler. - // Subtract the address contained in IS because jmp will add it back. - op::sub(0x16, 0x16, RegId::IS), - // jmp will multiply by 4, so we need to divide to cancel that out. - op::divi(0x16, 0x16, 4), - // Jump to the start of the contract we loaded. - op::jmp(0x16), - ] - }; - - let num_of_instructions = u16::try_from(get_instructions(0, 0).len()) - .expect("to never have more than u16::MAX instructions"); - - let num_of_blobs = u32::try_from(blob_ids.len()).map_err(|_| { - error!( - Other, - "the number of blobs ({}) exceeds the maximum number of blobs supported: {}", - blob_ids.len(), - u32::MAX - ) - })?; - - let instruction_bytes = get_instructions(num_of_instructions, num_of_blobs) - .into_iter() - .flat_map(|instruction| instruction.to_bytes()); - - let blob_bytes = blob_ids.iter().flatten().copied(); - - Ok(instruction_bytes.chain(blob_bytes).collect()) -} +use super::{compute_contract_id_and_state_root, Contract, Regular}; #[derive(Debug, Clone)] pub struct BlobsUploaded { diff --git a/packages/fuels-programs/src/debug.rs b/packages/fuels-programs/src/debug.rs new file mode 100644 index 0000000000..105f7335d9 --- /dev/null +++ b/packages/fuels-programs/src/debug.rs @@ -0,0 +1,456 @@ +use fuel_asm::{Instruction, Opcode}; +use fuels_core::{error, types::errors::Result}; +use itertools::Itertools; + +use crate::{ + assembly::{ + contract_call::{ContractCallData, ContractCallInstructions}, + script_and_predicate_loader::LoaderCode, + }, + utils::prepend_msg, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptCallData { + pub code: Vec, + pub data_section_offset: Option, + pub data: Vec, +} + +impl ScriptCallData { + pub fn data_section(&self) -> Option<&[u8]> { + self.data_section_offset.map(|offset| { + let offset = offset as usize; + &self.code[offset..] + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScriptType { + ContractCall(Vec), + Loader { + script: ScriptCallData, + blob_id: [u8; 32], + }, + Other(ScriptCallData), +} + +fn parse_script_call(script: &[u8], script_data: &[u8]) -> ScriptCallData { + let data_section_offset = if script.len() >= 16 { + let data_offset = u64::from_be_bytes(script[8..16].try_into().expect("will have 8 bytes")); + if data_offset as usize >= script.len() { + None + } else { + Some(data_offset) + } + } else { + None + }; + + ScriptCallData { + data: script_data.to_vec(), + data_section_offset, + code: script.to_vec(), + } +} + +fn parse_contract_calls( + script: &[u8], + script_data: &[u8], +) -> Result>> { + let instructions: std::result::Result, _> = + fuel_asm::from_bytes(script.to_vec()).try_collect(); + + let Ok(instructions) = instructions else { + return Ok(None); + }; + + let Some(call_instructions) = extract_call_instructions(&instructions) else { + return Ok(None); + }; + + let Some(minimum_call_offset) = call_instructions.iter().map(|i| i.call_data_offset()).min() + else { + return Ok(None); + }; + + let num_calls = call_instructions.len(); + + call_instructions.iter().enumerate().map(|(idx, current_call_instructions)| { + let data_start = + (current_call_instructions.call_data_offset() - minimum_call_offset) as usize; + + let data_end = if idx + 1 < num_calls { + (call_instructions[idx + 1].call_data_offset() + - current_call_instructions.call_data_offset()) as usize + } else { + script_data.len() + }; + + if data_start > script_data.len() || data_end > script_data.len() { + return Err(error!( + Other, + "call data offset requires data section of length {}, but data section is only {} bytes long", + data_end, + script_data.len() + )); + } + + let contract_call_data = ContractCallData::decode( + &script_data[data_start..data_end], + current_call_instructions.is_gas_fwd_variant(), + )?; + + Ok(contract_call_data) + }).collect::>().map(Some) +} + +fn extract_call_instructions( + mut instructions: &[Instruction], +) -> Option> { + let mut call_instructions = vec![]; + + while let Some(extracted_instructions) = ContractCallInstructions::extract_from(instructions) { + let num_instructions = extracted_instructions.len(); + debug_assert!(num_instructions > 0); + + instructions = &instructions[num_instructions..]; + call_instructions.push(extracted_instructions); + } + + if !instructions.is_empty() { + match instructions { + [single_instruction] if single_instruction.opcode() == Opcode::RET => {} + _ => return None, + } + } + + Some(call_instructions) +} + +impl ScriptType { + pub fn detect(script: &[u8], data: &[u8]) -> Result { + if let Some(contract_calls) = parse_contract_calls(script, data) + .map_err(prepend_msg("while decoding contract call"))? + { + return Ok(Self::ContractCall(contract_calls)); + } + + if let Some((script, blob_id)) = parse_loader_script(script, data)? { + return Ok(Self::Loader { script, blob_id }); + } + + Ok(Self::Other(parse_script_call(script, data))) + } +} + +fn parse_loader_script(script: &[u8], data: &[u8]) -> Result> { + let Some(loader_code) = LoaderCode::from_loader_binary(script) + .map_err(prepend_msg("while decoding loader script"))? + else { + return Ok(None); + }; + + Ok(Some(( + ScriptCallData { + code: script.to_vec(), + data: data.to_vec(), + data_section_offset: Some(loader_code.data_section_offset() as u64), + }, + loader_code.blob_id(), + ))) +} + +#[cfg(test)] +mod tests { + + use fuel_asm::RegId; + use fuels_core::types::errors::Error; + use rand::{RngCore, SeedableRng}; + use test_case::test_case; + + use crate::assembly::{ + contract_call::{CallOpcodeParamsOffset, ContractCallInstructions}, + script_and_predicate_loader::loader_instructions_w_data_section, + }; + + use super::*; + + #[test] + fn can_handle_empty_scripts() { + // given + let empty_script = []; + + // when + let res = ScriptType::detect(&empty_script, &[]).unwrap(); + + // then + assert_eq!( + res, + ScriptType::Other(ScriptCallData { + code: vec![], + data_section_offset: None, + data: vec![] + }) + ) + } + + #[test] + fn is_fine_with_malformed_scripts() { + // given + let mut script = vec![0; 100 * Instruction::SIZE]; + let mut rng = rand::rngs::StdRng::from_seed([0; 32]); + rng.fill_bytes(&mut script); + + // when + let script_type = ScriptType::detect(&script, &[]).unwrap(); + + // then + assert_eq!( + script_type, + ScriptType::Other(ScriptCallData { + code: script, + data_section_offset: None, + data: vec![] + }) + ); + } + + // Mostly to do with the script binary not having the script data offset in the second word + #[test] + fn is_fine_with_handwritten_scripts() { + // given + let handwritten_script = [ + fuel_asm::op::movi(0x10, 100), + fuel_asm::op::movi(0x10, 100), + fuel_asm::op::movi(0x10, 100), + fuel_asm::op::movi(0x10, 100), + fuel_asm::op::movi(0x10, 100), + ] + .iter() + .flat_map(|i| i.to_bytes()) + .collect::>(); + + // when + let script_type = ScriptType::detect(&handwritten_script, &[]).unwrap(); + + // then + assert_eq!( + script_type, + ScriptType::Other(ScriptCallData { + code: handwritten_script.to_vec(), + data_section_offset: None, + data: vec![] + }) + ); + } + + fn example_contract_call_data(has_args: bool, gas_fwd: bool) -> Vec { + let mut data = vec![]; + data.extend_from_slice(&100u64.to_be_bytes()); + data.extend_from_slice(&[0; 32]); + data.extend_from_slice(&[1; 32]); + data.extend_from_slice(&[0; 8]); + data.extend_from_slice(&[0; 8]); + data.extend_from_slice(&"test".len().to_be_bytes()); + data.extend_from_slice("test".as_bytes()); + if has_args { + data.extend_from_slice(&[0; 8]); + } + if gas_fwd { + data.extend_from_slice(&[0; 8]); + } + data + } + + #[test_case(108, "amount")] + #[test_case(100, "asset id")] + #[test_case(68, "contract id")] + #[test_case(36, "function selector offset")] + #[test_case(28, "encoded args offset")] + #[test_case(20, "function selector length")] + #[test_case(12, "function selector")] + #[test_case(8, "forwarded gas")] + fn catches_missing_data(amount_of_data_to_steal: usize, expected_msg: &str) { + // given + let script = ContractCallInstructions::new(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(1), + }) + .into_bytes() + .collect_vec(); + + let ok_data = example_contract_call_data(false, true); + let not_enough_data = ok_data[..ok_data.len() - amount_of_data_to_steal].to_vec(); + + // when + let err = ScriptType::detect(&script, ¬_enough_data).unwrap_err(); + + // then + let Error::Other(mut msg) = err else { + panic!("expected Error::Other"); + }; + + let expected_msg = + format!("while decoding contract call: while decoding {expected_msg}: not enough data"); + msg.truncate(expected_msg.len()); + + assert_eq!(expected_msg, msg); + } + + #[test] + fn handles_invalid_utf8_fn_selector() { + // given + let script = ContractCallInstructions::new(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(1), + }) + .into_bytes() + .collect_vec(); + + let invalid_utf8 = { + let invalid_data = [0x80, 0xBF, 0xC0, 0xAF, 0xFF]; + assert!(String::from_utf8(invalid_data.to_vec()).is_err()); + invalid_data + }; + + let mut ok_data = example_contract_call_data(false, true); + ok_data[96..101].copy_from_slice(&invalid_utf8); + + // when + let script_type = ScriptType::detect(&script, &ok_data).unwrap(); + + // then + let ScriptType::ContractCall(calls) = script_type else { + panic!("expected ScriptType::Other"); + }; + let Error::Codec(err) = calls[0].decode_fn_selector().unwrap_err() else { + panic!("expected Error::Codec"); + }; + + assert_eq!( + err, + "cannot decode function selector: invalid utf-8 sequence of 1 bytes from index 0" + ); + } + + #[test] + fn loader_script_without_a_blob() { + // given + let script = loader_instructions_w_data_section() + .iter() + .flat_map(|i| i.to_bytes()) + .collect::>(); + + // when + let err = ScriptType::detect(&script, &[]).unwrap_err(); + + // then + let Error::Other(msg) = err else { + panic!("expected Error::Other"); + }; + assert_eq!( + "while decoding loader script: while decoding blob id: not enough data, available: 0, requested: 32", + msg + ); + } + + #[test] + fn loader_script_with_almost_matching_instructions() { + // given + let mut loader_instructions = loader_instructions_w_data_section().to_vec(); + + loader_instructions.insert( + loader_instructions.len() - 2, + fuel_asm::op::movi(RegId::ZERO, 0), + ); + let script = loader_instructions + .iter() + .flat_map(|i| i.to_bytes()) + .collect::>(); + + // when + let script_type = ScriptType::detect(&script, &[]).unwrap(); + + // then + assert_eq!( + script_type, + ScriptType::Other(ScriptCallData { + code: script, + data_section_offset: None, + data: vec![] + }) + ); + } + + #[test] + fn extra_instructions_in_contract_calling_scripts_not_tolerated() { + // given + let mut contract_call_script = ContractCallInstructions::new(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(1), + }) + .into_bytes() + .collect_vec(); + + contract_call_script.extend(fuel_asm::op::movi(RegId::ZERO, 10).to_bytes()); + let script_data = example_contract_call_data(false, true); + + // when + let script_type = ScriptType::detect(&contract_call_script, &script_data).unwrap(); + + // then + assert_eq!( + script_type, + ScriptType::Other(ScriptCallData { + code: contract_call_script, + data_section_offset: None, + data: script_data + }) + ); + } + + #[test] + fn handles_invalid_call_data_offset() { + // given + let contract_call_1 = ContractCallInstructions::new(CallOpcodeParamsOffset { + call_data_offset: 0, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(1), + }) + .into_bytes(); + + let contract_call_2 = ContractCallInstructions::new(CallOpcodeParamsOffset { + call_data_offset: u16::MAX as usize, + amount_offset: 0, + asset_id_offset: 0, + gas_forwarded_offset: Some(1), + }) + .into_bytes(); + + let data_only_for_one_call = example_contract_call_data(false, true); + + let together = contract_call_1.chain(contract_call_2).collect_vec(); + + // when + let err = ScriptType::detect(&together, &data_only_for_one_call).unwrap_err(); + + // then + let Error::Other(msg) = err else { + panic!("expected Error::Other"); + }; + + assert_eq!( + "while decoding contract call: call data offset requires data section of length 65535, but data section is only 108 bytes long", + msg + ); + } +} diff --git a/packages/fuels-programs/src/executable.rs b/packages/fuels-programs/src/executable.rs index b0180c3cdd..4703997520 100644 --- a/packages/fuels-programs/src/executable.rs +++ b/packages/fuels-programs/src/executable.rs @@ -1,13 +1,12 @@ -use fuel_asm::{op, Instruction, RegId}; use fuels_core::{ - constants::WORD_SIZE, types::{ errors::Result, - transaction_builders::{Blob, BlobId, BlobTransactionBuilder}, + transaction_builders::{Blob, BlobTransactionBuilder}, }, Configurables, }; -use itertools::Itertools; + +use crate::assembly::script_and_predicate_loader::{extract_data_offset, LoaderCode}; /// This struct represents a standard executable with its associated bytecode and configurables. #[derive(Debug, Clone, PartialEq)] @@ -118,33 +117,29 @@ impl Executable { } pub fn data_offset_in_code(&self) -> usize { - self.code_with_offset().1 + self.loader_code().data_section_offset() } - fn code_with_offset(&self) -> (Vec, usize) { + fn loader_code(&self) -> LoaderCode { let mut code = self.state.code.clone(); self.state.configurables.update_constants_in(&mut code); - let blob_id = self.blob().id(); - - transform_into_configurable_loader(code, &blob_id) + LoaderCode::from_normal_binary(code) .expect("checked before turning into a Executable") } /// Returns the code of the loader executable with configurables applied. pub fn code(&self) -> Vec { - self.code_with_offset().0 + self.loader_code().as_bytes().to_vec() } /// A Blob containing the original executable code minus the data section. pub fn blob(&self) -> Blob { - let data_section_offset = extract_data_offset(&self.state.code) - .expect("checked before turning into a Executable"); - - let code_without_data_section = self.state.code[..data_section_offset].to_vec(); - - Blob::new(code_without_data_section) + // we don't apply configurables because they touch the data section which isn't part of the + // blob + LoaderCode::extract_blob(&self.state.code) + .expect("checked before turning into a Executable") } /// Uploads a blob containing the original executable code minus the data section. @@ -173,189 +168,13 @@ impl Executable { } } -fn extract_data_offset(binary: &[u8]) -> Result { - if binary.len() < 16 { - return Err(fuels_core::error!( - Other, - "given binary is too short to contain a data offset, len: {}", - binary.len() - )); - } - - let data_offset: [u8; 8] = binary[8..16].try_into().expect("checked above"); - - Ok(u64::from_be_bytes(data_offset) as usize) -} - -fn transform_into_configurable_loader( - binary: Vec, - blob_id: &BlobId, -) -> Result<(Vec, usize)> { - // The final code is going to have this structure (if the data section is non-empty): - // 1. loader instructions - // 2. blob id - // 3. length_of_data_section - // 4. the data_section (updated with configurables as needed) - const BLOB_ID_SIZE: u16 = 32; - const REG_ADDRESS_OF_DATA_AFTER_CODE: u8 = 0x10; - const REG_START_OF_LOADED_CODE: u8 = 0x11; - const REG_GENERAL_USE: u8 = 0x12; - let get_instructions = |num_of_instructions| { - // There are 3 main steps: - // 1. Load the blob content into memory - // 2. Load the data section right after the blob - // 3. Jump to the beginning of the memory where the blob was loaded - [ - // 1. Load the blob content into memory - // Find the start of the hardcoded blob ID, which is located after the loader code ends. - op::move_(REG_ADDRESS_OF_DATA_AFTER_CODE, RegId::PC), - // hold the address of the blob ID. - op::addi( - REG_ADDRESS_OF_DATA_AFTER_CODE, - REG_ADDRESS_OF_DATA_AFTER_CODE, - num_of_instructions * Instruction::SIZE as u16, - ), - // The code is going to be loaded from the current value of SP onwards, save - // the location into REG_START_OF_LOADED_CODE so we can jump into it at the end. - op::move_(REG_START_OF_LOADED_CODE, RegId::SP), - // REG_GENERAL_USE to hold the size of the blob. - op::bsiz(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE), - // Push the blob contents onto the stack. - op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 1), - // Move on to the data section length - op::addi( - REG_ADDRESS_OF_DATA_AFTER_CODE, - REG_ADDRESS_OF_DATA_AFTER_CODE, - BLOB_ID_SIZE, - ), - // load the size of the data section into REG_GENERAL_USE - op::lw(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE, 0), - // after we have read the length of the data section, we move the pointer to the actual - // data by skipping WORD_SIZE B. - op::addi( - REG_ADDRESS_OF_DATA_AFTER_CODE, - REG_ADDRESS_OF_DATA_AFTER_CODE, - WORD_SIZE as u16, - ), - // load the data section of the executable - op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 2), - // Jump into the memory where the contract is loaded. - // What follows is called _jmp_mem by the sway compiler. - // Subtract the address contained in IS because jmp will add it back. - op::sub( - REG_START_OF_LOADED_CODE, - REG_START_OF_LOADED_CODE, - RegId::IS, - ), - // jmp will multiply by 4, so we need to divide to cancel that out. - op::divi(REG_START_OF_LOADED_CODE, REG_START_OF_LOADED_CODE, 4), - // Jump to the start of the contract we loaded. - op::jmp(REG_START_OF_LOADED_CODE), - ] - }; - - let get_instructions_no_data_section = |num_of_instructions| { - // There are 2 main steps: - // 1. Load the blob content into memory - // 2. Jump to the beginning of the memory where the blob was loaded - [ - // 1. Load the blob content into memory - // Find the start of the hardcoded blob ID, which is located after the loader code ends. - op::move_(REG_ADDRESS_OF_DATA_AFTER_CODE, RegId::PC), - // hold the address of the blob ID. - op::addi( - REG_ADDRESS_OF_DATA_AFTER_CODE, - REG_ADDRESS_OF_DATA_AFTER_CODE, - num_of_instructions * Instruction::SIZE as u16, - ), - // The code is going to be loaded from the current value of SP onwards, save - // the location into REG_START_OF_LOADED_CODE so we can jump into it at the end. - op::move_(REG_START_OF_LOADED_CODE, RegId::SP), - // REG_GENERAL_USE to hold the size of the blob. - op::bsiz(REG_GENERAL_USE, REG_ADDRESS_OF_DATA_AFTER_CODE), - // Push the blob contents onto the stack. - op::ldc(REG_ADDRESS_OF_DATA_AFTER_CODE, 0, REG_GENERAL_USE, 1), - // Jump into the memory where the contract is loaded. - // What follows is called _jmp_mem by the sway compiler. - // Subtract the address contained in IS because jmp will add it back. - op::sub( - REG_START_OF_LOADED_CODE, - REG_START_OF_LOADED_CODE, - RegId::IS, - ), - // jmp will multiply by 4, so we need to divide to cancel that out. - op::divi(REG_START_OF_LOADED_CODE, REG_START_OF_LOADED_CODE, 4), - // Jump to the start of the contract we loaded. - op::jmp(REG_START_OF_LOADED_CODE), - ] - }; - - let offset = extract_data_offset(&binary)?; - - if binary.len() < offset { - return Err(fuels_core::error!( - Other, - "data section offset is out of bounds, offset: {offset}, binary len: {}", - binary.len() - )); - } - - let data_section = binary[offset..].to_vec(); - - if !data_section.is_empty() { - let num_of_instructions = u16::try_from(get_instructions(0).len()) - .expect("to never have more than u16::MAX instructions"); - - let instruction_bytes = get_instructions(num_of_instructions) - .into_iter() - .flat_map(|instruction| instruction.to_bytes()) - .collect_vec(); - - let blob_bytes = blob_id.iter().copied().collect_vec(); - - let original_data_section_len_encoded = u64::try_from(data_section.len()) - .expect("data section to be less than u64::MAX") - .to_be_bytes(); - - // The data section is placed after all of the instructions, the BlobId, and the number representing - // how big the data section is. - let new_data_section_offset = - instruction_bytes.len() + blob_bytes.len() + original_data_section_len_encoded.len(); - - let code = instruction_bytes - .into_iter() - .chain(blob_bytes) - .chain(original_data_section_len_encoded) - .chain(data_section) - .collect(); - - Ok((code, new_data_section_offset)) - } else { - let num_of_instructions = u16::try_from(get_instructions_no_data_section(0).len()) - .expect("to never have more than u16::MAX instructions"); - - let instruction_bytes = get_instructions_no_data_section(num_of_instructions) - .into_iter() - .flat_map(|instruction| instruction.to_bytes()); - - let blob_bytes = blob_id.iter().copied(); - - let code = instruction_bytes.chain(blob_bytes).collect_vec(); - // there is no data section, so we point the offset to the end of the file - let new_data_section_offset = code.len(); - - Ok((code, new_data_section_offset)) - } -} - fn validate_loader_can_be_made_from_code( mut code: Vec, configurables: Configurables, ) -> Result<()> { configurables.update_constants_in(&mut code); - // BlobId currently doesn't affect our ability to produce the loader code - transform_into_configurable_loader(code, &Default::default())?; + let _ = LoaderCode::from_normal_binary(code)?; Ok(()) } @@ -464,12 +283,9 @@ mod tests { assert_eq!(blob.as_ref(), data_stripped_code); let loader_code = loader.code(); - let blob_id = blob.id(); assert_eq!( loader_code, - transform_into_configurable_loader(code, &blob_id) - .unwrap() - .0 + LoaderCode::from_normal_binary(code).unwrap().as_bytes() ) } diff --git a/packages/fuels-programs/src/lib.rs b/packages/fuels-programs/src/lib.rs index 9b0ee77497..77ef512757 100644 --- a/packages/fuels-programs/src/lib.rs +++ b/packages/fuels-programs/src/lib.rs @@ -1,4 +1,13 @@ +#[cfg(feature = "std")] pub mod calls; +#[cfg(feature = "std")] pub mod contract; +#[cfg(feature = "std")] pub mod executable; +#[cfg(feature = "std")] pub mod responses; + +pub mod debug; + +pub(crate) mod assembly; +pub(crate) mod utils; diff --git a/packages/fuels-programs/src/utils.rs b/packages/fuels-programs/src/utils.rs new file mode 100644 index 0000000000..cdee3d31db --- /dev/null +++ b/packages/fuels-programs/src/utils.rs @@ -0,0 +1,19 @@ +use fuels_core::types::errors::{error, Error}; + +pub fn prepend_msg<'a>(msg: impl AsRef + 'a) -> impl Fn(Error) -> Error + 'a { + move |err| match err { + Error::IO(orig_msg) => { + error!(IO, "{}: {}", msg.as_ref(), orig_msg) + } + Error::Codec(orig_msg) => { + error!(Codec, "{}: {}", msg.as_ref(), orig_msg) + } + Error::Transaction(reason) => Error::Transaction(reason), + Error::Provider(orig_msg) => { + error!(Provider, "{}: {}", msg.as_ref(), orig_msg) + } + Error::Other(orig_msg) => { + error!(Other, "{}: {}", msg.as_ref(), orig_msg) + } + } +} diff --git a/packages/fuels/Cargo.toml b/packages/fuels/Cargo.toml index 490f43c2c7..e62bcda8bf 100644 --- a/packages/fuels/Cargo.toml +++ b/packages/fuels/Cargo.toml @@ -20,7 +20,7 @@ fuel-tx = { workspace = true } fuels-accounts = { workspace = true, default-features = false } fuels-core = { workspace = true } fuels-macros = { workspace = true } -fuels-programs = { workspace = true, optional = true } +fuels-programs = { workspace = true } fuels-test-helpers = { workspace = true, optional = true } [features] @@ -32,10 +32,9 @@ coin-cache = ["fuels-accounts/coin-cache"] # used so that we don't get a new feature flag for every optional dependency. std = [ "dep:fuel-core-client", - "dep:fuels-programs", + "fuels-programs/std", "dep:fuels-test-helpers", "fuels-accounts/std", - "fuels-programs?/std", "fuels-core/std", "fuels-test-helpers?/std", ] diff --git a/packages/fuels/src/lib.rs b/packages/fuels/src/lib.rs index b3835272f6..e1f58c0414 100644 --- a/packages/fuels/src/lib.rs +++ b/packages/fuels/src/lib.rs @@ -33,7 +33,6 @@ pub mod macros { pub use fuels_macros::*; } -#[cfg(feature = "std")] pub mod programs { pub use fuels_programs::*; } diff --git a/wasm-tests/Cargo.toml b/wasm-tests/Cargo.toml index 4d13e5d1a7..df171ce683 100644 --- a/wasm-tests/Cargo.toml +++ b/wasm-tests/Cargo.toml @@ -13,6 +13,7 @@ rust-version = { workspace = true } crate-type = ['cdylib'] [dev-dependencies] +hex = { workspace = true } fuels = { workspace = true } fuels-core = { workspace = true } getrandom = { version = "0.2.11", features = ["js"] } diff --git a/wasm-tests/src/lib.rs b/wasm-tests/src/lib.rs index c3b54fb729..cc9649445b 100644 --- a/wasm-tests/src/lib.rs +++ b/wasm-tests/src/lib.rs @@ -8,8 +8,10 @@ mod tests { accounts::predicate::Predicate, core::{codec::ABIEncoder, traits::Tokenizable}, macros::wasm_abigen, - types::{bech32::Bech32Address, errors::Result}, + programs::debug::ScriptType, + types::{bech32::Bech32Address, errors::Result, AssetId}, }; + use fuels_core::codec::abi_formatter::ABIFormatter; use wasm_bindgen_test::wasm_bindgen_test; #[wasm_bindgen_test] @@ -212,4 +214,238 @@ mod tests { Ok(()) } + + #[wasm_bindgen_test] + fn can_decode_a_contract_calling_script() -> Result<()> { + let script = hex::decode("724028d8724428b05d451000724828b82d41148a724029537244292b5d451000724829332d41148a24040000")?; + let script_data = hex::decode("000000000000000a00000000000000000000000000000000000000000000000000000000000000001e62ecaa5c32f1e51954f46149d5e542472bdba45838199406464af46ab147ed000000000000290800000000000029260000000000000016636865636b5f7374727563745f696e746567726974790000000201000000000000001400000000000000000000000000000000000000000000000000000000000000001e62ecaa5c32f1e51954f46149d5e542472bdba45838199406464af46ab147ed000000000000298300000000000029a20000000000000017695f616d5f63616c6c65645f646966666572656e746c7900000002011e62ecaa5c32f1e51954f46149d5e542472bdba45838199406464af46ab147ed000000000000007b00000000000001c8")?; + + let abi = r#"{ + "programType": "contract", + "specVersion": "1", + "encodingVersion": "1", + "concreteTypes": [ + { + "type": "()", + "concreteTypeId": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d" + }, + { + "type": "bool", + "concreteTypeId": "b760f44fa5965c2474a3b471467a22c43185152129295af588b022ae50b50903" + }, + { + "type": "struct AllStruct", + "concreteTypeId": "91804f0112892169cddf041007c9f16f95281d45c3f363e544c33dffc8179266", + "metadataTypeId": 1 + }, + { + "type": "struct CallData", + "concreteTypeId": "c1b2644ef8de5c5b7a95aaadf3f5cedd40f42286d459bcd051c3cc35fa1ce5ec", + "metadataTypeId": 2 + }, + { + "type": "struct MemoryAddress", + "concreteTypeId": "0b7b6a791f80f65fe493c3e0d0283bf8206871180c9b696797ff0098ff63b474", + "metadataTypeId": 3 + } + ], + "metadataTypes": [ + { + "type": "b256", + "metadataTypeId": 0 + }, + { + "type": "struct AllStruct", + "metadataTypeId": 1, + "components": [ + { + "name": "some_struct", + "typeId": 4 + } + ] + }, + { + "type": "struct CallData", + "metadataTypeId": 2, + "components": [ + { + "name": "memory_address", + "typeId": 3 + }, + { + "name": "num_coins_to_forward", + "typeId": 7 + }, + { + "name": "asset_id_of_coins_to_forward", + "typeId": 5 + }, + { + "name": "amount_of_gas_to_forward", + "typeId": 7 + } + ] + }, + { + "type": "struct MemoryAddress", + "metadataTypeId": 3, + "components": [ + { + "name": "contract_id", + "typeId": 5 + }, + { + "name": "function_selector", + "typeId": 7 + }, + { + "name": "function_data", + "typeId": 7 + } + ] + }, + { + "type": "struct SomeStruct", + "metadataTypeId": 4, + "components": [ + { + "name": "field", + "typeId": 6 + }, + { + "name": "field_2", + "typeId": "b760f44fa5965c2474a3b471467a22c43185152129295af588b022ae50b50903" + } + ] + }, + { + "type": "struct std::contract_id::ContractId", + "metadataTypeId": 5, + "components": [ + { + "name": "bits", + "typeId": 0 + } + ] + }, + { + "type": "u32", + "metadataTypeId": 6 + }, + { + "type": "u64", + "metadataTypeId": 7 + } + ], + "functions": [ + { + "inputs": [ + { + "name": "arg", + "concreteTypeId": "91804f0112892169cddf041007c9f16f95281d45c3f363e544c33dffc8179266" + } + ], + "name": "check_struct_integrity", + "output": "b760f44fa5965c2474a3b471467a22c43185152129295af588b022ae50b50903", + "attributes": [ + { + "name": "payable", + "arguments": [] + } + ] + }, + { + "inputs": [], + "name": "get_struct", + "output": "91804f0112892169cddf041007c9f16f95281d45c3f363e544c33dffc8179266", + "attributes": null + }, + { + "inputs": [ + { + "name": "arg1", + "concreteTypeId": "91804f0112892169cddf041007c9f16f95281d45c3f363e544c33dffc8179266" + }, + { + "name": "arg2", + "concreteTypeId": "0b7b6a791f80f65fe493c3e0d0283bf8206871180c9b696797ff0098ff63b474" + } + ], + "name": "i_am_called_differently", + "output": "2e38e77b22c314a449e91fafed92a43826ac6aa403ae6a8acb6cf58239fbaf5d", + "attributes": [ + { + "name": "payable", + "arguments": [] + } + ] + }, + { + "inputs": [ + { + "name": "call_data", + "concreteTypeId": "c1b2644ef8de5c5b7a95aaadf3f5cedd40f42286d459bcd051c3cc35fa1ce5ec" + } + ], + "name": "nested_struct_with_reserved_keyword_substring", + "output": "c1b2644ef8de5c5b7a95aaadf3f5cedd40f42286d459bcd051c3cc35fa1ce5ec", + "attributes": null + } + ], + "loggedTypes": [], + "messagesTypes": [], + "configurables": [] + }"#; + + let decoder = ABIFormatter::from_json_abi(abi)?; + + // when + let script_type = ScriptType::detect(&script, &script_data)?; + + // then + let ScriptType::ContractCall(call_descriptions) = script_type else { + panic!("expected a contract call") + }; + + assert_eq!(call_descriptions.len(), 2); + + let call_description = &call_descriptions[0]; + + let expected_contract_id = + "1e62ecaa5c32f1e51954f46149d5e542472bdba45838199406464af46ab147ed".parse()?; + assert_eq!(call_description.contract_id, expected_contract_id); + assert_eq!(call_description.amount, 10); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "check_struct_integrity" + ); + assert!(call_description.gas_forwarded.is_none()); + + assert_eq!( + decoder.decode_fn_args( + &call_description.decode_fn_selector().unwrap(), + &call_description.encoded_args + )?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }"] + ); + + let call_description = &call_descriptions[1]; + + assert_eq!(call_description.contract_id, expected_contract_id); + assert_eq!(call_description.amount, 20); + assert_eq!(call_description.asset_id, AssetId::default()); + assert_eq!( + call_description.decode_fn_selector().unwrap(), + "i_am_called_differently" + ); + assert!(call_description.gas_forwarded.is_none()); + + assert_eq!( + decoder.decode_fn_args(&call_description.decode_fn_selector().unwrap(), &call_description.encoded_args)?, + vec!["AllStruct { some_struct: SomeStruct { field: 2, field_2: true } }", "MemoryAddress { contract_id: std::contract_id::ContractId { bits: Bits256([30, 98, 236, 170, 92, 50, 241, 229, 25, 84, 244, 97, 73, 213, 229, 66, 71, 43, 219, 164, 88, 56, 25, 148, 6, 70, 74, 244, 106, 177, 71, 237]) }, function_selector: 123, function_data: 456 }"] + ); + + Ok(()) + } }