diff --git a/CHANGELOG.md b/CHANGELOG.md index 7886e1905b7..8f1ee091124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### State Compatible +* [#8895](https://github.com/osmosis-labs/osmosis/pull/8895) (xcs) feat: support XCS final hop memos + ## v28.0.2 ### State Breaking diff --git a/app/apptesting/test_suite.go b/app/apptesting/test_suite.go index 50bbd4267b5..ce364431a96 100644 --- a/app/apptesting/test_suite.go +++ b/app/apptesting/test_suite.go @@ -103,12 +103,17 @@ func init() { // Setup sets up basic environment for suite (App, Ctx, and test accounts) // preserves the caching enabled/disabled state. func (s *KeeperTestHelper) Setup() { + s.T().Log("Setting up KeeperTestHelper") dir, err := os.MkdirTemp("", "osmosisd-test-home") if err != nil { panic(fmt.Sprintf("failed creating temporary directory: %v", err)) } s.T().Cleanup(func() { os.RemoveAll(dir); s.withCaching = false }) - s.App = app.SetupWithCustomHome(false, dir) + if app.IsDebugLogEnabled() { + s.App = app.SetupWithCustomHome(false, dir, s.T()) + } else { + s.App = app.SetupWithCustomHome(false, dir) + } s.setupGeneral() // Manually set validator signing info, otherwise we panic diff --git a/app/test_helpers.go b/app/test_helpers.go index 1900f67b6a6..e28976d4f60 100644 --- a/app/test_helpers.go +++ b/app/test_helpers.go @@ -3,6 +3,7 @@ package app import ( "encoding/json" "os" + "testing" "time" "cosmossdk.io/log" @@ -17,6 +18,8 @@ import ( "github.com/cosmos/cosmos-sdk/baseapp" codectypes "github.com/cosmos/cosmos-sdk/codec/types" cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + cosmoserver "github.com/cosmos/cosmos-sdk/server" + servertypes "github.com/cosmos/cosmos-sdk/server/types" "github.com/cosmos/cosmos-sdk/testutil/mock" sims "github.com/cosmos/cosmos-sdk/testutil/sims" sdk "github.com/cosmos/cosmos-sdk/types" @@ -117,13 +120,55 @@ func GenesisStateWithValSet(app *OsmosisApp) GenesisState { var defaultGenesisStatebytes = []byte{} // SetupWithCustomHome initializes a new OsmosisApp with a custom home directory -func SetupWithCustomHome(isCheckTx bool, dir string) *OsmosisApp { - return SetupWithCustomHomeAndChainId(isCheckTx, dir, "osmosis-1") +func SetupWithCustomHome(isCheckTx bool, dir string, t ...*testing.T) *OsmosisApp { + return SetupWithCustomHomeAndChainId(isCheckTx, dir, "osmosis-1", t...) } -func SetupWithCustomHomeAndChainId(isCheckTx bool, dir, chainId string) *OsmosisApp { +// DebugAppOptions is a stub implementing AppOptions +type DebugAppOptions struct{} + +// Get implements AppOptions +func (ao DebugAppOptions) Get(o string) interface{} { + if o == cosmoserver.FlagTrace { + return true + } + return nil +} + +func IsDebugLogEnabled() bool { + return os.Getenv("OSMO_KEEPER_DEBUG") != "" +} + +func SetupWithCustomHomeAndChainId(isCheckTx bool, dir, chainId string, t ...*testing.T) *OsmosisApp { db := cosmosdb.NewMemDB() - app := NewOsmosisApp(log.NewNopLogger(), db, nil, true, map[int64]bool{}, dir, 0, sims.EmptyAppOptions{}, EmptyWasmOpts, baseapp.SetChainID(chainId)) + var ( + l log.Logger + appOpts servertypes.AppOptions + ) + if IsDebugLogEnabled() { + appOpts = DebugAppOptions{} + } else { + appOpts = sims.EmptyAppOptions{} + } + if len(t) > 0 { + testEnv := t[0] + testEnv.Log("Using test environment logger") + l = log.NewTestLogger(testEnv) + } else { + l = log.NewNopLogger() + } + app := NewOsmosisApp( + l, + db, + nil, + true, + map[int64]bool{}, + dir, + 0, + appOpts, + EmptyWasmOpts, + baseapp.SetChainID(chainId), + ) if !isCheckTx { if len(defaultGenesisStatebytes) == 0 { var err error diff --git a/cosmwasm/contracts/crosschain-registry/src/contract.rs b/cosmwasm/contracts/crosschain-registry/src/contract.rs index fc9e48a210c..6d84155c580 100644 --- a/cosmwasm/contracts/crosschain-registry/src/contract.rs +++ b/cosmwasm/contracts/crosschain-registry/src/contract.rs @@ -82,6 +82,7 @@ pub fn execute( receiver, into_chain, with_memo, + final_memo, } => { let registries = Registry::new(deps.as_ref(), env.contract.address.to_string())?; let coin = cw_utils::one_coin(&info)?; @@ -92,6 +93,7 @@ pub fn execute( env.contract.address.to_string(), env.block.time, with_memo, + final_memo, None, false, )?; diff --git a/cosmwasm/contracts/crosschain-registry/src/execute.rs b/cosmwasm/contracts/crosschain-registry/src/execute.rs index 25da7b38d8d..e9b3be8a0ca 100644 --- a/cosmwasm/contracts/crosschain-registry/src/execute.rs +++ b/cosmwasm/contracts/crosschain-registry/src/execute.rs @@ -131,6 +131,7 @@ pub fn propose_pfm( own_addr.to_string(), env.block.time, format!(r#"{{"ibc_callback":"{own_addr}"}}"#), + String::new(), // no last transfer memo Some(Callback { contract: own_addr, msg: format!(r#"{{"validate_pfm": {{"chain": "{chain}"}} }}"#).try_into()?, diff --git a/cosmwasm/contracts/crosschain-registry/src/msg.rs b/cosmwasm/contracts/crosschain-registry/src/msg.rs index 5769dbdd168..3cad58a7518 100644 --- a/cosmwasm/contracts/crosschain-registry/src/msg.rs +++ b/cosmwasm/contracts/crosschain-registry/src/msg.rs @@ -53,6 +53,8 @@ pub enum ExecuteMsg { into_chain: Option, #[serde(default = "String::new")] with_memo: String, + #[serde(default = "String::new")] + final_memo: String, }, } diff --git a/cosmwasm/contracts/crosschain-swaps/src/checks.rs b/cosmwasm/contracts/crosschain-swaps/src/checks.rs index 5b99ce96717..a49178d10c6 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/checks.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/checks.rs @@ -39,7 +39,9 @@ fn validate_explicit_receiver(deps: Deps, receiver: &str) -> Result<(String, Add } let Ok(_) = bech32::decode(&address) else { - return Err(ContractError::InvalidReceiver { receiver: receiver.to_string() }) + return Err(ContractError::InvalidReceiver { + receiver: receiver.to_string(), + }); }; let registry = get_registry(deps)?; @@ -54,7 +56,9 @@ fn validate_explicit_receiver(deps: Deps, receiver: &str) -> Result<(String, Add /// transfers from failing after forwarding fn validate_bech32_receiver(deps: Deps, receiver: &str) -> Result<(String, Addr), ContractError> { let Ok((prefix, _, _)) = bech32::decode(receiver) else { - return Err(ContractError::InvalidReceiver { receiver: receiver.to_string() }) + return Err(ContractError::InvalidReceiver { + receiver: receiver.to_string(), + }); }; let registry = get_registry(deps)?; @@ -65,14 +69,18 @@ fn validate_bech32_receiver(deps: Deps, receiver: &str) -> Result<(String, Addr) fn validate_chain_receiver(deps: Deps, receiver: &str) -> Result<(String, Addr), ContractError> { let Some((chain, addr)) = receiver.split('/').collect_tuple() else { - return Err(ContractError::InvalidReceiver { receiver: receiver.to_string() }) + return Err(ContractError::InvalidReceiver { + receiver: receiver.to_string(), + }); }; // TODO: validate that the prefix of the receiver matches the chain let _registry = get_registry(deps)?; let Ok(_) = bech32::decode(addr) else { - return Err(ContractError::InvalidReceiver { receiver: receiver.to_string() }) + return Err(ContractError::InvalidReceiver { + receiver: receiver.to_string(), + }); }; Ok((chain.to_string(), Addr::unchecked(addr))) diff --git a/cosmwasm/contracts/crosschain-swaps/src/contract.rs b/cosmwasm/contracts/crosschain-swaps/src/contract.rs index 8d89d36c151..f32ec43dbd9 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/contract.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/contract.rs @@ -59,6 +59,7 @@ pub fn execute( receiver, slippage, next_memo, + final_memo, on_failed_delivery, route, } => execute::unwrap_or_swap_and_forward( @@ -67,6 +68,7 @@ pub fn execute( slippage, &receiver, next_memo, + final_memo, on_failed_delivery, route, ), diff --git a/cosmwasm/contracts/crosschain-swaps/src/execute.rs b/cosmwasm/contracts/crosschain-swaps/src/execute.rs index 1058236c98b..8d9f5f015cf 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/execute.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/execute.rs @@ -34,12 +34,14 @@ fn ibc_message_event(context: &str, message: T) -> cosmwasm_std::Event /// valid channel), it will just proceed to swap and forward. If it's not, then /// it will send an IBC message to unwrap it first and provide a callback to /// ensure the right swap_and_forward gets called after the unwrap succeeds +#[allow(clippy::too_many_arguments)] pub fn unwrap_or_swap_and_forward( ctx: (DepsMut, Env, MessageInfo), output_denom: String, slippage: swaprouter::Slippage, receiver: &str, next_memo: Option, + final_memo: Option, failed_delivery_action: FailedDeliveryAction, route: Option>, ) -> Result { @@ -73,6 +75,7 @@ pub fn unwrap_or_swap_and_forward( env.contract.address.to_string(), env.block.time, build_memo(None, env.contract.address.as_str())?, + String::new(), Some(Callback { contract: env.contract.address.clone(), msg: serde_cw_value::to_value(&ExecuteMsg::OsmosisSwap { @@ -80,10 +83,11 @@ pub fn unwrap_or_swap_and_forward( receiver: receiver.to_string(), slippage, next_memo, + final_memo, on_failed_delivery: failed_delivery_action.clone(), route, })? - .into(), + .try_into()?, }), false, )?; @@ -125,6 +129,7 @@ pub fn unwrap_or_swap_and_forward( slippage, receiver, next_memo, + final_memo, failed_delivery_action, route, ) @@ -142,6 +147,7 @@ pub fn swap_and_forward( slippage: swaprouter::Slippage, receiver: &str, next_memo: Option, + final_memo: Option, failed_delivery_action: FailedDeliveryAction, route: Option>, ) -> Result { @@ -152,16 +158,11 @@ pub fn swap_and_forward( // Check that the received is valid and retrieve its channel let (valid_chain, valid_receiver) = validate_receiver(deps.as_ref(), receiver)?; - // If there is a memo, check that it is valid (i.e. a valud json object that + + // If there are memos, check that they are valid (i.e. a valid json object that // doesn't contain the key that we will insert later) - let memo = if let Some(memo) = &next_memo { - // Ensure the json is an object ({...}) and that it does not contain the CALLBACK_KEY - deps.api.debug(&format!("checking memo: {memo:?}")); - ensure_key_missing(memo.as_value(), CALLBACK_KEY)?; - serde_json_wasm::to_string(&memo)? - } else { - String::new() - }; + let s_next_memo = validate_user_provided_memo(&deps, next_memo.as_ref())?; + let s_final_memo = validate_user_provided_memo(&deps, final_memo.as_ref())?; // Validate that the swapped token can be unwrapped. If it can't, abort // early to avoid swapping unnecessarily @@ -172,7 +173,8 @@ pub fn swap_and_forward( Some(&valid_chain), env.contract.address.to_string(), env.block.time, - memo, + s_next_memo, + s_final_memo, None, false, )?; @@ -208,6 +210,7 @@ pub fn swap_and_forward( chain: valid_chain, receiver: valid_receiver, next_memo, + final_memo, on_failed_delivery: failed_delivery_action, }, }, @@ -256,7 +259,13 @@ pub fn handle_swap_reply( // If the memo is provided we want to include it in the IBC message. If not, // we default to an empty object. The resulting memo will always include the // callback so this contract can track the IBC send - let memo = build_memo(swap_msg_state.forward_to.next_memo, contract_addr.as_str())?; + let next_memo = build_memo(swap_msg_state.forward_to.next_memo, contract_addr.as_str())?; + let final_memo = swap_msg_state + .forward_to + .final_memo + .map(|final_memo| serde_json_wasm::to_string(&final_memo)) + .transpose()? + .unwrap_or_default(); let registry = get_registry(deps.as_ref())?; let ibc_transfer = registry.unwrap_coin_into( @@ -268,7 +277,8 @@ pub fn handle_swap_reply( Some(&swap_msg_state.forward_to.chain), env.contract.address.to_string(), env.block.time, - memo, + next_memo, + final_memo, None, false, )?; @@ -314,7 +324,9 @@ pub fn handle_forward_reply( deps.api.debug(&format!("handle_forward_reply")); // Parse the result from the underlying chain call (IBC send) let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = msg.result else { - return Err(ContractError::FailedIBCTransfer { msg: format!("failed reply: {:?}", msg.result) }) + return Err(ContractError::FailedIBCTransfer { + msg: format!("failed reply: {:?}", msg.result), + }); }; // The response contains the packet sequence. This is needed to be able to @@ -442,6 +454,20 @@ pub fn set_swap_contract( Ok(Response::new().add_attribute("action", "set_swaps_contract")) } +fn validate_user_provided_memo( + deps: &DepsMut, + user_memo: Option<&SerializableJson>, +) -> Result { + Ok(if let Some(memo) = user_memo { + // Ensure the json is an object ({...}) and that it does not contain the CALLBACK_KEY + deps.api.debug(&format!("checking memo: {memo:?}")); + ensure_key_missing(memo.as_value(), CALLBACK_KEY)?; + serde_json_wasm::to_string(&memo)? + } else { + String::new() + }) +} + #[cfg(test)] mod tests { use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; diff --git a/cosmwasm/contracts/crosschain-swaps/src/ibc_lifecycle.rs b/cosmwasm/contracts/crosschain-swaps/src/ibc_lifecycle.rs index f80e7176449..06e7e06db51 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/ibc_lifecycle.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/ibc_lifecycle.rs @@ -21,7 +21,7 @@ fn create_recovery( // RECOVERY_STATES map. recovery.status = recovery_reason; let Some(mut recoveries) = recoveries else { - return Ok::<_, ContractError>(vec![recovery]) + return Ok::<_, ContractError>(vec![recovery]); }; recoveries.push(recovery); Ok(recoveries) @@ -65,7 +65,7 @@ pub fn receive_ack( let sent_packet = INFLIGHT_PACKETS.may_load(deps.storage, (&source_channel, sequence))?; let Some(inflight_packet) = sent_packet else { // If there isn't, continue - return Ok(response.add_attribute("msg", "received unexpected ack")) + return Ok(response.add_attribute("msg", "received unexpected ack")); }; // Remove the in-flight packet INFLIGHT_PACKETS.remove(deps.storage, (&source_channel, sequence)); @@ -102,7 +102,7 @@ pub fn receive_timeout( let sent_packet = INFLIGHT_PACKETS.may_load(deps.storage, (&source_channel, sequence))?; let Some(inflight_packet) = sent_packet else { // If there isn't, continue - return Ok(response.add_attribute("msg", "received unexpected timeout")) + return Ok(response.add_attribute("msg", "received unexpected timeout")); }; // Remove the in-flight packet INFLIGHT_PACKETS.remove(deps.storage, (&source_channel, sequence)); diff --git a/cosmwasm/contracts/crosschain-swaps/src/msg.rs b/cosmwasm/contracts/crosschain-swaps/src/msg.rs index e678fef6258..2415d9dc610 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/msg.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/msg.rs @@ -54,6 +54,10 @@ pub enum ExecuteMsg { /// packet to include a memo, this is the field where they can specify /// it. If provided, the memo is expected to be a valid JSON object next_memo: Option, + /// IBC packets can contain an optional memo. If a sender wants the last + /// hop's packet to include a memo, this is the field where they can specify + /// it. If provided, the memo is expected to be a valid JSON object + final_memo: Option, /// If for any reason the swap were to fail, users can specify a /// "recovery address" that can clain the funds on osmosis after a /// confirmed failure. diff --git a/cosmwasm/contracts/crosschain-swaps/src/state.rs b/cosmwasm/contracts/crosschain-swaps/src/state.rs index cacb0ed6ee6..cb10f90db91 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/state.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/state.rs @@ -18,6 +18,7 @@ pub struct ForwardTo { pub chain: String, pub receiver: Addr, pub next_memo: Option, + pub final_memo: Option, pub on_failed_delivery: FailedDeliveryAction, } diff --git a/cosmwasm/contracts/crosschain-swaps/src/utils.rs b/cosmwasm/contracts/crosschain-swaps/src/utils.rs index 7390f9b74e5..ca4649ecfa5 100644 --- a/cosmwasm/contracts/crosschain-swaps/src/utils.rs +++ b/cosmwasm/contracts/crosschain-swaps/src/utils.rs @@ -15,7 +15,7 @@ pub fn parse_swaprouter_reply(msg: Reply) -> Result let SubMsgResult::Ok(SubMsgResponse { data: Some(b), .. }) = msg.result else { return Err(ContractError::FailedSwap { msg: format!("No data in swaprouter reply"), - }) + }); }; // Parse underlying response from the chain @@ -37,12 +37,14 @@ pub fn build_memo( ) -> Result { // If the memo is provided we want to include it in the IBC message. If not, // we default to an empty object - let memo = next_memo.unwrap_or_else(|| serde_json_wasm::from_str("{}").unwrap()); + let memo = next_memo.unwrap_or_else(SerializableJson::empty); // Include the callback key in the memo without modifying the rest of the // provided memo let memo = { - let serde_cw_value::Value::Map(mut m) = memo.0 else { unreachable!() }; + let serde_cw_value::Value::Map(mut m) = memo.into_value() else { + unreachable!() + }; m.insert( serde_cw_value::Value::String(CALLBACK_KEY.to_string()), serde_cw_value::Value::String(contract_addr.to_string()), diff --git a/cosmwasm/contracts/crosschain-swaps/tests/crosschain_swap_test.rs b/cosmwasm/contracts/crosschain-swaps/tests/crosschain_swap_test.rs index d197e63ab93..e3fafcaf0b4 100644 --- a/cosmwasm/contracts/crosschain-swaps/tests/crosschain_swap_test.rs +++ b/cosmwasm/contracts/crosschain-swaps/tests/crosschain_swap_test.rs @@ -67,6 +67,7 @@ fn crosschain_swap() { receiver: "osmo1l4u56l7cvx8n0n6c7w650k02vz67qudjlcut89".to_string(), on_failed_delivery: FailedDeliveryAction::DoNothing, next_memo: None, + final_memo: None, route: None, }; let funds: &[Coin] = &[Coin::new(10000, "uosmo")]; @@ -98,7 +99,7 @@ fn crosschain_swap() { } fn get_amount( - balances: &Vec, + balances: &[osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin], denom: &str, ) -> u128 { balances diff --git a/cosmwasm/contracts/crosschain-swaps/tests/test_env.rs b/cosmwasm/contracts/crosschain-swaps/tests/test_env.rs index 46c916f8e91..8b57d201052 100644 --- a/cosmwasm/contracts/crosschain-swaps/tests/test_env.rs +++ b/cosmwasm/contracts/crosschain-swaps/tests/test_env.rs @@ -14,6 +14,13 @@ pub struct TestEnv { pub crosschain_address: String, pub owner: SigningAccount, } + +impl Default for TestEnv { + fn default() -> Self { + Self::new() + } +} + impl TestEnv { pub fn new() -> Self { let app = OsmosisTestApp::new(); diff --git a/cosmwasm/contracts/outpost/src/contract.rs b/cosmwasm/contracts/outpost/src/contract.rs index 790d009ec38..d57f1c7841f 100644 --- a/cosmwasm/contracts/outpost/src/contract.rs +++ b/cosmwasm/contracts/outpost/src/contract.rs @@ -26,7 +26,7 @@ pub fn instantiate( let Ok((prefix, _, _)) = bech32::decode(msg.crosschain_swaps_contract.as_str()) else { return Err(ContractError::InvalidCrosschainSwapsContract { contract: msg.crosschain_swaps_contract, - }) + }); }; if prefix != "osmo" { return Err(ContractError::InvalidCrosschainSwapsContract { diff --git a/cosmwasm/contracts/outpost/src/execute.rs b/cosmwasm/contracts/outpost/src/execute.rs index 91e1aaf4056..7194bf0ece2 100644 --- a/cosmwasm/contracts/outpost/src/execute.rs +++ b/cosmwasm/contracts/outpost/src/execute.rs @@ -52,6 +52,7 @@ pub fn execute_swap( receiver, slippage, next_memo, + final_memo: None, on_failed_delivery, route, }; diff --git a/cosmwasm/contracts/swaprouter/tests/swap_test.rs b/cosmwasm/contracts/swaprouter/tests/swap_test.rs index 5a894c7dd63..5d724658b84 100644 --- a/cosmwasm/contracts/swaprouter/tests/swap_test.rs +++ b/cosmwasm/contracts/swaprouter/tests/swap_test.rs @@ -230,7 +230,7 @@ fn assert_input_decreased_and_output_increased( } fn get_amount( - balances: &Vec, + balances: &[osmosis_test_tube::osmosis_std::types::cosmos::base::v1beta1::Coin], denom: &str, ) -> u128 { balances diff --git a/cosmwasm/contracts/swaprouter/tests/test_env.rs b/cosmwasm/contracts/swaprouter/tests/test_env.rs index 39027fb1065..076b9921125 100644 --- a/cosmwasm/contracts/swaprouter/tests/test_env.rs +++ b/cosmwasm/contracts/swaprouter/tests/test_env.rs @@ -10,6 +10,13 @@ pub struct TestEnv { pub contract_address: String, pub owner: SigningAccount, } + +impl Default for TestEnv { + fn default() -> Self { + Self::new() + } +} + impl TestEnv { pub fn new() -> Self { let app = OsmosisTestApp::new(); diff --git a/cosmwasm/packages/registry/src/error.rs b/cosmwasm/packages/registry/src/error.rs index c331030c2cc..391ffe4693a 100644 --- a/cosmwasm/packages/registry/src/error.rs +++ b/cosmwasm/packages/registry/src/error.rs @@ -68,6 +68,9 @@ pub enum RegistryError { #[error("invalid json: {error}. Got: {json}")] InvalidJson { error: String, json: String }, + #[error("duplicate key found in next memo json")] + DuplicateKeyError, + // Registry loading errors #[error("contract alias does not exist: {alias:?}")] AliasDoesNotExist { alias: String }, diff --git a/cosmwasm/packages/registry/src/msg.rs b/cosmwasm/packages/registry/src/msg.rs index 608af12fe1d..69b00e0741a 100644 --- a/cosmwasm/packages/registry/src/msg.rs +++ b/cosmwasm/packages/registry/src/msg.rs @@ -1,8 +1,12 @@ +use std::collections::BTreeMap; + use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::Addr; use schemars::JsonSchema; use serde_json_wasm::from_str; +use crate::registry::Memo; +use crate::utils::stringify; use crate::RegistryError; #[cw_serde] @@ -94,7 +98,10 @@ pub struct QueryDenomPathForAliasResponse { PartialEq, Eq, )] -pub struct SerializableJson(pub serde_cw_value::Value); +#[repr(transparent)] +pub struct SerializableJson( + #[serde(deserialize_with = "deserialize_cw_value")] serde_cw_value::Value, +); impl JsonSchema for SerializableJson { fn schema_name() -> String { @@ -107,14 +114,92 @@ impl JsonSchema for SerializableJson { } impl SerializableJson { + pub fn into_value(self) -> serde_cw_value::Value { + self.0 + } + + pub fn new(mut value: serde_cw_value::Value) -> Result { + flatten_cw_value(&mut value); + match &value { + serde_cw_value::Value::Map(_) => Ok(Self(value)), + serde_cw_value::Value::Unit | serde_cw_value::Value::Option(None) => Ok(Self::empty()), + json => Err(RegistryError::InvalidJson { + error: "invalid json: expected an object".to_string(), + json: stringify(json)?, + }), + } + } + + pub fn empty() -> Self { + Self(serde_cw_value::Value::Map(BTreeMap::new())) + } + + pub fn is_empty(&self) -> bool { + match &self.0 { + serde_cw_value::Value::Map(m) => m.is_empty(), + _ => true, + } + } + pub fn as_value(&self) -> &serde_cw_value::Value { &self.0 } + + /// Merge two [`SerializableJson`] instances together. Fail in case + /// the same top-level key is found twice, or in case any of the two + /// JSON structs are not objects. + pub fn merge(self, other: SerializableJson) -> Result { + let mut first_map = match self.0 { + serde_cw_value::Value::Map(m) => m, + serde_cw_value::Value::Unit | serde_cw_value::Value::Option(None) => BTreeMap::new(), + json => { + return Err(RegistryError::InvalidJson { + error: "invalid json: expected an object".to_string(), + json: stringify(&json)?, + }) + } + }; + let second_map = match other.0 { + serde_cw_value::Value::Map(m) => m, + serde_cw_value::Value::Unit | serde_cw_value::Value::Option(None) => BTreeMap::new(), + json => { + return Err(RegistryError::InvalidJson { + error: "invalid json: expected an object".to_string(), + json: stringify(&json)?, + }) + } + }; + + for (key, value) in second_map { + if first_map.insert(key, value).is_some() { + return Err(RegistryError::DuplicateKeyError); + } + } + + Ok(SerializableJson(serde_cw_value::Value::Map(first_map))) + } +} + +impl From for serde_cw_value::Value { + fn from(SerializableJson(value): SerializableJson) -> Self { + value + } +} + +impl TryFrom for SerializableJson { + type Error = RegistryError; + + fn try_from(value: serde_cw_value::Value) -> Result { + Self::new(value) + } } -impl From for SerializableJson { - fn from(value: serde_cw_value::Value) -> Self { - Self(value) +impl TryFrom for SerializableJson { + type Error = RegistryError; + + fn try_from(memo: Memo) -> Result { + let value = serde_cw_value::to_value(&memo)?; + Self::new(value) } } @@ -122,7 +207,7 @@ impl TryFrom for SerializableJson { type Error = RegistryError; fn try_from(value: String) -> Result { - Ok(Self(from_str(&value)?)) + Self::new(from_str(&value)?) } } @@ -139,8 +224,339 @@ impl Callback { } pub fn to_json(&self) -> Result { - Ok(SerializableJson(serde_json_wasm::from_str( - &self.try_string()?, - )?)) + SerializableJson::new(serde_json_wasm::from_str(&self.try_string()?)?) + } +} + +fn flatten_cw_value(v: &mut serde_cw_value::Value) { + use std::mem; + use std::ops::DerefMut; + + use serde_cw_value::Value::*; + + match v { + Bool(_) | U8(_) | U16(_) | U32(_) | U64(_) | I8(_) | I16(_) | I32(_) | I64(_) | Char(_) + | String(_) | Unit | Bytes(_) => {} + Option(opt) => { + *v = opt.take().map_or(Unit, |mut value| { + flatten_cw_value(&mut value); + *value + }); + } + Newtype(value) => { + flatten_cw_value(value); + *v = mem::replace(value.deref_mut(), Unit); + } + Seq(seq) => { + for value in seq.iter_mut() { + flatten_cw_value(value); + } + } + Map(map) => { + let old_map = mem::take(map); + + for (mut key, mut value) in old_map { + flatten_cw_value(&mut key); + flatten_cw_value(&mut value); + map.insert(key, value); + } + } + } +} + +fn deserialize_cw_value<'de, D>(deserializer: D) -> Result +where + D: ::cosmwasm_schema::serde::Deserializer<'de>, +{ + use ::cosmwasm_schema::serde::Deserialize; + let value = serde_cw_value::Value::deserialize(deserializer)?; + let object = + SerializableJson::new(value).map_err(::cosmwasm_schema::serde::de::Error::custom)?; + Ok(object.into_value()) +} + +#[cfg(test)] +mod registry_msg_tests { + use serde_cw_value::Value; + + use super::*; + use crate::registry::{ChannelId, ForwardingMemo, Memo}; + + #[test] + fn test_deserialize_empty() { + let memo: SerializableJson = serde_json_wasm::from_str("{}").unwrap(); + assert!(matches!(memo.into_value(), serde_cw_value::Value::Map(m) if m.is_empty())); + + let memo: SerializableJson = serde_json_wasm::from_str("null").unwrap(); + println!("{memo:#?}"); + assert!(matches!(memo.into_value(), serde_cw_value::Value::Map(m) if m.is_empty())); + } + + #[test] + fn test_from_memo() { + let next_next_memo = SerializableJson(Value::Seq(vec![ + Value::U64(1), + Value::U64(5), + Value::U64(1234), + Value::Map({ + let mut m = BTreeMap::new(); + m.insert(Value::String("a".to_owned()), Value::U64(5)); + m.insert(Value::String("b".to_owned()), Value::U64(2)); + m + }), + ])); + + let memo: SerializableJson = Memo { + callback: None, + forward: Some(ForwardingMemo { + receiver: "abc1abc".to_owned(), + port: "transfer".to_owned(), + channel: ChannelId::new("channel-0").unwrap(), + next: Some(Box::new( + Memo { + callback: None, + forward: Some( + ForwardingMemo { + receiver: "def1def".to_owned(), + port: "transfer".to_owned(), + channel: ChannelId::new("channel-1").unwrap(), + next: Some(Box::new(next_next_memo)), + } + .try_into() + .unwrap(), + ), + } + .try_into() + .unwrap(), + )), + }), + } + .try_into() + .unwrap(); + + let expected_memo_json = map([( + "forward", + map([ + ("receiver", Value::String("abc1abc".to_owned())), + ("port", Value::String("transfer".to_owned())), + ("channel", Value::String("channel-0".to_owned())), + ( + "next", + map([( + "forward", + map([ + ("receiver", Value::String("def1def".to_owned())), + ("port", Value::String("transfer".to_owned())), + ("channel", Value::String("channel-1".to_owned())), + ( + "next", + seq([ + Value::U64(1), + Value::U64(5), + Value::U64(1234), + map([("a", Value::U64(5)), ("b", Value::U64(2))]).into_value(), + ]), + ), + ]) + .into_value(), + )]) + .into_value(), + ), + ]), + )]); + + assert_eq!(memo, expected_memo_json); + } + + #[test] + fn test_deserialize_json() { + let input = r#" + { + "test": [ + 1, + 5, + 1234, + {"a": 5, "b": 2} + ] + } + "#; + let expected = map([( + "test", + Value::Seq(vec![ + Value::U64(1), + Value::U64(5), + Value::U64(1234), + Value::Map({ + let mut m = BTreeMap::new(); + m.insert(Value::String("a".to_owned()), Value::U64(5)); + m.insert(Value::String("b".to_owned()), Value::U64(2)); + m + }), + ]), + )]); + + let parsed_input: SerializableJson = from_str(input).unwrap(); + assert_eq!(parsed_input, expected); + } + + #[test] + fn test_flatten_cw_value() { + let input = Value::Newtype(Box::new( + map([( + "test", + Value::Newtype(Box::new(Value::Newtype(Box::new(Value::Seq(vec![ + Value::Newtype(Box::new(Value::U8(1))), + Value::Newtype(Box::new(Value::U32(5))), + Value::U64(1234), + Value::Map({ + let mut m = BTreeMap::new(); + m.insert( + Value::Newtype(Box::new(Value::String("a".to_owned()))), + Value::Newtype(Box::new(Value::U32(5))), + ); + m.insert( + Value::String("b".to_owned()), + Value::Newtype(Box::new(Value::U8(2))), + ); + m + }), + ]))))), + )]) + .into_value(), + )); + let expected = map([( + "test", + Value::Seq(vec![ + Value::U8(1), + Value::U32(5), + Value::U64(1234), + Value::Map({ + let mut m = BTreeMap::new(); + m.insert(Value::String("a".to_owned()), Value::U32(5)); + m.insert(Value::String("b".to_owned()), Value::U8(2)); + m + }), + ]), + )]) + .into_value(); + + let input = SerializableJson::new(input).unwrap(); + assert_eq!(input.into_value(), expected); + } + + #[test] + fn test_merge_json() { + // some examples + assert_eq!( + map([("a", Value::U64(1))]) + .merge(map([("b", Value::U64(2))])) + .unwrap(), + map([("a", Value::U64(1)), ("b", Value::U64(2))]), + ); + assert_eq!( + map([("a", Value::U64(1))]) + .merge(map([("a", Value::U64(2))])) + .unwrap_err(), + RegistryError::DuplicateKeyError, + ); + assert_eq!( + map([("a", Value::U64(1))]) + .merge(map([("b", map([("b", Value::U64(2))]))])) + .unwrap(), + map([ + ("a", Value::U64(1)), + ("b", map([("b", Value::U64(2))]).into_value()) + ]), + ); + assert_eq!( + map([("a", map([("b", Value::U64(2))]))]) + .merge(map([("b", Value::U64(1))])) + .unwrap(), + map([ + ("a", map([("b", Value::U64(2))]).into_value()), + ("b", Value::U64(1)) + ]), + ); + assert_eq!( + map([("a", map([("b", Value::U64(1))]))]) + .merge(map([("b", map([("b", Value::U64(2))]))])) + .unwrap(), + map([ + ("a", map([("b", Value::U64(1))])), + ("b", map([("b", Value::U64(2))])), + ]), + ); + + // non-empty + empty + assert_eq!( + map([("a", Value::U64(1))]) + .merge(SerializableJson::new(Value::Unit).unwrap()) + .unwrap(), + map([("a", Value::U64(1))]) + ); + assert_eq!( + map([("a", Value::U64(1))]) + .merge(SerializableJson::new(Value::Option(None)).unwrap()) + .unwrap(), + map([("a", Value::U64(1))]) + ); + assert_eq!( + map([("a", Value::U64(1))]) + .merge(SerializableJson::empty()) + .unwrap(), + map([("a", Value::U64(1))]) + ); + + // empty + non-empty + assert_eq!( + SerializableJson::new(Value::Unit) + .unwrap() + .merge(map([("a", Value::U64(1))])) + .unwrap(), + map([("a", Value::U64(1))]) + ); + assert_eq!( + SerializableJson::new(Value::Option(None)) + .unwrap() + .merge(map([("a", Value::U64(1))])) + .unwrap(), + map([("a", Value::U64(1))]) + ); + assert_eq!( + SerializableJson::empty() + .merge(map([("a", Value::U64(1))])) + .unwrap(), + map([("a", Value::U64(1))]) + ); + } + + fn map(kvpairs: I) -> SerializableJson + where + K: Into, + V: Into, + I: IntoIterator, + { + SerializableJson::new(Value::Map(BTreeMap::from_iter( + kvpairs + .into_iter() + .map(|(k, v)| (Value::String(k.into()), v.into())), + ))) + .unwrap() + } + + fn seq(vals: I) -> Value + where + V: Into, + I: IntoIterator, + { + Value::Seq( + vals.into_iter() + .map(|value| { + let mut value = value.into(); + flatten_cw_value(&mut value); + value + }) + .collect(), + ) } } diff --git a/cosmwasm/packages/registry/src/registry.rs b/cosmwasm/packages/registry/src/registry.rs index 27c61d9e577..345a28337c9 100644 --- a/cosmwasm/packages/registry/src/registry.rs +++ b/cosmwasm/packages/registry/src/registry.rs @@ -4,9 +4,8 @@ use itertools::Itertools; use sha2::Digest; use sha2::Sha256; -use crate::msg::Callback; +use crate::msg::{Callback, SerializableJson}; use crate::proto; -use crate::utils::merge_json; use crate::{error::RegistryError, msg::QueryMsg}; use std::convert::AsRef; @@ -117,16 +116,16 @@ pub struct ForwardingMemo { pub port: String, pub channel: ChannelId, #[serde(skip_serializing_if = "Option::is_none")] - pub next: Option>, + pub next: Option>, } #[cw_serde] pub struct Memo { #[serde(skip_serializing_if = "Option::is_none")] - forward: Option, + pub forward: Option, #[serde(rename = "wasm")] #[serde(skip_serializing_if = "Option::is_none")] - callback: Option, + pub callback: Option, } // We will assume here that chains use the standard ibc-go formats. This is ok @@ -422,6 +421,7 @@ impl<'a> Registry<'a> { own_addr: String, block_time: Timestamp, first_transfer_memo: String, + last_transfer_memo: String, receiver_callback: Option, skip_forwarding_check: bool, ) -> Result { @@ -516,7 +516,7 @@ impl<'a> Registry<'a> { let path_iter = path.iter().skip(1); // initialize mutable variables for the iteration - let mut next: Option> = None; + let mut next: Option> = None; let mut prev_chain: &str = receiver_chain; let mut callback = receiver_callback; // The last call should have the receiver callback @@ -552,25 +552,39 @@ impl<'a> Registry<'a> { } // The next memo wraps the previous one - next = Some(Box::new(Memo { - forward: Some(ForwardingMemo { - receiver: self.encode_addr_for_chain(&receiver_addr, prev_chain)?, - port: TRANSFER_PORT.to_string(), - channel, - next: if next.is_none() && callback.is_some() { - // If there is no next, this means we are on the last - // forward. We can then default to a memo with only the - // receiver callback. - Some(Box::new(Memo { - forward: None, - callback, // The callback may be None - })) - } else { - next - }, - }), - callback: None, - })); + next = Some(Box::new( + Memo { + forward: Some(ForwardingMemo { + receiver: self.encode_addr_for_chain(&receiver_addr, prev_chain)?, + port: TRANSFER_PORT.to_string(), + channel, + next: if next.is_none() { + // If there is no next, this means we are on the last + // forward. We can then default to a memo with only the + // receiver callback and any user provided last memo. + let last_transfer_memo = if last_transfer_memo.is_empty() { + SerializableJson::empty() + } else { + serde_json_wasm::from_str(&last_transfer_memo)? + }; + + let next_memo = last_transfer_memo.merge( + Memo { + forward: None, + callback, // The callback may be None + } + .try_into()?, + )?; + + (!next_memo.is_empty()).then(|| Box::new(next_memo)) + } else { + next + }, + }), + callback: None, + } + .try_into()?, + )); prev_chain = hop.on.as_ref(); callback = None; } @@ -579,19 +593,41 @@ impl<'a> Registry<'a> { // callback. This is not necessary if next.is_some() because the // callback would already have been included. if next.is_none() { - next = Some(Box::new(Memo { + let last_transfer_memo = if last_transfer_memo.is_empty() { + SerializableJson::empty() + } else { + serde_json_wasm::from_str(&last_transfer_memo)? + }; + + let callback_memo: SerializableJson = Memo { forward: None, callback, // The callback may also be None - })); + } + .try_into()?; + + let next_memo = last_transfer_memo.merge(callback_memo)?; + + if !next_memo.is_empty() { + next = Some(Box::new(next_memo)); + } } - // Serialize the memo - let forward = serde_json_wasm::to_string(&next)?; + // Merge the first transfer memo + if !first_transfer_memo.is_empty() { + let first_transfer_memo: SerializableJson = + serde_json_wasm::from_str(&first_transfer_memo)?; + + if let Some(box_next) = next.as_mut() { + use std::ops::DerefMut; + let next_memo = std::mem::replace(box_next.deref_mut(), SerializableJson::empty()); + *box_next.deref_mut() = next_memo.merge(first_transfer_memo)?; + } else { + next = Some(Box::new(first_transfer_memo)); + } + } - // If the user provided a memo to be included in the transfer, we merge - // it with the calculated one. By using the provided memo as a base, - // only its forward key would be overwritten if it existed - let memo = merge_json(&first_transfer_memo, &forward)?; + // Serialize the memo + let memo = serde_json_wasm::to_string(&next)?; let ts = block_time.plus_seconds(PACKET_LIFETIME); // Cosmwasm's IBCMsg::Transfer does not support memo. @@ -638,24 +674,32 @@ mod test { receiver: "receiver".to_string(), port: "port".to_string(), channel: ChannelId::new("channel-0").unwrap(), - next: Some(Box::new(Memo { - forward: Some(ForwardingMemo { - receiver: "receiver2".to_string(), - port: "port2".to_string(), - channel: ChannelId::new("channel-1").unwrap(), - next: None, - }), - callback: None, - })), + next: Some(Box::new( + Memo { + forward: Some(ForwardingMemo { + receiver: "receiver2".to_string(), + port: "port2".to_string(), + channel: ChannelId::new("channel-1").unwrap(), + next: None, + }), + callback: None, + } + .try_into() + .unwrap(), + )), }), callback: None, }; let encoded = serde_json_wasm::to_string(&memo).unwrap(); - let decoded: Memo = serde_json_wasm::from_str(&encoded).unwrap(); - assert_eq!(memo, decoded); + let Memo { + callback: decoded_callback, + forward: decoded_forward, + } = serde_json_wasm::from_str(&encoded).unwrap(); + assert_eq!(memo.callback, decoded_callback); + assert_eq!(memo.forward, decoded_forward); assert_eq!( encoded, - r#"{"forward":{"receiver":"receiver","port":"port","channel":"channel-0","next":{"forward":{"receiver":"receiver2","port":"port2","channel":"channel-1"}}}}"# + r#"{"forward":{"receiver":"receiver","port":"port","channel":"channel-0","next":{"forward":{"channel":"channel-1","port":"port2","receiver":"receiver2"}}}}"# ) } diff --git a/cosmwasm/packages/registry/src/utils.rs b/cosmwasm/packages/registry/src/utils.rs index 6ee78561268..9d8c9474ea3 100644 --- a/cosmwasm/packages/registry/src/utils.rs +++ b/cosmwasm/packages/registry/src/utils.rs @@ -22,66 +22,3 @@ pub fn extract_map(json: Value) -> Result, RegistryError> }), } } - -pub fn merge_json(first: &str, second: &str) -> Result { - // replacing some potential empty values we want to accept with an empty object - let first = match first { - "" => "{}", - "null" => "{}", - _ => first, - }; - let second = match second { - "" => "{}", - "null" => "{}", - _ => second, - }; - - let first_val: Value = serde_json_wasm::from_str(first)?; - let second_val: Value = serde_json_wasm::from_str(second)?; - - // checking potential "empty" values we want to accept - - let mut first_map = extract_map(first_val)?; - let second_map = extract_map(second_val)?; - - first_map.extend(second_map); - - stringify(&Value::Map(first_map)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_merge_json() { - // some examples - assert_eq!( - merge_json(r#"{"a": 1}"#, r#"{"b": 2}"#).unwrap(), - r#"{"a":1,"b":2}"# - ); - assert_eq!( - merge_json(r#"{"a": 1}"#, r#"{"a": 2}"#).unwrap(), - r#"{"a":2}"# - ); - assert_eq!( - merge_json(r#"{"a": 1}"#, r#"{"a": {"b": 2}}"#).unwrap(), - r#"{"a":{"b":2}}"# - ); - assert_eq!( - merge_json(r#"{"a": {"b": 2}}"#, r#"{"a": 1}"#).unwrap(), - r#"{"a":1}"# - ); - assert_eq!( - merge_json(r#"{"a": {"b": 2}}"#, r#"{"a": {"c": 3}}"#).unwrap(), - r#"{"a":{"c":3}}"# - ); - // Empties - assert_eq!(merge_json(r#"{"a": 1}"#, r#""#).unwrap(), r#"{"a":1}"#); - assert_eq!(merge_json(r#""#, r#"{"a": 1}"#).unwrap(), r#"{"a":1}"#); - assert_eq!(merge_json(r#"{"a": 1}"#, r#"null"#).unwrap(), r#"{"a":1}"#); - assert_eq!(merge_json(r#"null"#, r#"{"a": 1}"#).unwrap(), r#"{"a":1}"#); - assert_eq!(merge_json(r#"{"a": 1}"#, r#"{}"#).unwrap(), r#"{"a":1}"#); - assert_eq!(merge_json(r#"{}"#, r#"{"a": 1}"#).unwrap(), r#"{"a":1}"#); - } -} diff --git a/tests/ibc-hooks/bytecode/crosschain_registry.wasm b/tests/ibc-hooks/bytecode/crosschain_registry.wasm index a963d033b06..ebd2324e65c 100644 Binary files a/tests/ibc-hooks/bytecode/crosschain_registry.wasm and b/tests/ibc-hooks/bytecode/crosschain_registry.wasm differ diff --git a/tests/ibc-hooks/bytecode/crosschain_swaps.wasm b/tests/ibc-hooks/bytecode/crosschain_swaps.wasm index 7e460df0a62..c79e7121df4 100644 Binary files a/tests/ibc-hooks/bytecode/crosschain_swaps.wasm and b/tests/ibc-hooks/bytecode/crosschain_swaps.wasm differ diff --git a/tests/ibc-hooks/bytecode/outpost.wasm b/tests/ibc-hooks/bytecode/outpost.wasm index cbebb8e9228..5b549e28d54 100644 Binary files a/tests/ibc-hooks/bytecode/outpost.wasm and b/tests/ibc-hooks/bytecode/outpost.wasm differ diff --git a/tests/ibc-hooks/ibc_middleware_test.go b/tests/ibc-hooks/ibc_middleware_test.go index 27da9c53740..0ce9aff4d44 100644 --- a/tests/ibc-hooks/ibc_middleware_test.go +++ b/tests/ibc-hooks/ibc_middleware_test.go @@ -2137,3 +2137,279 @@ func (suite *HooksTestSuite) TestOutpostExplicit() { initializer := suite.chainB.SenderAccount.GetAddress() suite.ExecuteOutpostSwap(initializer, initializer, fmt.Sprintf(`ibc:channel-0/%s`, initializer.String())) } + +func (suite *HooksTestSuite) TestCrosschainSwapsFinalMemoMultipleHops() { + // Start on B with `token0`, swap on A with `C/token0`, + // then use the final memo to forward the swapped tokens + // to C. On C, we should end up with native `token0`. + + accountA := suite.chainA.SenderAccount.GetAddress() + accountB := suite.chainB.SenderAccount.GetAddress() + accountC := suite.chainC.SenderAccount.GetAddress() + + swapRouterAddr, crosschainAddr := suite.SetupCrosschainSwaps(ChainA, true) + + preToken0BalanceOnC := suite.chainC.GetOsmosisApp().BankKeeper.GetBalance(suite.chainC.GetContext(), accountC, "token0") + preToken0BalanceOnB := suite.chainB.GetOsmosisApp().BankKeeper.GetBalance(suite.chainB.GetContext(), accountB, "token0") + + const ( + transferAmount int64 = 12000000 + swapAmount int64 = 1000 + receiveAmount int64 = 980 + ) + suite.Require().Greater(transferAmount, defaultPoolAmount) + suite.Require().Greater(defaultPoolAmount, swapAmount) + + // Setup initial tokens + suite.SimpleNativeTransfer("token0", osmomath.NewInt(transferAmount), []Chain{ChainB, ChainA}) + suite.SimpleNativeTransfer("token0", osmomath.NewInt(transferAmount), []Chain{ChainC, ChainA}) + + // Balance of token0 on C should have decreased by the transfer amt + postToken0BalanceOnC := suite.chainC.GetOsmosisApp().BankKeeper.GetBalance(suite.chainC.GetContext(), accountC, "token0") + suite.Require().Equal(int64(-transferAmount), (postToken0BalanceOnC.Amount.Sub(preToken0BalanceOnC.Amount)).Int64()) + + // Likewise for token0 on B + postToken0BalanceOnB := suite.chainB.GetOsmosisApp().BankKeeper.GetBalance(suite.chainB.GetContext(), accountB, "token0") + suite.Require().Equal(int64(-transferAmount), (postToken0BalanceOnB.Amount.Sub(preToken0BalanceOnB.Amount)).Int64()) + + // Setup pool + token0BA := suite.GetIBCDenom(ChainB, ChainA, "token0") + token0CA := suite.GetIBCDenom(ChainC, ChainA, "token0") + + poolId := suite.CreateIBCPoolOnChain(ChainA, token0BA, token0CA, osmomath.NewInt(defaultPoolAmount)) + suite.SetupIBCSimpleRouteOnChain(swapRouterAddr, accountA, poolId, ChainA, token0BA, token0CA) + + // Generate forward instructions to receive natve token0 on C + forwardMsg := fmt.Sprintf(`{"forward":{"receiver":"%s", "port":"transfer", "channel":%q}}`, + accountC, + suite.GetSenderChannel(ChainB, ChainC), + ) + + // Generate swap instructions for the contract + swapMsg := fmt.Sprintf(`{"osmosis_swap":{"output_denom":%q,"slippage":{"twap": {"window_seconds": 1, "slippage_percentage":"20"}},"receiver":"chainB/%s", "on_failed_delivery": "do_nothing", "final_memo":%s}}`, + token0CA, + accountB, + forwardMsg, + ) + + // Generate full memo + msg := fmt.Sprintf(`{"wasm": {"contract": "%s", "msg": %s } }`, crosschainAddr, swapMsg) + + // Send IBC transfer with the memo with crosschain-swap instructions + channelBA := suite.GetSenderChannel(ChainB, ChainA) + transferMsg := NewMsgTransfer(sdk.NewCoin("token0", osmomath.NewInt(swapAmount)), accountB.String(), crosschainAddr.String(), channelBA, msg) + _, res, _, err := suite.FullSend(transferMsg, BtoA) + suite.Require().NoError(err) + suite.Require().NotNil(res) + + fungibleTokenPacketEvent := findFungibleTokenPacketEvent(res.GetEvents()) + suite.Require().NotNil(fungibleTokenPacketEvent) + + memo, err := parseFungibleTokenPacketEventMemo(fungibleTokenPacketEvent) + suite.Require().NotNil(memo) + suite.Require().NoError(err) + + _, finalMemo := extractNextAndFinalMemosFromSwapMsg(memo) + suite.Require().NotNil(finalMemo) + suite.assertForwardMemoStructure( + finalMemo, + suite.GetSenderChannel(ChainB, ChainC), + "transfer", + accountC.String(), + ) + + // Declare the expected amounts on C + expectedPreToken0BalanceOnC2 := postToken0BalanceOnC.Amount + expectedPostToken0BalanceOnC2 := postToken0BalanceOnC.Amount.Add(osmomath.NewInt(receiveAmount)) + + preToken0BalanceOnC2 := suite.chainC.GetOsmosisApp().BankKeeper.GetBalance(suite.chainC.GetContext(), accountC, "token0") + suite.Require().Equal(expectedPreToken0BalanceOnC2, preToken0BalanceOnC2.Amount) + + // Forward chain: A => C => B (=> C) + + // A => C + packet, err := ibctesting.ParsePacketFromEvents(res.GetEvents()) + suite.Require().NoError(err) + res = suite.RelayPacketNoAck(packet, AtoC) + + // C => B + packet, err = ibctesting.ParsePacketFromEvents(res.GetEvents()) + suite.Require().NoError(err) + res = suite.RelayPacketNoAck(packet, CtoB) + + // B => C + packet, err = ibctesting.ParsePacketFromEvents(res.GetEvents()) + suite.Require().NoError(err) + res = suite.RelayPacketNoAck(packet, BtoC) + + // Check C's balance + postToken0BalanceOnC2 := suite.chainC.GetOsmosisApp().BankKeeper.GetBalance(suite.chainC.GetContext(), accountC, "token0") + suite.Require().Equal(expectedPostToken0BalanceOnC2, postToken0BalanceOnC2.Amount) +} + +func findFungibleTokenPacketEvent(events []abci.Event) *abci.Event { + for i := 0; i < len(events); i++ { + if events[i].Type == "fungible_token_packet" { + return &events[i] + } + } + return nil +} + +func parseFungibleTokenPacketEventMemo(event *abci.Event) (map[string]any, error) { + for i := 0; i < len(event.Attributes); i++ { + if event.Attributes[i].Key == "memo" { + var memo map[string]any + decoder := json.NewDecoder(strings.NewReader(event.Attributes[i].Value)) + err := decoder.Decode(&memo) + if err != nil { + return nil, err + } + return memo, nil + } + } + return nil, fmt.Errorf("could not find memo in event") +} + +func extractNextAndFinalMemosFromSwapMsg(memo map[string]any) (nextMemo map[string]any, finalMemo map[string]any) { + var ok bool + + wasm, ok := memo["wasm"].(map[string]any) + if !ok { + return nil, nil + } + + msg, ok := wasm["msg"].(map[string]any) + if !ok { + return nil, nil + } + + osmosisSwap, ok := msg["osmosis_swap"].(map[string]any) + if !ok { + return nil, nil + } + + nextMemoAny := osmosisSwap["next_memo"] + if nextMemoAny != nil { + nextMemo, ok = nextMemoAny.(map[string]any) + if !ok { + return nil, nil + } + } + + finalMemoAny := osmosisSwap["final_memo"] + if finalMemoAny != nil { + finalMemo, ok = finalMemoAny.(map[string]any) + if !ok { + return nil, nil + } + } + + return +} + +func (suite *HooksTestSuite) assertForwardMemoStructure(memo map[string]any, channel, port, receiver string) { + forward, ok := memo["forward"].(map[string]any) + suite.Require().True(ok) + + suite.Require().Equal(channel, forward["channel"]) + suite.Require().Equal(port, forward["port"]) + suite.Require().Equal(receiver, forward["receiver"]) +} + +func (suite *HooksTestSuite) TestCrosschainSwapsFinalMemoOneHop() { + // Start on B with `token0`, swap on A with A's `token0`. + + accountA := suite.chainA.SenderAccount.GetAddress() + accountB := suite.chainB.SenderAccount.GetAddress() + + swapRouterAddr, crosschainAddr := suite.SetupCrosschainSwaps(ChainA, true) + + preToken0BalanceOnB := suite.chainB.GetOsmosisApp().BankKeeper.GetBalance(suite.chainB.GetContext(), accountB, "token0") + + const ( + transferAmount int64 = 12000000 + swapAmount int64 = 1000 + receiveAmount int64 = 980 + ) + suite.Require().Greater(transferAmount, defaultPoolAmount) + suite.Require().Greater(defaultPoolAmount, swapAmount) + + // Setup initial tokens + suite.SimpleNativeTransfer("token0", osmomath.NewInt(transferAmount), []Chain{ChainB, ChainA}) + suite.SimpleNativeTransfer("token0", osmomath.NewInt(transferAmount), []Chain{ChainC, ChainA}) + + // Balance of token0 on B should have decreased by the transfer amt + postToken0BalanceOnB := suite.chainB.GetOsmosisApp().BankKeeper.GetBalance(suite.chainB.GetContext(), accountB, "token0") + suite.Require().Equal(int64(-transferAmount), (postToken0BalanceOnB.Amount.Sub(preToken0BalanceOnB.Amount)).Int64()) + + // Setup pool + token0BA := suite.GetIBCDenom(ChainB, ChainA, "token0") + token0 := "token0" + + poolId := suite.CreateIBCPoolOnChain(ChainA, token0BA, token0, osmomath.NewInt(defaultPoolAmount)) + suite.SetupIBCSimpleRouteOnChain(swapRouterAddr, accountA, poolId, ChainA, token0BA, token0) + + // Generate next memo + nextMemoStr := `{"a":{"a":"a"}}` + finalMemoStr := `{"b":{"b":"b"}}` + + // Generate swap instructions for the contract + swapMsg := fmt.Sprintf(`{"osmosis_swap":{"output_denom":%q,"slippage":{"twap": {"window_seconds": 1, "slippage_percentage":"20"}},"receiver":"chainB/%s", "on_failed_delivery": "do_nothing", "next_memo":%s, "final_memo":%s}}`, + token0, + accountB, + nextMemoStr, + finalMemoStr, + ) + + // Generate full memo + msg := fmt.Sprintf(`{"wasm": {"contract": "%s", "msg": %s } }`, crosschainAddr, swapMsg) + + // Send IBC transfer with the memo with crosschain-swap instructions + channelBA := suite.GetSenderChannel(ChainB, ChainA) + transferMsg := NewMsgTransfer(sdk.NewCoin(token0, osmomath.NewInt(swapAmount)), accountB.String(), crosschainAddr.String(), channelBA, msg) + _, res, _, err := suite.FullSend(transferMsg, BtoA) + suite.Require().NoError(err) + suite.Require().NotNil(res) + + // Check that we include next and final memos + fungibleTokenPacketEvent := findFungibleTokenPacketEvent(res.GetEvents()) + suite.Require().NotNil(fungibleTokenPacketEvent) + + memo, err := parseFungibleTokenPacketEventMemo(fungibleTokenPacketEvent) + suite.Require().NotNil(memo) + suite.Require().NoError(err) + + nextMemo, finalMemo := extractNextAndFinalMemosFromSwapMsg(memo) + suite.Require().NotNil(nextMemo) + suite.Require().NotNil(finalMemo) + + aaa, ok := nextMemo["a"].(map[string]any) + suite.Require().True(ok) + suite.Require().Equal("a", aaa["a"]) + + bbb, ok := finalMemo["b"].(map[string]any) + suite.Require().True(ok) + suite.Require().Equal("b", bbb["b"]) + + // Forward: A => B + packet, err := ibctesting.ParsePacketFromEvents(res.GetEvents()) + suite.Require().NoError(err) + res = suite.RelayPacketNoAck(packet, AtoB) + + // Test that the next and final memos get merged + fungibleTokenPacketEvent = findFungibleTokenPacketEvent(res.GetEvents()) + suite.Require().NotNil(fungibleTokenPacketEvent) + + memo, err = parseFungibleTokenPacketEventMemo(fungibleTokenPacketEvent) + suite.Require().NotNil(memo) + suite.Require().NoError(err) + + aaa, ok = memo["a"].(map[string]any) + suite.Require().True(ok) + suite.Require().Equal("a", aaa["a"]) + + bbb, ok = memo["b"].(map[string]any) + suite.Require().True(ok) + suite.Require().Equal("b", bbb["b"]) +}