Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow supplying final hop memos in XCS contracts #8895

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.1

### State Breaking
Expand Down
7 changes: 6 additions & 1 deletion app/apptesting/test_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 49 additions & 4 deletions app/test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"encoding/json"
"os"
"testing"
"time"

"cosmossdk.io/log"
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cosmwasm/contracts/crosschain-registry/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -92,6 +93,7 @@ pub fn execute(
env.contract.address.to_string(),
env.block.time,
with_memo,
final_memo,
None,
false,
)?;
Expand Down
1 change: 1 addition & 0 deletions cosmwasm/contracts/crosschain-registry/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider passing through the newly introduced final_memo.
Right now, this line sets the final memo to an empty string, effectively discarding any user input. Given that the PR introduces a new final_memo feature in related modules, it may be beneficial to accept and forward the user-supplied final memo here (or confirm this is intentional if no final hop memo is desired in propose_pfm).

Below is an illustrative diff, assuming a new function parameter final_memo: String is made available:

-        String::new(), // no last transfer memo
+        final_memo,    // pass the final memo from user input

Committable suggestion skipped: line range outside the PR's diff.

Some(Callback {
contract: own_addr,
msg: format!(r#"{{"validate_pfm": {{"chain": "{chain}"}} }}"#).try_into()?,
Expand Down
2 changes: 2 additions & 0 deletions cosmwasm/contracts/crosschain-registry/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ pub enum ExecuteMsg {
into_chain: Option<String>,
#[serde(default = "String::new")]
with_memo: String,
#[serde(default = "String::new")]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be #[serde(default)] but not an issue

final_memo: String,
},
}

Expand Down
16 changes: 12 additions & 4 deletions cosmwasm/contracts/crosschain-swaps/src/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand All @@ -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)?;
Expand All @@ -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)))
Expand Down
2 changes: 2 additions & 0 deletions cosmwasm/contracts/crosschain-swaps/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub fn execute(
receiver,
slippage,
next_memo,
final_memo,
on_failed_delivery,
route,
} => execute::unwrap_or_swap_and_forward(
Expand All @@ -67,6 +68,7 @@ pub fn execute(
slippage,
&receiver,
next_memo,
final_memo,
on_failed_delivery,
route,
),
Expand Down
54 changes: 40 additions & 14 deletions cosmwasm/contracts/crosschain-swaps/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ fn ibc_message_event<T: Debug>(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<SerializableJson>,
final_memo: Option<SerializableJson>,
failed_delivery_action: FailedDeliveryAction,
route: Option<Vec<SwapAmountInRoute>>,
) -> Result<Response, ContractError> {
Expand Down Expand Up @@ -73,17 +75,19 @@ 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 {
output_denom,
receiver: receiver.to_string(),
slippage,
next_memo,
final_memo,
Comment on lines +78 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider passing through the final_memo parameter.

Right now, this line sets the final memo to an empty string, effectively discarding any user input. Consider passing through the final_memo parameter to maintain consistency with the feature's intent.

-            String::new(),
+            final_memo.map(|m| serde_json_wasm::to_string(&m)).transpose()?.unwrap_or_default(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
String::new(),
Some(Callback {
contract: env.contract.address.clone(),
msg: serde_cw_value::to_value(&ExecuteMsg::OsmosisSwap {
output_denom,
receiver: receiver.to_string(),
slippage,
next_memo,
final_memo,
final_memo.map(|m| serde_json_wasm::to_string(&m)).transpose()?.unwrap_or_default(),
Some(Callback {
contract: env.contract.address.clone(),
msg: serde_cw_value::to_value(&ExecuteMsg::OsmosisSwap {
output_denom,
receiver: receiver.to_string(),
slippage,
next_memo,
final_memo,

on_failed_delivery: failed_delivery_action.clone(),
route,
})?
.into(),
.try_into()?,
}),
false,
)?;
Expand Down Expand Up @@ -125,6 +129,7 @@ pub fn unwrap_or_swap_and_forward(
slippage,
receiver,
next_memo,
final_memo,
failed_delivery_action,
route,
)
Expand All @@ -142,6 +147,7 @@ pub fn swap_and_forward(
slippage: swaprouter::Slippage,
receiver: &str,
next_memo: Option<SerializableJson>,
final_memo: Option<SerializableJson>,
failed_delivery_action: FailedDeliveryAction,
route: Option<Vec<SwapAmountInRoute>>,
) -> Result<Response, ContractError> {
Expand All @@ -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
Expand All @@ -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,
)?;
Expand Down Expand Up @@ -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,
},
},
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String, ContractError> {
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};
Expand Down
6 changes: 3 additions & 3 deletions cosmwasm/contracts/crosschain-swaps/src/ibc_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 4 additions & 0 deletions cosmwasm/contracts/crosschain-swaps/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SerializableJson>,
/// 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<SerializableJson>,
/// 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.
Expand Down
1 change: 1 addition & 0 deletions cosmwasm/contracts/crosschain-swaps/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct ForwardTo {
pub chain: String,
pub receiver: Addr,
pub next_memo: Option<SerializableJson>,
pub final_memo: Option<SerializableJson>,
pub on_failed_delivery: FailedDeliveryAction,
}

Expand Down
Loading