diff --git a/Makefile b/Makefile index 06e6252c..80c94cc6 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,9 @@ test-storage: test-e2e: cargo test -p e2e-move-tests --features testing +test-unit: + cargo test stdlib_move_unit_tests --features testing -p e2e-move-tests + build: precompile build-rust build-go build-rust: build-rust-release diff --git a/crates/e2e-move-tests/src/tests/move_unit.rs b/crates/e2e-move-tests/src/tests/move_unit.rs index c18a46de..f59e2290 100644 --- a/crates/e2e-move-tests/src/tests/move_unit.rs +++ b/crates/e2e-move-tests/src/tests/move_unit.rs @@ -20,6 +20,7 @@ use move_cli::base::test::{run_move_unit_tests_with_factory, UnitTestResult}; use move_core_types::effects::ChangeSet; use move_unit_test::UnitTestingConfig; use move_vm_runtime::native_extensions::NativeContextExtensions; +use move_model::metadata::{CompilerVersion, LanguageVersion}; use once_cell::sync::Lazy; use std::path::PathBuf; @@ -69,6 +70,8 @@ fn run_tests_for_pkg(path_to_pkg: impl Into) { .compiler_config .known_attributes .clone_from(metadata::get_all_attribute_names()); + build_config.compiler_config.compiler_version = Some(CompilerVersion::V2_1); + build_config.compiler_config.language_version = Some(LanguageVersion::V2_1); let res = run_move_unit_tests_with_factory( &pkg_path, diff --git a/crates/natives/src/cosmos.rs b/crates/natives/src/cosmos.rs index 185772a2..1b839d12 100644 --- a/crates/natives/src/cosmos.rs +++ b/crates/natives/src/cosmos.rs @@ -86,9 +86,6 @@ fn native_requested_messages( ty_args: Vec, _arguments: VecDeque, ) -> SafeNativeResult> { - let gas_params = &context.native_gas_params.initia_stdlib; - context.charge(gas_params.cosmos_stargate_base)?; - debug_assert!(ty_args.is_empty()); debug_assert!(_arguments.is_empty()); @@ -100,7 +97,37 @@ fn native_requested_messages( .into_iter() .map(|m| Value::struct_(Struct::pack(vec![Value::vector_u8(m.data)]))); - Ok(smallvec![Value::vector_for_testing_only(messages)]) + let options = cosmos_context + .messages + .borrow() + .clone() + .into_iter() + .map(|m| { + let (callback_id, callback_fid) = if let Some(callback) = m.callback { + ( + callback.id, + format!( + "{}::{}::{}", + callback.module_address.to_standard_string(), + callback.module_name, + callback.function_name + ) + .into_bytes(), + ) + } else { + (0, vec![]) + }; + Value::struct_(Struct::pack(vec![ + Value::bool(m.allow_failure), + Value::u64(callback_id), + Value::vector_u8(callback_fid), + ])) + }); + + Ok(smallvec![ + Value::vector_for_testing_only(messages), + Value::vector_for_testing_only(options) + ]) } /*************************************************************************************************** diff --git a/crates/natives/src/ibctesting.rs b/crates/natives/src/ibctesting.rs new file mode 100644 index 00000000..16b86e56 --- /dev/null +++ b/crates/natives/src/ibctesting.rs @@ -0,0 +1,22 @@ +use crate::dispatchable_fungible_asset::native_dispatch; +use crate::interface::{RawSafeNative, SafeNativeBuilder}; +use move_vm_runtime::native_functions::NativeFunction; + +/*************************************************************************************************** + * module + * + **************************************************************************************************/ +pub fn make_all( + builder: &SafeNativeBuilder, +) -> impl Iterator + '_ { + let mut natives = vec![]; + + natives.extend([ + ("dispatchable_ibc_ack", native_dispatch as RawSafeNative), + ("dispatchable_ibc_timeout", native_dispatch), + ("dispatchable_callback", native_dispatch), + ("dispatchable_on_receive", native_dispatch), + ]); + + builder.make_named_natives(natives) +} diff --git a/crates/natives/src/lib.rs b/crates/natives/src/lib.rs index 7a71ee9e..94a127fd 100644 --- a/crates/natives/src/lib.rs +++ b/crates/natives/src/lib.rs @@ -31,6 +31,9 @@ pub mod table; pub mod transaction_context; pub mod type_info; +#[cfg(feature = "testing")] +pub mod ibctesting; + use initia_move_gas::{MiscGasParameters, NativeGasParameters}; use interface::SafeNativeBuilder; use move_core_types::account_address::AccountAddress; @@ -83,6 +86,9 @@ pub fn initia_move_natives( ); add_natives_from_module!("biguint", biguint::make_all(builder)); + #[cfg(feature = "testing")] + add_natives_from_module!("ibctesting", ibctesting::make_all(builder)); + make_table_from_iter(initia_std_addr, natives) } diff --git a/precompile/binaries/minlib/cosmos.mv b/precompile/binaries/minlib/cosmos.mv index 1225e496..4e4bf006 100644 Binary files a/precompile/binaries/minlib/cosmos.mv and b/precompile/binaries/minlib/cosmos.mv differ diff --git a/precompile/binaries/minlib/json.mv b/precompile/binaries/minlib/json.mv index b312aa49..51431a89 100644 Binary files a/precompile/binaries/minlib/json.mv and b/precompile/binaries/minlib/json.mv differ diff --git a/precompile/binaries/stdlib/cosmos.mv b/precompile/binaries/stdlib/cosmos.mv index 1225e496..4e4bf006 100644 Binary files a/precompile/binaries/stdlib/cosmos.mv and b/precompile/binaries/stdlib/cosmos.mv differ diff --git a/precompile/binaries/stdlib/json.mv b/precompile/binaries/stdlib/json.mv index b312aa49..51431a89 100644 Binary files a/precompile/binaries/stdlib/json.mv and b/precompile/binaries/stdlib/json.mv differ diff --git a/precompile/modules/initia_stdlib/Move.toml b/precompile/modules/initia_stdlib/Move.toml index 90bfb230..64b5bad9 100644 --- a/precompile/modules/initia_stdlib/Move.toml +++ b/precompile/modules/initia_stdlib/Move.toml @@ -10,3 +10,4 @@ MoveNursery = { local = "../move_nursery" } std = "0x1" initia_std = "0x1" relayer = "0x3d18d54532fc42e567090852db6eb21fa528f952" +cafe = "0xcafe" diff --git a/precompile/modules/initia_stdlib/sources/address.move b/precompile/modules/initia_stdlib/sources/address.move index 83d14e7e..0bc919f1 100644 --- a/precompile/modules/initia_stdlib/sources/address.move +++ b/precompile/modules/initia_stdlib/sources/address.move @@ -82,7 +82,7 @@ module initia_std::address { assert!(addr == from_sdk(addr_sdk), 0) } - // string <> address + // hex string <> address native public fun to_string(addr: address): String; native public fun from_string(addr_str: String): address; diff --git a/precompile/modules/initia_stdlib/sources/cosmos.move b/precompile/modules/initia_stdlib/sources/cosmos.move index 237f975c..68c1dac3 100644 --- a/precompile/modules/initia_stdlib/sources/cosmos.move +++ b/precompile/modules/initia_stdlib/sources/cosmos.move @@ -414,17 +414,30 @@ module initia_std::cosmos { ) } + // + // Native Functions + // + native fun stargate_internal( sender: address, data: vector, option: Options ); #[test_only] - native public fun requested_messages(): vector; + native public fun requested_messages(): (vector, vector); #[test_only] public fun was_message_requested(msg: &String): bool { + was_message_requested_with_options(msg, &disallow_failure()) + } + + #[test_only] + public fun was_message_requested_with_options( + msg: &String, opt: &Options + ): bool { use std::vector; - vector::contains(&requested_messages(), msg) + let (messages, opts) = requested_messages(); + let (found, idx) = vector::index_of(&messages, msg); + found && vector::borrow(&opts, idx) == opt } // ================================================== Options ================================================= @@ -493,6 +506,11 @@ module initia_std::cosmos { } } + /// Unpack options for external use + public fun unpack_options(opt: Options): (bool, u64, String) { + (opt.allow_failure, opt.callback_id, string::utf8(opt.callback_fid)) + } + //=========================================== Tests =========================================== #[test(sender = @0xcafe)] @@ -518,4 +536,37 @@ module initia_std::cosmos { assert!(was_message_requested(&msg), 1); } + + #[test(sender = @0xcafe)] + public fun test_stargate_with_options(sender: &signer) { + use std::string::{utf8, bytes}; + + let voter = utf8(b"voter"); + let proposal_id = 1; + let option = 1; + let metadata = utf8(b"metadata"); + let msg = + json::marshal_to_string( + &VoteRequest { + _type_: utf8(b"/cosmos.gov.v1.MsgVote"), + proposal_id, + voter: voter, + option, + metadata: metadata + } + ); + + stargate_with_options( + sender, + *bytes(&msg), + allow_failure_with_callback(1, utf8(b"0x1::test::test_fn")) + ); + + assert!( + was_message_requested_with_options( + &msg, &allow_failure_with_callback(1, utf8(b"0x1::test::test_fn")) + ), + 1 + ); + } } diff --git a/precompile/modules/initia_stdlib/sources/function_info.move b/precompile/modules/initia_stdlib/sources/function_info.move index bc37445b..e67302a8 100644 --- a/precompile/modules/initia_stdlib/sources/function_info.move +++ b/precompile/modules/initia_stdlib/sources/function_info.move @@ -84,4 +84,23 @@ module initia_std::function_info { // Test only dependencies so we can invoke those friend functions. #[test_only] friend initia_std::function_info_tests; + + #[test_only] + public fun load_module_from_function_for_testing(f: &FunctionInfo) { + load_module_from_function(f) + } + + #[test_only] + public fun new_function_info_for_testing( + module_address: address, module_name: String, function_name: String + ): FunctionInfo { + new_function_info_from_address(module_address, module_name, function_name) + } + + #[test_only] + public fun check_dispatch_type_compatibility_for_testing( + framework_function: &FunctionInfo, dispatch_target: &FunctionInfo + ): bool { + check_dispatch_type_compatibility(framework_function, dispatch_target) + } } diff --git a/precompile/modules/initia_stdlib/sources/ibctesting/README.md b/precompile/modules/initia_stdlib/sources/ibctesting/README.md new file mode 100644 index 00000000..d71bc5ca --- /dev/null +++ b/precompile/modules/initia_stdlib/sources/ibctesting/README.md @@ -0,0 +1,53 @@ +# IBC Testing + +This package provides convenient unit-test tools for IBC operations. + +## How to Use + +There are three steps in IBC testing: + +### `execute_cosmos_messages()` + +This function checks pending Cosmos messages and verifies if a message can be executed. + +- If a message is executable, it transfers the requested token from the sender to the `@std` address. +- Otherwise, it performs no operation. + +If a message is registered with options at `cosmos::stargate_with_options`, it will check and execute the given callback function if option contains callback. There are four cases for these options excluding callback options: + +| Success | Option (allow_failure) | Abort | Revert | +|---------|-------------------------|-------|--------| +| Failed | true | false | true | +| Failed | false | true | true | +| True | true | false | false | +| True | false | false | false | + +Refer to [ibc_transfer_tests.move](../../tests/ibc_transfer_tests.move) and [ibc_transfer_tests_helpers.move#95](../../tests/ibc_transfer_tests_helpers.move#95) for details. + +### `relay_packets()` + +If there is at least one executable IBC transfer from the previous step, it will simulate IBC packet relaying by depositing the counterparty token to the recipient and executing the `on_receive` dispatch function. + +The `on_receive` function should have the following signature: + +```rust +public fun on_receive(recipient: &signer, msg_opt: &Option): bool +``` + +In the `on_receive` function, you can verify the passed message and ensure the counterparty token is correctly received. Refer to [ibc_transfer_tests_helpers.move#115](../../tests/ibc_transfer_tests_helpers.move#115) for details. + +Based on the response of `on_receive`, the IBC packet relaying actions decide whether to execute acknowledgment with success or failure. Refer to [ibc_transfer_tests.move#222](../../tests/ibc_transfer_tests.move#222) for details. + +You can also use `block::set_block_info` to simulate timeout cases. Refer to [ibc_transfer_tests.move#264](../../tests/ibc_transfer_tests.move#264) for details. + +### `relay_acks_timeouts()` + +Initia provides an async callback feature to allow a dApp developer to receive IBC packet success notifications. For more details, see [Initia IBC Hooks](https://github.com/initia-labs/initia/tree/main/x/ibc-hooks/move-hooks). + +If a message contains a memo field like `{"move": {"message": {}, "async_callback": {"id": "100", "module_address": "0xabc", "module_name": "testing"}}}`, it will execute `0xabc::testing::ibc_ack` or `0xabc::testing::ibc_timeout` according to the result of IBC packet relaying. + +## Avoid Re-entrancy in Unit Tests + +The IBC testing package is built with the dispatch function of Aptos Move, which does not allow re-entrancy. When writing testing scripts, ensure that you do not call `ibctesting` or test modules from callback functions (`on_callback`, `on_receive`, `ibc_ack`, `ibc_timeout`). + +To avoid re-entrancy issues, create a new test module, as demonstrated in [`ibc_transfer_tests`](../../tests/ibc_transfer_tests.move). diff --git a/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting.move b/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting.move new file mode 100644 index 00000000..2f0e6d95 --- /dev/null +++ b/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting.move @@ -0,0 +1,439 @@ +#[test_only] +module initia_std::ibctesting { + use std::string::{Self, String, utf8}; + use std::object; + use std::fungible_asset::Metadata; + use std::vector; + use std::managed_coin; + use std::account; + use std::coin; + use std::option::{Self, Option}; + use std::cosmos::{Self, Options}; + use std::json::{Self, JSONObject}; + use std::block; + use std::address; + use std::function_info::{ + FunctionInfo, + new_function_info_for_testing, + check_dispatch_type_compatibility_for_testing + }; + use std::ibctesting_utils::{ + counterparty_symbol, + intermediate_sender, + create_counterparty_token + }; + + // + // Errors + // + + const EIBC_TESTING_IN_PROGRESS: u64 = 0x1; + const EIBC_TESTING_PACKETS_NOT_RELAYED: u64 = 0x2; + const ECOSMOS_MESSAGE_FAILED: u64 = 0x3; + + const ENOT_SUPPORTED_COSMOS_MESSAGE: u64 = 0x10; + const EINVALID_TIMEOUT: u64 = 0x11; + const EZERO_AMOUNT: u64 = 0x12; + + // + // Chain Operations + // + + /// Execute the requested cosmos messages and store the packets into the chain. + public fun execute_cosmos_messages() { + assert!(!exists(@std), EIBC_TESTING_IN_PROGRESS); + + let (messages, opts) = cosmos::requested_messages(); + let packets = vector::empty(); + vector::zip( + messages, + opts, + |msg, opt| { + let transfer_req = json::unmarshal(*string::bytes(&msg)); + assert!( + *string::bytes(&transfer_req._type_) + == b"/ibc.applications.transfer.v1.MsgTransfer", + ENOT_SUPPORTED_COSMOS_MESSAGE + ); + + // should have at least one of timeout_height or timeout_timestamp + assert!( + transfer_req.timeout_height.revision_height != 0 + || transfer_req.timeout_timestamp != 0, + EINVALID_TIMEOUT + ); + + // amount should be greater than 0 + assert!(transfer_req.token.amount > 0, EZERO_AMOUNT); + + if (execute(transfer_req, opt)) { + vector::push_back(&mut packets, transfer_req); + }; + } + ); + + let chain_signer = account::create_signer_for_test(@std); + move_to( + &chain_signer, ChainStore { packets, results: vector::empty() } + ); + } + + /// Relay the packets to the counterparty chain. + /// If the block height or timestamp is reached to timeout of a packet, then send a timeout message. + /// + /// The caller must implement the `on_receive` dispatchable function to decide the success of the transfer. + /// The caller also can use this callback to execute destination hook functions. + /// + /// fun on_receive(recipient: &signer, message: &Option): bool; + public fun relay_packets(on_receive: &FunctionInfo) acquires ChainStore { + if (!exists(@std)) { + return; + }; + + let (height, timestamp) = block::get_block_info(); + + let chain_data = borrow_global_mut(@std); + vector::for_each( + chain_data.packets, + |packet| { + let (message, async_callback) = unmarshal_memo(packet.memo); + + // check timeout (multiply timeout timestamp by 1_000_000_000 to convert to nanoseconds) + if (( + packet.timeout_height.revision_height != 0 + && packet.timeout_height.revision_height <= height + ) + || ( + packet.timeout_timestamp != 0 + && packet.timeout_timestamp <= timestamp * 1_000_000_000u64 + )) { + vector::push_back( + &mut chain_data.results, + TransferResult { success: false, timeout: true, async_callback } + ); + + // return not supported yet + // return; + } else { + // do on_receive actions + let denom = packet.token.denom; + let metadata = coin::denom_to_metadata(denom); + let counterparty_symbol = counterparty_symbol(metadata); + let counterparty_metadata_addr = + coin::metadata_address(@std, counterparty_symbol); + if (!object::object_exists(counterparty_metadata_addr)) { + create_counterparty_token(metadata); + }; + + let counterparty_metadata = + object::address_to_object(counterparty_metadata_addr); + let recipient = + if (option::is_some(&message)) { + // In order to get actual intermediate sender on destination chain, we need to put destination channel + // but it is not available in the packet yet. so we use source channel as a temporary solution. + intermediate_sender(packet.source_channel, packet.receiver) + } else { + // send to the recipient + address::from_sdk(packet.receiver) + }; + + // mint token to the recipient + managed_coin::mint_to( + &account::create_signer_for_test(@std), + recipient, + counterparty_metadata, + packet.token.amount + ); + + // execute the callback to decide the success of the transfer + check_dispatch_type_compatibility_for_testing( + &dispatchable_on_receive_function_info(), on_receive + ); + let success = + dispatchable_on_receive( + &account::create_signer_for_test(recipient), + &message, + on_receive + ); + vector::push_back( + &mut chain_data.results, + TransferResult { success, timeout: false, async_callback } + ); + } + } + ); + } + + public fun relay_acks_timeouts() acquires ChainStore { + if (!exists(@std)) { + return; + }; + + let chain_data = move_from(@std); + assert!( + vector::length(&chain_data.packets) == vector::length(&chain_data.results), + EIBC_TESTING_PACKETS_NOT_RELAYED + ); + + vector::zip( + chain_data.packets, + chain_data.results, + |packet, result| { + + // refund coin to the sender if the transfer is failed + if (!result.success) { + let denom = packet.token.denom; + let metadata = coin::denom_to_metadata(denom); + let sender = address::from_sdk(packet.sender); + + coin::transfer( + &account::create_signer_for_test(@std), + sender, + metadata, + packet.token.amount + ); + }; + + if (result.timeout && option::is_some(&result.async_callback)) { + let async_callback = option::destroy_some(result.async_callback); + let function_info = + ibc_timeout_callback_function_info( + async_callback.module_address, + async_callback.module_name + ); + + check_dispatch_type_compatibility_for_testing( + &dispatchable_ibc_timeout_function_info(), &function_info + ); + dispatchable_ibc_timeout(async_callback.id, &function_info); + } else if (option::is_some(&result.async_callback)) { + let async_callback = option::destroy_some(result.async_callback); + let function_info = + ibc_ack_callback_function_info( + async_callback.module_address, + async_callback.module_name + ); + + check_dispatch_type_compatibility_for_testing( + &dispatchable_ibc_ack_function_info(), &function_info + ); + dispatchable_ibc_ack( + async_callback.id, result.success, &function_info + ); + }; + } + ); + } + + // + // Internal Functions + // + + /// Execute the transfer request and return true if the transfer is successful. Otherwise, return false. + fun execute(transfer_req: TransferRequest, opt: Options): bool { + let (allow_failure, callback_id, callback_fid) = cosmos::unpack_options(opt); + + // check balance to check if the sender has enough funds + let denom = transfer_req.token.denom; + let metadata = coin::denom_to_metadata(denom); + let sender_addr = address::from_sdk(transfer_req.sender); + let balance = coin::balance(sender_addr, metadata); + if (balance < transfer_req.token.amount) { + // balance not enough; send failure message + + // if allow_failure is false, then abort the transaction + assert!(allow_failure, ECOSMOS_MESSAGE_FAILED); + + if (callback_id > 0) { + let function_info = callback_function_info(callback_fid); + check_dispatch_type_compatibility_for_testing( + &dispatchable_callback_function_info(), &function_info + ); + dispatchable_callback(callback_id, false, &function_info); + }; + + return false; + }; + + // withdraw token from the sender + let sender_signer = account::create_signer_for_test(sender_addr); + coin::transfer( + &sender_signer, + @std, + metadata, + transfer_req.token.amount + ); + + if (callback_id > 0) { + let function_info = callback_function_info(callback_fid); + check_dispatch_type_compatibility_for_testing( + &dispatchable_callback_function_info(), &function_info + ); + dispatchable_callback(callback_id, true, &function_info); + }; + + true + } + + fun unmarshal_memo(memo: String): (Option, Option) { + let memo_bytes = string::bytes(&memo); + if (vector::length(memo_bytes) == 0) { + return (option::none(), option::none()); + }; + + let memo_obj = json::unmarshal(*memo_bytes); + let move_obj = json::get_elem(&memo_obj, string::utf8(b"move")); + if (option::is_none(&move_obj)) { + return (option::none(), option::none()); + }; + + let move_obj = option::destroy_some(move_obj); + ( + json::get_elem(&move_obj, string::utf8(b"message")), + json::get_elem( + &move_obj, string::utf8(b"async_callback") + ) + ) + } + + // + // Helper Functions + // + + fun callback_function_info(callback_fid: String): FunctionInfo { + let idx = string::index_of(&callback_fid, &string::utf8(b"::")); + let module_addr = string::sub_string(&callback_fid, 0, idx); + let callback_fid = + string::sub_string(&callback_fid, idx + 2, string::length(&callback_fid)); + let idx = string::index_of(&callback_fid, &string::utf8(b"::")); + let module_name = string::sub_string(&callback_fid, 0, idx); + let function_name = + string::sub_string(&callback_fid, idx + 2, string::length(&callback_fid)); + + new_function_info_for_testing( + address::from_string(module_addr), module_name, function_name + ) + } + + fun ibc_ack_callback_function_info( + module_addr: address, module_name: String + ): FunctionInfo { + new_function_info_for_testing( + module_addr, module_name, string::utf8(b"ibc_ack") + ) + } + + fun ibc_timeout_callback_function_info( + module_addr: address, module_name: String + ): FunctionInfo { + new_function_info_for_testing( + module_addr, module_name, string::utf8(b"ibc_timeout") + ) + } + + fun dispatchable_on_receive_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_on_receive") + ) + } + + fun dispatchable_callback_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_callback") + ) + } + + fun dispatchable_ibc_ack_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_ibc_ack") + ) + } + + fun dispatchable_ibc_timeout_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_ibc_timeout") + ) + } + + // + // Types + // + + struct ChainStore has key, drop { + packets: vector, + results: vector + } + + struct TransferRequest has copy, drop, store { + _type_: String, + source_port: String, + source_channel: String, + sender: String, + receiver: String, + token: CosmosCoin, + timeout_height: TimeoutHeight, + timeout_timestamp: u64, + memo: String + } + + struct TransferResult has copy, drop, store { + success: bool, + timeout: bool, + async_callback: Option + } + + struct TimeoutHeight has copy, drop, store { + revision_number: u64, + revision_height: u64 + } + + struct CosmosCoin has copy, drop, store { + denom: String, + amount: u64 + } + + struct MoveMessage has copy, drop, store { + module_address: address, + module_name: String, + function_name: String, + type_args: vector, + args: vector + } + + struct MoveAsyncCallback has copy, drop, store { + id: u64, + module_address: address, + module_name: String + } + + // + // Struct Unpacking + // + + public fun new_move_message( + module_address: address, + module_name: String, + function_name: String, + type_args: vector, + args: vector + ): MoveMessage { + MoveMessage { module_address, module_name, function_name, type_args, args } + } + + // + // Native Functions + // + + native fun dispatchable_callback( + callback_id: u64, success: bool, f: &FunctionInfo + ); + native fun dispatchable_on_receive( + recipient: &signer, message: &Option, f: &FunctionInfo + ): bool; + native fun dispatchable_ibc_ack( + callback_id: u64, success: bool, f: &FunctionInfo + ); + native fun dispatchable_ibc_timeout( + callback_id: u64, f: &FunctionInfo + ); +} diff --git a/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting_utils.move b/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting_utils.move new file mode 100644 index 00000000..1077dbb3 --- /dev/null +++ b/precompile/modules/initia_stdlib/sources/ibctesting/ibctesting_utils.move @@ -0,0 +1,52 @@ +#[test_only] +module initia_std::ibctesting_utils { + use std::string::{Self, String, utf8}; + use std::object::Object; + use std::fungible_asset::{Self, Metadata}; + use std::vector; + use std::managed_coin; + use std::account; + use std::coin; + use std::option; + use std::hash::sha3_256; + use std::from_bcs::to_address; + + public fun counterparty_metadata(metadata: Object): Object { + let counterparty_symbol = counterparty_symbol(metadata); + coin::metadata(@std, counterparty_symbol) + } + + public fun intermediate_sender(channel: String, sender: String): address { + let seed = channel; + string::append(&mut seed, sender); + let seed_bytes = *string::bytes(&seed); + let prefix_bytes = b"ibc-move-hook-intermediary"; + + let buf = sha3_256(prefix_bytes); + vector::append(&mut buf, seed_bytes); + to_address(sha3_256(buf)) + } + + public fun counterparty_symbol(metadata: Object): String { + let symbol = fungible_asset::symbol(metadata); + let symbol_bytes = string::bytes(&symbol); + let counterparty_symbol = vector::empty(); + vector::append(&mut counterparty_symbol, b"counterparty_"); + vector::append(&mut counterparty_symbol, *symbol_bytes); + utf8(counterparty_symbol) + } + + public fun create_counterparty_token(metadata: Object) { + let chain_signer = account::create_signer_for_test(@std); + let counterparty_symbol = counterparty_symbol(metadata); + managed_coin::initialize( + &chain_signer, + option::none(), + utf8(b"ibctesting"), + counterparty_symbol, + 0u8, + utf8(b""), + utf8(b"") + ); + } +} diff --git a/precompile/modules/initia_stdlib/sources/json.move b/precompile/modules/initia_stdlib/sources/json.move index dde31e8c..5e9b1c9d 100644 --- a/precompile/modules/initia_stdlib/sources/json.move +++ b/precompile/modules/initia_stdlib/sources/json.move @@ -21,7 +21,7 @@ module initia_std::json { /// Unmarshal JSON value to the given type. public fun unmarshal_json_value(json_value: JSONValue): T { - unmarshal(json_value.value) + unmarshal_internal(json_value.value) } /// Get the list of keys from the JSON object. @@ -51,7 +51,7 @@ module initia_std::json { }; let elem = vector::borrow(&obj.elems, idx); - option::some(unmarshal(elem.value)) + option::some(unmarshal_internal(elem.value)) } /// Set or overwrite the element in the JSON object. diff --git a/precompile/modules/initia_stdlib/tests/ibc_transfer_tests.move b/precompile/modules/initia_stdlib/tests/ibc_transfer_tests.move new file mode 100644 index 00000000..69481bdd --- /dev/null +++ b/precompile/modules/initia_stdlib/tests/ibc_transfer_tests.move @@ -0,0 +1,550 @@ +#[test_only] +module cafe::ibc_transfer_tests { + use std::account::create_signer_for_test; + use std::unit_test::create_signers_for_testing; + use std::vector; + use std::managed_coin; + use std::coin; + use std::string::{Self, String, utf8}; + use std::option; + use std::signer; + use std::cosmos; + use std::address; + use std::block; + use std::ibctesting; + use std::json; + use std::object::Object; + use std::fungible_asset::Metadata; + use std::function_info::new_function_info_for_testing; + use cafe::ibc_transfer_tests_helpers::{ + store_on_callback_request, + check_on_callback_response, + store_on_receive_request, + check_on_receive_response, + store_on_timeout_request, + check_on_timeout_response, + store_on_ack_request, + check_on_ack_response + }; + + #[test] + fun test_ibc_transfer_success() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\": {\"message\": {\"module_address\":\"0xcafe\", \"module_name\":\"test\", \"function_name\":\"test\", \"type_args\":[\"test1\",\"test2\"], \"args\": [\"test1\", \"test2\"]}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + + let type_args = vector::empty(); + vector::push_back(&mut type_args, utf8(b"test1")); + vector::push_back(&mut type_args, utf8(b"test2")); + let args = vector::empty(); + vector::push_back(&mut args, utf8(b"test1")); + vector::push_back(&mut args, utf8(b"test2")); + let expected_msg = + ibctesting::new_move_message( + @cafe, + utf8(b"test"), + utf8(b"test"), + type_args, + args + ); + store_on_receive_request(&option::some(expected_msg), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + } + + #[test] + fun test_ibc_transfer_fail_with_allow_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000_001u64 }, // put more than balance + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 101u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request(*vector::borrow(&addrs, 0), 0u64, false, 101u64); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // called with failure + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // on_receive not called + check_on_receive_response(false); + } + + #[test] + fun test_ibc_transfer_success_without_callback() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // without callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::disallow_failure() + ); + + // store requests + // store_on_callback_request(*vector::borrow(&addrs, 0), 1_000u64, true); + store_on_receive_request(&option::none(), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // not called + check_on_callback_response(false); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + } + + #[test] + #[expected_failure(abort_code = 0x3, location = 0x1::ibctesting)] + fun test_ibc_transfer_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000_001u64 }, // put more than balance + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // without callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::disallow_failure() + ); + + // store requests + // store_on_callback_request(*vector::borrow(&addrs, 0), 1_000u64, true); + store_on_receive_request(&option::none(), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + } + + #[test] + fun test_ibc_transfer_timeout() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), true); + store_on_timeout_request(103u64, *vector::borrow(&addrs, 0)); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // set block info to raise timeout + block::set_block_info(20u64, 0u64); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should not be called + check_on_receive_response(false); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(true); + + // ack should not be called + check_on_ack_response(false); + } + + #[test] + fun test_ibc_transfer_ack_success() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), true); + store_on_ack_request( + 103u64, + true, + *vector::borrow(&addrs, 0), + 1_000u64 + ); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(false); + + // ack should not be called + check_on_ack_response(true); + } + + #[test] + fun test_ibc_transfer_ack_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), false); // trigger fail on receive + store_on_ack_request( + 103u64, + false, + *vector::borrow(&addrs, 0), + 1_000u64 + ); // expect ack to receive failure + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(false); + + // ack should not be called + check_on_ack_response(true); + } + + // + // Helpers + // + + fun init_metadata(): Object { + coin::metadata(@std, string::utf8(b"uinit")) + } + + fun create_init_token() { + let chain_signer = create_signer_for_test(@std); + managed_coin::initialize( + &chain_signer, + option::none(), + string::utf8(b"INIT"), + string::utf8(b"uinit"), + 0u8, + string::utf8(b""), + string::utf8(b"") + ); + } + + fun fund_init_token(recipient: address, amount: u64) { + let chain_signer = create_signer_for_test(@std); + let metadata = init_metadata(); + managed_coin::mint_to(&chain_signer, recipient, metadata, amount); + } + + // + // Types + // + + struct TransferRequest has copy, drop { + _type_: String, + source_port: String, + source_channel: String, + sender: String, + receiver: String, + token: CosmosCoin, + timeout_height: TimeoutHeight, + timeout_timestamp: u64, + memo: String + } + + struct CosmosCoin has copy, drop { + denom: String, + amount: u64 + } + + struct TimeoutHeight has copy, drop { + revision_number: u64, + revision_height: u64 + } +} diff --git a/precompile/modules/initia_stdlib/tests/ibc_transfer_tests_helpers.move b/precompile/modules/initia_stdlib/tests/ibc_transfer_tests_helpers.move new file mode 100644 index 00000000..cef30126 --- /dev/null +++ b/precompile/modules/initia_stdlib/tests/ibc_transfer_tests_helpers.move @@ -0,0 +1,183 @@ +#[test_only] +module cafe::ibc_transfer_tests_helpers { + use std::signer; + use std::option::{Self, Option}; + use std::coin; + use std::ibctesting; + use std::ibctesting_utils; + use std::string::utf8; + use std::fungible_asset::Metadata; + use std::object::Object; + use std::account::create_signer_for_test; + + struct OnCallbackRequest has key { + sender: address, + amount: u64, + result: bool, + id: u64 + } + + struct OnCallbackResponse has key {} + + struct OnReceiveRequest has key { + msg_opt: Option, + result: bool + } + + struct OnReceiveResponse has key {} + + struct OnAckRequest has key { + id: u64, + result: bool, + amount: u64, + sender: address + } + + struct OnAckResponse has key {} + + struct OnTimeoutRequest has key { + id: u64, + sender: address + } + + struct OnTimeoutResponse has key {} + + public fun store_on_callback_request( + sender: address, amount: u64, expected_result: bool, id: u64 + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnCallbackRequest { sender, amount, result: expected_result, id } + ); + } + + public fun check_on_callback_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_receive_request( + msg_opt: &Option, on_receive_result: bool + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnReceiveRequest { msg_opt: *msg_opt, result: on_receive_result } + ); + } + + public fun check_on_receive_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_ack_request( + id: u64, expected_result: bool, sender: address, amount: u64 + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnAckRequest { id, result: expected_result, sender, amount } + ); + } + + public fun check_on_ack_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_timeout_request(id: u64, sender: address) { + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnTimeoutRequest { id, sender }); + } + + public fun check_on_timeout_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun on_callback(id: u64, success: bool) acquires OnCallbackRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + + // check balances + if (success) { + assert!( + coin::balance(request.sender, init_metadata()) + == 1_000_000u64 - request.amount, + 0 + ); + } else { + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 0); + }; + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnCallbackResponse {}); + } + + public fun on_receive( + recipient: &signer, msg_opt: &Option + ): bool acquires OnReceiveRequest { + // check counterparty balance + let counterparty_metadata = + ibctesting_utils::counterparty_metadata(init_metadata()); + assert!( + coin::balance(signer::address_of(recipient), counterparty_metadata) + == 1_000u64, + 1 + ); + + let request = borrow_global_mut(@std); + + assert!(option::is_some(&request.msg_opt) == option::is_some(msg_opt), 2); + if (option::is_some(&request.msg_opt)) { + assert!( + option::destroy_some(request.msg_opt) == option::destroy_some(*msg_opt), + 3 + ); + }; + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnReceiveResponse {}); + + // success + request.result + } + + public fun ibc_ack(id: u64, success: bool) acquires OnAckRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + assert!(request.result == success, 1); + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnAckResponse {}); + + if (success) { + // balance should be restored + assert!( + coin::balance(request.sender, init_metadata()) + == 1_000_000u64 - request.amount, + 2 + ); + } else { + // balance should be restored + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 1); + } + } + + public fun ibc_timeout(id: u64) acquires OnTimeoutRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnTimeoutResponse {}); + + // balance should be restored + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 1); + } + + public fun init_metadata(): Object { + coin::metadata(@std, utf8(b"uinit")) + } +} diff --git a/precompile/modules/minitia_stdlib/Move.toml b/precompile/modules/minitia_stdlib/Move.toml index f0ae7a14..869367de 100644 --- a/precompile/modules/minitia_stdlib/Move.toml +++ b/precompile/modules/minitia_stdlib/Move.toml @@ -9,4 +9,5 @@ MoveNursery = { local = "../move_nursery" } [addresses] std = "0x1" minitia_std = "0x1" -relayer = "0x3d18d54532fc42e567090852db6eb21fa528f952" \ No newline at end of file +relayer = "0x3d18d54532fc42e567090852db6eb21fa528f952" +cafe = "0xcafe" diff --git a/precompile/modules/minitia_stdlib/sources/address.move b/precompile/modules/minitia_stdlib/sources/address.move index 6636badd..3b371687 100644 --- a/precompile/modules/minitia_stdlib/sources/address.move +++ b/precompile/modules/minitia_stdlib/sources/address.move @@ -82,7 +82,7 @@ module minitia_std::address { assert!(addr == from_sdk(addr_sdk), 0) } - // string <> address + // hex string <> address native public fun to_string(addr: address): String; native public fun from_string(addr_str: String): address; diff --git a/precompile/modules/minitia_stdlib/sources/cosmos.move b/precompile/modules/minitia_stdlib/sources/cosmos.move index ce745752..62d44bd5 100644 --- a/precompile/modules/minitia_stdlib/sources/cosmos.move +++ b/precompile/modules/minitia_stdlib/sources/cosmos.move @@ -414,17 +414,30 @@ module minitia_std::cosmos { ) } + // + // Native Functions + // + native fun stargate_internal( sender: address, data: vector, option: Options ); #[test_only] - native public fun requested_messages(): vector; + native public fun requested_messages(): (vector, vector); #[test_only] public fun was_message_requested(msg: &String): bool { + was_message_requested_with_options(msg, &disallow_failure()) + } + + #[test_only] + public fun was_message_requested_with_options( + msg: &String, opt: &Options + ): bool { use std::vector; - vector::contains(&requested_messages(), msg) + let (messages, opts) = requested_messages(); + let (found, idx) = vector::index_of(&messages, msg); + found && vector::borrow(&opts, idx) == opt } // ================================================== Options ================================================= @@ -493,6 +506,11 @@ module minitia_std::cosmos { } } + /// Unpack options for external use + public fun unpack_options(opt: Options): (bool, u64, String) { + (opt.allow_failure, opt.callback_id, string::utf8(opt.callback_fid)) + } + //=========================================== Tests =========================================== #[test(sender = @0xcafe)] @@ -518,4 +536,37 @@ module minitia_std::cosmos { assert!(was_message_requested(&msg), 1); } + + #[test(sender = @0xcafe)] + public fun test_stargate_with_options(sender: &signer) { + use std::string::{utf8, bytes}; + + let voter = utf8(b"voter"); + let proposal_id = 1; + let option = 1; + let metadata = utf8(b"metadata"); + let msg = + json::marshal_to_string( + &VoteRequest { + _type_: utf8(b"/cosmos.gov.v1.MsgVote"), + proposal_id, + voter: voter, + option, + metadata: metadata + } + ); + + stargate_with_options( + sender, + *bytes(&msg), + allow_failure_with_callback(1, utf8(b"0x1::test::test_fn")) + ); + + assert!( + was_message_requested_with_options( + &msg, &allow_failure_with_callback(1, utf8(b"0x1::test::test_fn")) + ), + 1 + ); + } } diff --git a/precompile/modules/minitia_stdlib/sources/function_info.move b/precompile/modules/minitia_stdlib/sources/function_info.move index 780e10bb..deac84cf 100644 --- a/precompile/modules/minitia_stdlib/sources/function_info.move +++ b/precompile/modules/minitia_stdlib/sources/function_info.move @@ -84,4 +84,23 @@ module minitia_std::function_info { // Test only dependencies so we can invoke those friend functions. #[test_only] friend minitia_std::function_info_tests; + + #[test_only] + public fun load_module_from_function_for_testing(f: &FunctionInfo) { + load_module_from_function(f) + } + + #[test_only] + public fun new_function_info_for_testing( + module_address: address, module_name: String, function_name: String + ): FunctionInfo { + new_function_info_from_address(module_address, module_name, function_name) + } + + #[test_only] + public fun check_dispatch_type_compatibility_for_testing( + framework_function: &FunctionInfo, dispatch_target: &FunctionInfo + ): bool { + check_dispatch_type_compatibility(framework_function, dispatch_target) + } } diff --git a/precompile/modules/minitia_stdlib/sources/ibctesting/README.md b/precompile/modules/minitia_stdlib/sources/ibctesting/README.md new file mode 100644 index 00000000..d71bc5ca --- /dev/null +++ b/precompile/modules/minitia_stdlib/sources/ibctesting/README.md @@ -0,0 +1,53 @@ +# IBC Testing + +This package provides convenient unit-test tools for IBC operations. + +## How to Use + +There are three steps in IBC testing: + +### `execute_cosmos_messages()` + +This function checks pending Cosmos messages and verifies if a message can be executed. + +- If a message is executable, it transfers the requested token from the sender to the `@std` address. +- Otherwise, it performs no operation. + +If a message is registered with options at `cosmos::stargate_with_options`, it will check and execute the given callback function if option contains callback. There are four cases for these options excluding callback options: + +| Success | Option (allow_failure) | Abort | Revert | +|---------|-------------------------|-------|--------| +| Failed | true | false | true | +| Failed | false | true | true | +| True | true | false | false | +| True | false | false | false | + +Refer to [ibc_transfer_tests.move](../../tests/ibc_transfer_tests.move) and [ibc_transfer_tests_helpers.move#95](../../tests/ibc_transfer_tests_helpers.move#95) for details. + +### `relay_packets()` + +If there is at least one executable IBC transfer from the previous step, it will simulate IBC packet relaying by depositing the counterparty token to the recipient and executing the `on_receive` dispatch function. + +The `on_receive` function should have the following signature: + +```rust +public fun on_receive(recipient: &signer, msg_opt: &Option): bool +``` + +In the `on_receive` function, you can verify the passed message and ensure the counterparty token is correctly received. Refer to [ibc_transfer_tests_helpers.move#115](../../tests/ibc_transfer_tests_helpers.move#115) for details. + +Based on the response of `on_receive`, the IBC packet relaying actions decide whether to execute acknowledgment with success or failure. Refer to [ibc_transfer_tests.move#222](../../tests/ibc_transfer_tests.move#222) for details. + +You can also use `block::set_block_info` to simulate timeout cases. Refer to [ibc_transfer_tests.move#264](../../tests/ibc_transfer_tests.move#264) for details. + +### `relay_acks_timeouts()` + +Initia provides an async callback feature to allow a dApp developer to receive IBC packet success notifications. For more details, see [Initia IBC Hooks](https://github.com/initia-labs/initia/tree/main/x/ibc-hooks/move-hooks). + +If a message contains a memo field like `{"move": {"message": {}, "async_callback": {"id": "100", "module_address": "0xabc", "module_name": "testing"}}}`, it will execute `0xabc::testing::ibc_ack` or `0xabc::testing::ibc_timeout` according to the result of IBC packet relaying. + +## Avoid Re-entrancy in Unit Tests + +The IBC testing package is built with the dispatch function of Aptos Move, which does not allow re-entrancy. When writing testing scripts, ensure that you do not call `ibctesting` or test modules from callback functions (`on_callback`, `on_receive`, `ibc_ack`, `ibc_timeout`). + +To avoid re-entrancy issues, create a new test module, as demonstrated in [`ibc_transfer_tests`](../../tests/ibc_transfer_tests.move). diff --git a/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting.move b/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting.move new file mode 100644 index 00000000..826d6045 --- /dev/null +++ b/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting.move @@ -0,0 +1,439 @@ +#[test_only] +module minitia_std::ibctesting { + use std::string::{Self, String, utf8}; + use std::object; + use std::fungible_asset::Metadata; + use std::vector; + use std::managed_coin; + use std::account; + use std::coin; + use std::option::{Self, Option}; + use std::cosmos::{Self, Options}; + use std::json::{Self, JSONObject}; + use std::block; + use std::address; + use std::function_info::{ + FunctionInfo, + new_function_info_for_testing, + check_dispatch_type_compatibility_for_testing + }; + use std::ibctesting_utils::{ + counterparty_symbol, + intermediate_sender, + create_counterparty_token + }; + + // + // Errors + // + + const EIBC_TESTING_IN_PROGRESS: u64 = 0x1; + const EIBC_TESTING_PACKETS_NOT_RELAYED: u64 = 0x2; + const ECOSMOS_MESSAGE_FAILED: u64 = 0x3; + + const ENOT_SUPPORTED_COSMOS_MESSAGE: u64 = 0x10; + const EINVALID_TIMEOUT: u64 = 0x11; + const EZERO_AMOUNT: u64 = 0x12; + + // + // Chain Operations + // + + /// Execute the requested cosmos messages and store the packets into the chain. + public fun execute_cosmos_messages() { + assert!(!exists(@std), EIBC_TESTING_IN_PROGRESS); + + let (messages, opts) = cosmos::requested_messages(); + let packets = vector::empty(); + vector::zip( + messages, + opts, + |msg, opt| { + let transfer_req = json::unmarshal(*string::bytes(&msg)); + assert!( + *string::bytes(&transfer_req._type_) + == b"/ibc.applications.transfer.v1.MsgTransfer", + ENOT_SUPPORTED_COSMOS_MESSAGE + ); + + // should have at least one of timeout_height or timeout_timestamp + assert!( + transfer_req.timeout_height.revision_height != 0 + || transfer_req.timeout_timestamp != 0, + EINVALID_TIMEOUT + ); + + // amount should be greater than 0 + assert!(transfer_req.token.amount > 0, EZERO_AMOUNT); + + if (execute(transfer_req, opt)) { + vector::push_back(&mut packets, transfer_req); + }; + } + ); + + let chain_signer = account::create_signer_for_test(@std); + move_to( + &chain_signer, ChainStore { packets, results: vector::empty() } + ); + } + + /// Relay the packets to the counterparty chain. + /// If the block height or timestamp is reached to timeout of a packet, then send a timeout message. + /// + /// The caller must implement the `on_receive` dispatchable function to decide the success of the transfer. + /// The caller also can use this callback to execute destination hook functions. + /// + /// fun on_receive(recipient: &signer, message: &Option): bool; + public fun relay_packets(on_receive: &FunctionInfo) acquires ChainStore { + if (!exists(@std)) { + return; + }; + + let (height, timestamp) = block::get_block_info(); + + let chain_data = borrow_global_mut(@std); + vector::for_each( + chain_data.packets, + |packet| { + let (message, async_callback) = unmarshal_memo(packet.memo); + + // check timeout (multiply timeout timestamp by 1_000_000_000 to convert to nanoseconds) + if (( + packet.timeout_height.revision_height != 0 + && packet.timeout_height.revision_height <= height + ) + || ( + packet.timeout_timestamp != 0 + && packet.timeout_timestamp <= timestamp * 1_000_000_000u64 + )) { + vector::push_back( + &mut chain_data.results, + TransferResult { success: false, timeout: true, async_callback } + ); + + // return not supported yet + // return; + } else { + // do on_receive actions + let denom = packet.token.denom; + let metadata = coin::denom_to_metadata(denom); + let counterparty_symbol = counterparty_symbol(metadata); + let counterparty_metadata_addr = + coin::metadata_address(@std, counterparty_symbol); + if (!object::object_exists(counterparty_metadata_addr)) { + create_counterparty_token(metadata); + }; + + let counterparty_metadata = + object::address_to_object(counterparty_metadata_addr); + let recipient = + if (option::is_some(&message)) { + // In order to get actual intermediate sender on destination chain, we need to put destination channel + // but it is not available in the packet yet. so we use source channel as a temporary solution. + intermediate_sender(packet.source_channel, packet.receiver) + } else { + // send to the recipient + address::from_sdk(packet.receiver) + }; + + // mint token to the recipient + managed_coin::mint_to( + &account::create_signer_for_test(@std), + recipient, + counterparty_metadata, + packet.token.amount + ); + + // execute the callback to decide the success of the transfer + check_dispatch_type_compatibility_for_testing( + &dispatchable_on_receive_function_info(), on_receive + ); + let success = + dispatchable_on_receive( + &account::create_signer_for_test(recipient), + &message, + on_receive + ); + vector::push_back( + &mut chain_data.results, + TransferResult { success, timeout: false, async_callback } + ); + } + } + ); + } + + public fun relay_acks_timeouts() acquires ChainStore { + if (!exists(@std)) { + return; + }; + + let chain_data = move_from(@std); + assert!( + vector::length(&chain_data.packets) == vector::length(&chain_data.results), + EIBC_TESTING_PACKETS_NOT_RELAYED + ); + + vector::zip( + chain_data.packets, + chain_data.results, + |packet, result| { + + // refund coin to the sender if the transfer is failed + if (!result.success) { + let denom = packet.token.denom; + let metadata = coin::denom_to_metadata(denom); + let sender = address::from_sdk(packet.sender); + + coin::transfer( + &account::create_signer_for_test(@std), + sender, + metadata, + packet.token.amount + ); + }; + + if (result.timeout && option::is_some(&result.async_callback)) { + let async_callback = option::destroy_some(result.async_callback); + let function_info = + ibc_timeout_callback_function_info( + async_callback.module_address, + async_callback.module_name + ); + + check_dispatch_type_compatibility_for_testing( + &dispatchable_ibc_timeout_function_info(), &function_info + ); + dispatchable_ibc_timeout(async_callback.id, &function_info); + } else if (option::is_some(&result.async_callback)) { + let async_callback = option::destroy_some(result.async_callback); + let function_info = + ibc_ack_callback_function_info( + async_callback.module_address, + async_callback.module_name + ); + + check_dispatch_type_compatibility_for_testing( + &dispatchable_ibc_ack_function_info(), &function_info + ); + dispatchable_ibc_ack( + async_callback.id, result.success, &function_info + ); + }; + } + ); + } + + // + // Internal Functions + // + + /// Execute the transfer request and return true if the transfer is successful. Otherwise, return false. + fun execute(transfer_req: TransferRequest, opt: Options): bool { + let (allow_failure, callback_id, callback_fid) = cosmos::unpack_options(opt); + + // check balance to check if the sender has enough funds + let denom = transfer_req.token.denom; + let metadata = coin::denom_to_metadata(denom); + let sender_addr = address::from_sdk(transfer_req.sender); + let balance = coin::balance(sender_addr, metadata); + if (balance < transfer_req.token.amount) { + // balance not enough; send failure message + + // if allow_failure is false, then abort the transaction + assert!(allow_failure, ECOSMOS_MESSAGE_FAILED); + + if (callback_id > 0) { + let function_info = callback_function_info(callback_fid); + check_dispatch_type_compatibility_for_testing( + &dispatchable_callback_function_info(), &function_info + ); + dispatchable_callback(callback_id, false, &function_info); + }; + + return false; + }; + + // withdraw token from the sender + let sender_signer = account::create_signer_for_test(sender_addr); + coin::transfer( + &sender_signer, + @std, + metadata, + transfer_req.token.amount + ); + + if (callback_id > 0) { + let function_info = callback_function_info(callback_fid); + check_dispatch_type_compatibility_for_testing( + &dispatchable_callback_function_info(), &function_info + ); + dispatchable_callback(callback_id, true, &function_info); + }; + + true + } + + fun unmarshal_memo(memo: String): (Option, Option) { + let memo_bytes = string::bytes(&memo); + if (vector::length(memo_bytes) == 0) { + return (option::none(), option::none()); + }; + + let memo_obj = json::unmarshal(*memo_bytes); + let move_obj = json::get_elem(&memo_obj, string::utf8(b"move")); + if (option::is_none(&move_obj)) { + return (option::none(), option::none()); + }; + + let move_obj = option::destroy_some(move_obj); + ( + json::get_elem(&move_obj, string::utf8(b"message")), + json::get_elem( + &move_obj, string::utf8(b"async_callback") + ) + ) + } + + // + // Helper Functions + // + + fun callback_function_info(callback_fid: String): FunctionInfo { + let idx = string::index_of(&callback_fid, &string::utf8(b"::")); + let module_addr = string::sub_string(&callback_fid, 0, idx); + let callback_fid = + string::sub_string(&callback_fid, idx + 2, string::length(&callback_fid)); + let idx = string::index_of(&callback_fid, &string::utf8(b"::")); + let module_name = string::sub_string(&callback_fid, 0, idx); + let function_name = + string::sub_string(&callback_fid, idx + 2, string::length(&callback_fid)); + + new_function_info_for_testing( + address::from_string(module_addr), module_name, function_name + ) + } + + fun ibc_ack_callback_function_info( + module_addr: address, module_name: String + ): FunctionInfo { + new_function_info_for_testing( + module_addr, module_name, string::utf8(b"ibc_ack") + ) + } + + fun ibc_timeout_callback_function_info( + module_addr: address, module_name: String + ): FunctionInfo { + new_function_info_for_testing( + module_addr, module_name, string::utf8(b"ibc_timeout") + ) + } + + fun dispatchable_on_receive_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_on_receive") + ) + } + + fun dispatchable_callback_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_callback") + ) + } + + fun dispatchable_ibc_ack_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_ibc_ack") + ) + } + + fun dispatchable_ibc_timeout_function_info(): FunctionInfo { + new_function_info_for_testing( + @std, utf8(b"ibctesting"), utf8(b"dispatchable_ibc_timeout") + ) + } + + // + // Types + // + + struct ChainStore has key, drop { + packets: vector, + results: vector + } + + struct TransferRequest has copy, drop, store { + _type_: String, + source_port: String, + source_channel: String, + sender: String, + receiver: String, + token: CosmosCoin, + timeout_height: TimeoutHeight, + timeout_timestamp: u64, + memo: String + } + + struct TransferResult has copy, drop, store { + success: bool, + timeout: bool, + async_callback: Option + } + + struct TimeoutHeight has copy, drop, store { + revision_number: u64, + revision_height: u64 + } + + struct CosmosCoin has copy, drop, store { + denom: String, + amount: u64 + } + + struct MoveMessage has copy, drop, store { + module_address: address, + module_name: String, + function_name: String, + type_args: vector, + args: vector + } + + struct MoveAsyncCallback has copy, drop, store { + id: u64, + module_address: address, + module_name: String + } + + // + // Struct Unpacking + // + + public fun new_move_message( + module_address: address, + module_name: String, + function_name: String, + type_args: vector, + args: vector + ): MoveMessage { + MoveMessage { module_address, module_name, function_name, type_args, args } + } + + // + // Native Functions + // + + native fun dispatchable_callback( + callback_id: u64, success: bool, f: &FunctionInfo + ); + native fun dispatchable_on_receive( + recipient: &signer, message: &Option, f: &FunctionInfo + ): bool; + native fun dispatchable_ibc_ack( + callback_id: u64, success: bool, f: &FunctionInfo + ); + native fun dispatchable_ibc_timeout( + callback_id: u64, f: &FunctionInfo + ); +} diff --git a/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting_utils.move b/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting_utils.move new file mode 100644 index 00000000..0c944218 --- /dev/null +++ b/precompile/modules/minitia_stdlib/sources/ibctesting/ibctesting_utils.move @@ -0,0 +1,52 @@ +#[test_only] +module minitia_std::ibctesting_utils { + use std::string::{Self, String, utf8}; + use std::object::Object; + use std::fungible_asset::{Self, Metadata}; + use std::vector; + use std::managed_coin; + use std::account; + use std::coin; + use std::option; + use std::hash::sha3_256; + use std::from_bcs::to_address; + + public fun counterparty_metadata(metadata: Object): Object { + let counterparty_symbol = counterparty_symbol(metadata); + coin::metadata(@std, counterparty_symbol) + } + + public fun intermediate_sender(channel: String, sender: String): address { + let seed = channel; + string::append(&mut seed, sender); + let seed_bytes = *string::bytes(&seed); + let prefix_bytes = b"ibc-move-hook-intermediary"; + + let buf = sha3_256(prefix_bytes); + vector::append(&mut buf, seed_bytes); + to_address(sha3_256(buf)) + } + + public fun counterparty_symbol(metadata: Object): String { + let symbol = fungible_asset::symbol(metadata); + let symbol_bytes = string::bytes(&symbol); + let counterparty_symbol = vector::empty(); + vector::append(&mut counterparty_symbol, b"counterparty_"); + vector::append(&mut counterparty_symbol, *symbol_bytes); + utf8(counterparty_symbol) + } + + public fun create_counterparty_token(metadata: Object) { + let chain_signer = account::create_signer_for_test(@std); + let counterparty_symbol = counterparty_symbol(metadata); + managed_coin::initialize( + &chain_signer, + option::none(), + utf8(b"ibctesting"), + counterparty_symbol, + 0u8, + utf8(b""), + utf8(b"") + ); + } +} diff --git a/precompile/modules/minitia_stdlib/sources/json.move b/precompile/modules/minitia_stdlib/sources/json.move index 2bc9db6b..d91b7a1c 100644 --- a/precompile/modules/minitia_stdlib/sources/json.move +++ b/precompile/modules/minitia_stdlib/sources/json.move @@ -21,7 +21,7 @@ module minitia_std::json { /// Unmarshal JSON value to the given type. public fun unmarshal_json_value(json_value: JSONValue): T { - unmarshal(json_value.value) + unmarshal_internal(json_value.value) } /// Get the list of keys from the JSON object. @@ -51,7 +51,7 @@ module minitia_std::json { }; let elem = vector::borrow(&obj.elems, idx); - option::some(unmarshal(elem.value)) + option::some(unmarshal_internal(elem.value)) } /// Set or overwrite the element in the JSON object. diff --git a/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests.move b/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests.move new file mode 100644 index 00000000..69481bdd --- /dev/null +++ b/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests.move @@ -0,0 +1,550 @@ +#[test_only] +module cafe::ibc_transfer_tests { + use std::account::create_signer_for_test; + use std::unit_test::create_signers_for_testing; + use std::vector; + use std::managed_coin; + use std::coin; + use std::string::{Self, String, utf8}; + use std::option; + use std::signer; + use std::cosmos; + use std::address; + use std::block; + use std::ibctesting; + use std::json; + use std::object::Object; + use std::fungible_asset::Metadata; + use std::function_info::new_function_info_for_testing; + use cafe::ibc_transfer_tests_helpers::{ + store_on_callback_request, + check_on_callback_response, + store_on_receive_request, + check_on_receive_response, + store_on_timeout_request, + check_on_timeout_response, + store_on_ack_request, + check_on_ack_response + }; + + #[test] + fun test_ibc_transfer_success() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\": {\"message\": {\"module_address\":\"0xcafe\", \"module_name\":\"test\", \"function_name\":\"test\", \"type_args\":[\"test1\",\"test2\"], \"args\": [\"test1\", \"test2\"]}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + + let type_args = vector::empty(); + vector::push_back(&mut type_args, utf8(b"test1")); + vector::push_back(&mut type_args, utf8(b"test2")); + let args = vector::empty(); + vector::push_back(&mut args, utf8(b"test1")); + vector::push_back(&mut args, utf8(b"test2")); + let expected_msg = + ibctesting::new_move_message( + @cafe, + utf8(b"test"), + utf8(b"test"), + type_args, + args + ); + store_on_receive_request(&option::some(expected_msg), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + } + + #[test] + fun test_ibc_transfer_fail_with_allow_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000_001u64 }, // put more than balance + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 101u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request(*vector::borrow(&addrs, 0), 0u64, false, 101u64); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // called with failure + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // on_receive not called + check_on_receive_response(false); + } + + #[test] + fun test_ibc_transfer_success_without_callback() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // without callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::disallow_failure() + ); + + // store requests + // store_on_callback_request(*vector::borrow(&addrs, 0), 1_000u64, true); + store_on_receive_request(&option::none(), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // not called + check_on_callback_response(false); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + } + + #[test] + #[expected_failure(abort_code = 0x3, location = 0x1::ibctesting)] + fun test_ibc_transfer_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000_001u64 }, // put more than balance + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8(b"") + }; + + // without callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::disallow_failure() + ); + + // store requests + // store_on_callback_request(*vector::borrow(&addrs, 0), 1_000u64, true); + store_on_receive_request(&option::none(), true); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + } + + #[test] + fun test_ibc_transfer_timeout() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), true); + store_on_timeout_request(103u64, *vector::borrow(&addrs, 0)); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // set block info to raise timeout + block::set_block_info(20u64, 0u64); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should not be called + check_on_receive_response(false); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(true); + + // ack should not be called + check_on_ack_response(false); + } + + #[test] + fun test_ibc_transfer_ack_success() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), true); + store_on_ack_request( + 103u64, + true, + *vector::borrow(&addrs, 0), + 1_000u64 + ); + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(false); + + // ack should not be called + check_on_ack_response(true); + } + + #[test] + fun test_ibc_transfer_ack_failure() { + create_init_token(); + + // initialize block info + block::set_block_info(1u64, 0u64); + + let signers = create_signers_for_testing(2); + let addrs = vector::map_ref(&signers, |s| signer::address_of(s)); + fund_init_token(*vector::borrow(&addrs, 0), 1_000_000u64); + + // without callback + let request = TransferRequest { + _type_: string::utf8(b"/ibc.applications.transfer.v1.MsgTransfer"), + source_port: string::utf8(b"transfer"), + source_channel: string::utf8(b"channel-0"), + sender: address::to_sdk(*vector::borrow(&addrs, 0)), + receiver: address::to_sdk(*vector::borrow(&addrs, 1)), + token: CosmosCoin { denom: string::utf8(b"uinit"), amount: 1_000u64 }, + timeout_height: TimeoutHeight { + revision_number: 0u64, // unused in this test + revision_height: 10u64 // set timeout height to 10 + }, + timeout_timestamp: 0u64, // timeout timestamp is not used in this test + memo: string::utf8( + b"{\"move\":{\"async_callback\":{\"id\": \"103\", \"module_address\": \"0xcafe\", \"module_name\": \"ibc_transfer_tests_helpers\"}}}" + ) + }; + + // with callback + cosmos::stargate_with_options( + vector::borrow(&signers, 0), + json::marshal(&request), + cosmos::allow_failure_with_callback( + 100u64, utf8(b"0xcafe::ibc_transfer_tests_helpers::on_callback") + ) + ); + + // store requests + store_on_callback_request( + *vector::borrow(&addrs, 0), + 1_000u64, + true, + 100u64 + ); + store_on_receive_request(&option::none(), false); // trigger fail on receive + store_on_ack_request( + 103u64, + false, + *vector::borrow(&addrs, 0), + 1_000u64 + ); // expect ack to receive failure + + // chain operations (execute cosmos messages) + ibctesting::execute_cosmos_messages(); + + // callback should be called + check_on_callback_response(true); + + // chain operations (relay packets) + ibctesting::relay_packets( + &new_function_info_for_testing( + @cafe, utf8(b"ibc_transfer_tests_helpers"), utf8(b"on_receive") + ) + ); + + // receive should be called + check_on_receive_response(true); + + // relay acks and timeouts + ibctesting::relay_acks_timeouts(); + + // timeout should be called + check_on_timeout_response(false); + + // ack should not be called + check_on_ack_response(true); + } + + // + // Helpers + // + + fun init_metadata(): Object { + coin::metadata(@std, string::utf8(b"uinit")) + } + + fun create_init_token() { + let chain_signer = create_signer_for_test(@std); + managed_coin::initialize( + &chain_signer, + option::none(), + string::utf8(b"INIT"), + string::utf8(b"uinit"), + 0u8, + string::utf8(b""), + string::utf8(b"") + ); + } + + fun fund_init_token(recipient: address, amount: u64) { + let chain_signer = create_signer_for_test(@std); + let metadata = init_metadata(); + managed_coin::mint_to(&chain_signer, recipient, metadata, amount); + } + + // + // Types + // + + struct TransferRequest has copy, drop { + _type_: String, + source_port: String, + source_channel: String, + sender: String, + receiver: String, + token: CosmosCoin, + timeout_height: TimeoutHeight, + timeout_timestamp: u64, + memo: String + } + + struct CosmosCoin has copy, drop { + denom: String, + amount: u64 + } + + struct TimeoutHeight has copy, drop { + revision_number: u64, + revision_height: u64 + } +} diff --git a/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests_helpers.move b/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests_helpers.move new file mode 100644 index 00000000..cef30126 --- /dev/null +++ b/precompile/modules/minitia_stdlib/tests/ibc_transfer_tests_helpers.move @@ -0,0 +1,183 @@ +#[test_only] +module cafe::ibc_transfer_tests_helpers { + use std::signer; + use std::option::{Self, Option}; + use std::coin; + use std::ibctesting; + use std::ibctesting_utils; + use std::string::utf8; + use std::fungible_asset::Metadata; + use std::object::Object; + use std::account::create_signer_for_test; + + struct OnCallbackRequest has key { + sender: address, + amount: u64, + result: bool, + id: u64 + } + + struct OnCallbackResponse has key {} + + struct OnReceiveRequest has key { + msg_opt: Option, + result: bool + } + + struct OnReceiveResponse has key {} + + struct OnAckRequest has key { + id: u64, + result: bool, + amount: u64, + sender: address + } + + struct OnAckResponse has key {} + + struct OnTimeoutRequest has key { + id: u64, + sender: address + } + + struct OnTimeoutResponse has key {} + + public fun store_on_callback_request( + sender: address, amount: u64, expected_result: bool, id: u64 + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnCallbackRequest { sender, amount, result: expected_result, id } + ); + } + + public fun check_on_callback_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_receive_request( + msg_opt: &Option, on_receive_result: bool + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnReceiveRequest { msg_opt: *msg_opt, result: on_receive_result } + ); + } + + public fun check_on_receive_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_ack_request( + id: u64, expected_result: bool, sender: address, amount: u64 + ) { + let chain_signer = create_signer_for_test(@std); + move_to( + &chain_signer, + OnAckRequest { id, result: expected_result, sender, amount } + ); + } + + public fun check_on_ack_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun store_on_timeout_request(id: u64, sender: address) { + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnTimeoutRequest { id, sender }); + } + + public fun check_on_timeout_response(called: bool) { + assert!(called == exists(@std), 0); + } + + public fun on_callback(id: u64, success: bool) acquires OnCallbackRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + + // check balances + if (success) { + assert!( + coin::balance(request.sender, init_metadata()) + == 1_000_000u64 - request.amount, + 0 + ); + } else { + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 0); + }; + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnCallbackResponse {}); + } + + public fun on_receive( + recipient: &signer, msg_opt: &Option + ): bool acquires OnReceiveRequest { + // check counterparty balance + let counterparty_metadata = + ibctesting_utils::counterparty_metadata(init_metadata()); + assert!( + coin::balance(signer::address_of(recipient), counterparty_metadata) + == 1_000u64, + 1 + ); + + let request = borrow_global_mut(@std); + + assert!(option::is_some(&request.msg_opt) == option::is_some(msg_opt), 2); + if (option::is_some(&request.msg_opt)) { + assert!( + option::destroy_some(request.msg_opt) == option::destroy_some(*msg_opt), + 3 + ); + }; + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnReceiveResponse {}); + + // success + request.result + } + + public fun ibc_ack(id: u64, success: bool) acquires OnAckRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + assert!(request.result == success, 1); + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnAckResponse {}); + + if (success) { + // balance should be restored + assert!( + coin::balance(request.sender, init_metadata()) + == 1_000_000u64 - request.amount, + 2 + ); + } else { + // balance should be restored + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 1); + } + } + + public fun ibc_timeout(id: u64) acquires OnTimeoutRequest { + let request = borrow_global_mut(@std); + assert!(request.id == id, 0); + + // record results + let chain_signer = create_signer_for_test(@std); + move_to(&chain_signer, OnTimeoutResponse {}); + + // balance should be restored + assert!(coin::balance(request.sender, init_metadata()) == 1_000_000u64, 1); + } + + public fun init_metadata(): Object { + coin::metadata(@std, utf8(b"uinit")) + } +}