diff --git a/Cargo.lock b/Cargo.lock index e881f2954b..0df01673b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,19 +1311,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "cw20" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "011c45920f8200bd5d32d4fe52502506f64f2f75651ab408054d4cfc75ca3a9b" -dependencies = [ - "cosmwasm-schema", - "cosmwasm-std", - "cw-utils", - "schemars", - "serde", -] - [[package]] name = "darling" version = "0.13.4" @@ -5690,15 +5677,25 @@ dependencies = [ "cosmwasm-std", "cw-controllers", "cw-storage-plus", - "cw-utils", "cw2", - "cw20", "ethabi", "schemars", "semver", "serde", "thiserror", "token-factory-api", + "ucs01-relay-api", +] + +[[package]] +name = "ucs01-relay-api" +version = "0.1.0" +dependencies = [ + "cosmwasm-schema", + "cosmwasm-std", + "ethabi", + "thiserror", + "token-factory-api", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index be3a9ea0ab..408dfb69ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ unionlabs = { path = "lib/unionlabs", default-features = false } beacon-api = { path = "lib/beacon-api", default-features = false } token-factory-api = { path = "cosmwasm/token-factory-api", default-features = false } +ucs01-relay-api = { path = "cosmwasm/ucs01-relay-api", default-features = false } [patch.crates-io] typenum = { git = "https://github.com/unionlabs/typenum", branch = "ben/192-const-generic-32-bit" } diff --git a/cosmwasm/cosmwasm.nix b/cosmwasm/cosmwasm.nix index 333c78ed21..af2816cac7 100644 --- a/cosmwasm/cosmwasm.nix +++ b/cosmwasm/cosmwasm.nix @@ -4,12 +4,15 @@ ucs01-relay = crane.buildWasmContract { crateDirFromRoot = "cosmwasm/ucs01-relay"; }; + ucs01-relay-api = crane.buildWorkspaceMember { + crateDirFromRoot = "cosmwasm/ucs01-relay-api"; + }; ucs00-pingpong = crane.buildWasmContract { crateDirFromRoot = "cosmwasm/ucs00-pingpong"; }; in { packages = ucs01-relay.packages // ucs00-pingpong.packages; - checks = ucs01-relay.checks // ucs00-pingpong.checks; + checks = ucs01-relay.checks // ucs01-relay-api.checks // ucs00-pingpong.checks; }; } diff --git a/cosmwasm/ucs01-relay-api/Cargo.toml b/cosmwasm/ucs01-relay-api/Cargo.toml new file mode 100644 index 0000000000..dc84021538 --- /dev/null +++ b/cosmwasm/ucs01-relay-api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ucs01-relay-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +ethabi = { version = "18.0.0", default-features = false } +cosmwasm-std = { version = "1.3", features = ["stargate"] } +cosmwasm-schema = { version = "1.3" } +thiserror = { version = "1.0" } +token-factory-api = { workspace = true } diff --git a/cosmwasm/ucs01-relay-api/src/lib.rs b/cosmwasm/ucs01-relay-api/src/lib.rs new file mode 100644 index 0000000000..c087a5c562 --- /dev/null +++ b/cosmwasm/ucs01-relay-api/src/lib.rs @@ -0,0 +1,2 @@ +pub mod protocol; +pub mod types; diff --git a/cosmwasm/ucs01-relay-api/src/protocol.rs b/cosmwasm/ucs01-relay-api/src/protocol.rs new file mode 100644 index 0000000000..db60e0cbc5 --- /dev/null +++ b/cosmwasm/ucs01-relay-api/src/protocol.rs @@ -0,0 +1,280 @@ +use std::fmt::Debug; + +use cosmwasm_std::{ + attr, Addr, Binary, CosmosMsg, Event, IbcBasicResponse, IbcEndpoint, IbcMsg, IbcOrder, + IbcReceiveResponse, Response, SubMsg, Timestamp, +}; +use thiserror::Error; + +use crate::types::{ + EncodingError, GenericAck, TransferPacket, TransferPacketCommon, TransferToken, +}; + +// https://github.com/cosmos/ibc-go/blob/8218aeeef79d556852ec62a773f2bc1a013529d4/modules/apps/transfer/types/keys.go#L12 +pub const MODULE_NAME: &'static str = "transfer"; + +// https://github.com/cosmos/ibc-go/blob/8218aeeef79d556852ec62a773f2bc1a013529d4/modules/apps/transfer/types/events.go#L4-L22 +pub const PACKET_EVENT: &'static str = "fungible_token_packet"; +pub const TRANSFER_EVENT: &'static str = "ibc_transfer"; +pub const TIMEOUT_EVENT: &'static str = "timeout"; + +#[derive(Error, Debug, PartialEq)] +pub enum ProtocolError { + #[error("Channel doesn't exist: {channel_id}")] + NoSuchChannel { channel_id: String }, + #[error("Protocol must be caller")] + Unauthorized, +} + +#[allow(type_alias_bounds)] +pub type PacketExtensionOf = ::Extension; + +pub struct TransferInput { + pub current_time: Timestamp, + pub timeout_delta: u64, + pub sender: Addr, + pub receiver: String, + pub tokens: Vec, +} + +// We follow the following module implementation, events and attributes are +// almost 1:1 with the traditional go implementation. As we generalized the base +// implementation for multi-tokens transfer, the events are not containing a +// single ('denom', 'value') and ('amount', 'value') attributes but rather a set +// of ('denom:x', 'amount_value') attributes for each denom `x` that is +// transferred. i.e. [('denom:muno', '10'), ('denom:port/channel/weth', '150'), ..] +// https://github.com/cosmos/ibc-go/blob/7be17857b10457c67cbf66a49e13a9751eb10e8e/modules/apps/transfer/ibc_module.go +pub trait TransferProtocol { + /// Must be unique per Protocol + const VERSION: &'static str; + const ORDERING: IbcOrder; + /// Must be unique per Protocol + const RECEIVE_REPLY_ID: u64; + + type Packet: TryFrom + + TryInto + + TransferPacket; + + type Ack: TryFrom + + TryInto + + Into; + + type CustomMsg; + + type Error: Debug + From + From; + + fn channel_endpoint(&self) -> &IbcEndpoint; + + fn caller(&self) -> &Addr; + + fn self_addr(&self) -> &Addr; + + fn ack_success() -> Self::Ack; + + fn ack_failure(error: String) -> Self::Ack; + + fn send_tokens( + &mut self, + sender: &str, + receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error>; + + fn send_tokens_success( + &mut self, + sender: &str, + receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error>; + + fn send_tokens_failure( + &mut self, + sender: &str, + receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error>; + + fn send( + &mut self, + mut input: TransferInput, + extension: PacketExtensionOf, + ) -> Result, Self::Error> { + input.tokens = input + .tokens + .into_iter() + .map(|token| { + token.normalize_for_ibc_transfer(self.self_addr().as_str(), self.channel_endpoint()) + }) + .collect(); + + let packet = Self::Packet::try_from(TransferPacketCommon { + sender: input.sender.to_string(), + receiver: input.receiver.clone(), + tokens: input.tokens.clone(), + extension: extension.clone(), + })?; + + let send_msgs = self.send_tokens(packet.sender(), packet.receiver(), packet.tokens())?; + + Ok(Response::new() + .add_messages(send_msgs) + .add_message(IbcMsg::SendPacket { + channel_id: self.channel_endpoint().channel_id.clone(), + data: packet.try_into()?, + timeout: input.current_time.plus_seconds(input.timeout_delta).into(), + }) + .add_events([ + Event::new(TRANSFER_EVENT) + .add_attributes([ + ("sender", input.sender.as_str()), + ("receiver", input.receiver.as_str()), + ("memo", extension.into().as_str()), + ]) + .add_attributes(input.tokens.into_iter().map( + |TransferToken { denom, amount }| (format!("denom:{}", denom), amount), + )), + Event::new("message").add_attribute("module", MODULE_NAME), + ])) + } + + fn send_ack( + &mut self, + raw_ack: impl Into + Clone, + raw_packet: impl Into, + ) -> Result, Self::Error> { + let packet = Self::Packet::try_from(raw_packet.into())?; + // https://github.com/cosmos/ibc-go/blob/5ca37ef6e56a98683cf2b3b1570619dc9b322977/modules/apps/transfer/ibc_module.go#L261 + let ack = Into::::into(Self::Ack::try_from(raw_ack.clone().into())?); + let (ack_msgs, ack_attr) = match ack { + Ok(value) => ( + self.send_tokens_success(packet.sender(), packet.receiver(), packet.tokens())?, + attr("success", value.to_string()), + ), + Err(error) => ( + self.send_tokens_failure(packet.sender(), packet.receiver(), packet.tokens())?, + attr("error", error.to_string()), + ), + }; + Ok(IbcBasicResponse::new() + .add_event( + Event::new(PACKET_EVENT) + .add_attributes([ + ("module", MODULE_NAME), + ("sender", packet.sender()), + ("receiver", packet.receiver()), + ("memo", packet.extension().clone().into().as_str()), + ("acknowledgement", &raw_ack.into().to_string()), + ]) + .add_attributes(packet.tokens().into_iter().map( + |TransferToken { denom, amount }| (format!("denom:{}", denom), amount), + )), + ) + .add_event(Event::new(PACKET_EVENT).add_attributes([ack_attr])) + .add_messages(ack_msgs)) + } + + fn send_timeout( + &mut self, + raw_packet: impl Into, + ) -> Result, Self::Error> { + let packet = Self::Packet::try_from(raw_packet.into())?; + // same branch as failure ack + let refund_msgs = + self.send_tokens_failure(packet.sender(), packet.receiver(), packet.tokens())?; + Ok(IbcBasicResponse::new() + .add_event( + Event::new(TIMEOUT_EVENT) + .add_attributes([ + ("module", MODULE_NAME), + ("refund_receiver", packet.sender()), + ("memo", packet.extension().clone().into().as_str()), + ]) + .add_attributes(packet.tokens().into_iter().map( + |TransferToken { denom, amount }| (format!("denom:{}", denom), amount), + )), + ) + .add_messages(refund_msgs)) + } + + fn make_receive_phase1_execute( + &mut self, + raw_packet: impl Into, + ) -> Result, Self::Error>; + + fn receive_phase0( + &mut self, + raw_packet: impl Into + Clone, + ) -> IbcReceiveResponse { + let handle = || -> Result, Self::Error> { + let packet = Self::Packet::try_from(raw_packet.clone().into())?; + + // NOTE: The default message ack is always successful and only + // overwritten if the submessage execution revert via the reply handler. + // the caller MUST ENSURE that the reply is threaded through the + // protocol. + let execute_msg = SubMsg::reply_on_error( + self.make_receive_phase1_execute(raw_packet)?, + Self::RECEIVE_REPLY_ID, + ); + + Ok(IbcReceiveResponse::new() + .set_ack(Self::ack_success().try_into()?) + .add_event( + Event::new(PACKET_EVENT) + .add_attributes([ + ("module", MODULE_NAME), + ("sender", packet.sender()), + ("receiver", packet.receiver()), + ("memo", packet.extension().clone().into().as_str()), + ("success", "true"), + ]) + .add_attributes(packet.tokens().into_iter().map( + |TransferToken { denom, amount }| (format!("denom:{}", denom), amount), + )), + ) + .add_submessage(execute_msg)) + }; + + match handle() { + Ok(response) => response, + // NOTE: same branch as if the submessage fails + Err(err) => Self::receive_error(err), + } + } + + fn receive_phase1_transfer( + &mut self, + receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error>; + + fn receive_phase1( + &mut self, + raw_packet: impl Into, + ) -> Result, Self::Error> { + let packet = Self::Packet::try_from(raw_packet.into())?; + + // Only the running contract is allowed to execute this message + if self.caller() != self.self_addr() { + return Err(ProtocolError::Unauthorized.into()); + } + + Ok(Response::new() + .add_messages(self.receive_phase1_transfer(packet.receiver(), packet.tokens())?)) + } + + fn receive_error(error: impl Debug) -> IbcReceiveResponse { + let error = format!("{:?}", error); + IbcReceiveResponse::new() + .set_ack( + Self::ack_failure(error.clone()) + .try_into() + .expect("impossible"), + ) + .add_event(Event::new(PACKET_EVENT).add_attributes([ + ("module", MODULE_NAME), + ("success", "false"), + ("error", &error), + ])) + } +} diff --git a/cosmwasm/ucs01-relay-api/src/types.rs b/cosmwasm/ucs01-relay-api/src/types.rs new file mode 100644 index 0000000000..01738adc1e --- /dev/null +++ b/cosmwasm/ucs01-relay-api/src/types.rs @@ -0,0 +1,580 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Binary, Coin, IbcEndpoint, Uint256}; +use ethabi::{ParamType, Token}; + +pub type GenericAck = Result; + +#[derive(thiserror::Error, Clone, PartialEq, Eq, Debug)] +pub enum EncodingError { + #[error("ICS20 can handle a single coin only.")] + Ics20OnlyOneCoin, + #[error("Unable to encode or decode the transfer packet.")] + InvalidEncoding, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TransferPacketCommon { + pub sender: String, + pub receiver: String, + pub tokens: Vec, + pub extension: T, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TransferToken { + pub denom: String, + pub amount: Uint256, +} + +impl TransferToken { + // If a denom originated from a remote network, it will be in the form: + // `factory/{contract_address}/{port_id}/{channel_id}/denom` + // In order for the remote module to consider this denom as local, we must + // strip the `factory/{contract_address}/` prefix before sending the tokens. + pub fn normalize_for_ibc_transfer( + self, + contract_address: &str, + endpoint: &IbcEndpoint, + ) -> Self { + let normalized_denom = match self + .denom + .strip_prefix("factory/") + .and_then(|denom| denom.strip_prefix(contract_address)) + .and_then(|denom| denom.strip_prefix("/")) + { + // This is a denom created by us + Some(factory_denom) => { + // Check whether it's a remotely created denom by switching + // to the remote POV and using the DenomOrigin parser. + match DenomOrigin::from((factory_denom, endpoint)) { + DenomOrigin::Local { denom } => denom.to_string(), + DenomOrigin::Remote { .. } => self.denom, + } + } + None => self.denom, + }; + TransferToken { + denom: normalized_denom.to_string(), + amount: self.amount, + } + } +} + +impl From for TransferToken { + fn from(value: Coin) -> Self { + Self { + denom: value.denom, + amount: value.amount.into(), + } + } +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Ucs01TransferPacket { + /// the sender address + sender: String, + /// the recipient address on the destination chain + receiver: String, + /// the transferred tokens + tokens: Vec, +} + +impl Ucs01TransferPacket { + pub fn sender(&self) -> &String { + &self.sender + } + + pub fn receiver(&self) -> &String { + &self.receiver + } + + pub fn tokens(&self) -> &Vec { + &self.tokens + } + + pub fn new(sender: String, receiver: String, tokens: Vec) -> Self { + Self { + sender, + receiver, + tokens, + } + } +} + +impl TryFrom for Binary { + type Error = EncodingError; + + fn try_from(value: Ucs01TransferPacket) -> Result { + Ok(ethabi::encode(&[ + Token::String(value.sender), + Token::String(value.receiver), + Token::Array( + value + .tokens + .into_iter() + .map(|TransferToken { denom, amount }| { + Token::Tuple(vec![ + Token::String(denom), + Token::Uint(amount.to_be_bytes().into()), + ]) + }) + .collect(), + ), + ]) + .into()) + } +} + +impl TryFrom for Ucs01TransferPacket { + type Error = EncodingError; + + fn try_from(value: Binary) -> Result { + let encoded_packet = ethabi::decode( + &[ + ParamType::String, + ParamType::String, + ParamType::Array(Box::new(ParamType::Tuple(vec![ + ParamType::String, + ParamType::Uint(256), + ]))), + ], + &value, + ) + .map_err(|_| EncodingError::InvalidEncoding)?; + // NOTE: at this point, it is technically impossible to have any other branch than the one we + // match unless there is a bug in the underlying `ethabi` crate + match &encoded_packet[..] { + [Token::String(sender), Token::String(receiver), Token::Array(tokens)] => { + Ok(Ucs01TransferPacket { + sender: sender.clone(), + receiver: receiver.clone(), + tokens: tokens + .into_iter() + .map(|encoded_token| { + if let Token::Tuple(encoded_token_inner) = encoded_token { + match &encoded_token_inner[..] { + [Token::String(denom), Token::Uint(amount)] => TransferToken { + denom: denom.clone(), + // NOTE: both structures uses big endian by default + amount: Uint256::new(amount.clone().into()), + }, + _ => unreachable!(), + } + } else { + unreachable!() + } + }) + .collect(), + }) + } + _ => unreachable!(), + } + } +} + +// https://github.com/cosmos/ibc/tree/0cd8028ea593a240723d13bba17f3d61d62397ad/spec/app/ics-020-fungible-token-transfer#data-structures +// https://github.com/cosmos/ibc-go/blob/d02ab9db8fc80eb5e55041d3d6416370c33441f7/proto/ibc/applications/transfer/v2/packet.proto +#[cw_serde] +pub struct Ics20Packet { + pub denom: String, + pub amount: Uint256, + pub sender: String, + pub receiver: String, + pub memo: String, +} + +impl TryFrom for Binary { + type Error = EncodingError; + fn try_from(value: Ics20Packet) -> Result { + cosmwasm_std::to_vec(&value) + .map_err(|_| EncodingError::InvalidEncoding) + .map(Into::into) + } +} + +impl TryFrom for Ics20Packet { + type Error = EncodingError; + fn try_from(value: Binary) -> Result { + cosmwasm_std::from_slice(&value).map_err(|_| EncodingError::InvalidEncoding) + } +} + +pub trait TransferPacket: + TryFrom, Error = EncodingError> +{ + type Extension: Into + Clone; + + // NOTE: can't ref here because cosmwasm_std::Coins don't impl iterator nor + // exposes the underlying BTreeMap... + fn tokens(&self) -> Vec; + + fn sender(&self) -> &str; + + fn receiver(&self) -> &str; + + fn extension(&self) -> &Self::Extension; +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct NoExtension; + +impl From for String { + fn from(_: NoExtension) -> Self { + String::new() + } +} + +impl TransferPacket for Ucs01TransferPacket { + type Extension = NoExtension; + + fn tokens(&self) -> Vec { + self.tokens().clone() + } + + fn sender(&self) -> &str { + &self.sender + } + + fn receiver(&self) -> &str { + &self.receiver + } + + fn extension(&self) -> &Self::Extension { + &NoExtension + } +} + +impl TransferPacket for Ics20Packet { + type Extension = String; + + fn tokens(&self) -> Vec { + vec![TransferToken { + denom: self.denom.clone(), + amount: self.amount, + }] + } + + fn sender(&self) -> &str { + &self.sender + } + + fn receiver(&self) -> &str { + &self.receiver + } + + fn extension(&self) -> &Self::Extension { + &self.memo + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Ucs01Ack { + Failure, + Success, +} + +impl TryFrom for Binary { + type Error = EncodingError; + + fn try_from(value: Ucs01Ack) -> Result { + Ok(match value { + Ucs01Ack::Failure => [0].into(), + Ucs01Ack::Success => [1].into(), + }) + } +} + +impl TryFrom for Ucs01Ack { + type Error = EncodingError; + + fn try_from(value: Binary) -> Result { + match value.as_slice() { + [0] => Ok(Ucs01Ack::Failure), + [1] => Ok(Ucs01Ack::Success), + _ => Err(EncodingError::InvalidEncoding), + } + } +} + +impl From for GenericAck { + fn from(value: Ucs01Ack) -> Self { + match value { + Ucs01Ack::Failure => Err(Default::default()), + Ucs01Ack::Success => Ok(Default::default()), + } + } +} + +/// Standard ICS20 acknowledgement https://github.com/cosmos/cosmos-sdk/blob/v0.42.0/proto/ibc/core/channel/v1/channel.proto#L141-L147 +#[cw_serde] +pub enum Ics20Ack { + Result(Binary), + Error(String), +} + +impl TryFrom for Binary { + type Error = EncodingError; + fn try_from(value: Ics20Ack) -> Result { + Ok(cosmwasm_std::to_vec(&value) + .map_err(|_| EncodingError::InvalidEncoding)? + .into()) + } +} + +impl TryFrom for Ics20Ack { + type Error = EncodingError; + // Interesting, the Error variant of the enum clash with the AT in the return type, https://github.com/rust-lang/rust/issues/57644 + fn try_from(value: Binary) -> Result>::Error> { + cosmwasm_std::from_slice::(&value).map_err(|_| EncodingError::InvalidEncoding) + } +} + +impl From for GenericAck { + fn from(value: Ics20Ack) -> Self { + match value { + Ics20Ack::Result(err) => Ok(err), + Ics20Ack::Error(err) => Err(err), + } + } +} + +impl TryFrom> for Ucs01TransferPacket { + type Error = EncodingError; + + fn try_from( + TransferPacketCommon { + sender, + receiver, + tokens, + .. + }: TransferPacketCommon, + ) -> Result { + Ok(Self::new(sender, receiver, tokens)) + } +} + +impl TryFrom> for Ics20Packet { + type Error = EncodingError; + + fn try_from( + TransferPacketCommon { + sender, + receiver, + tokens, + extension, + }: TransferPacketCommon, + ) -> Result { + let (denom, amount) = match &tokens[..] { + [TransferToken { denom, amount }] => Ok((denom.clone(), amount.clone())), + _ => Err(EncodingError::Ics20OnlyOneCoin), + }?; + Ok(Self { + sender, + receiver, + denom, + amount: amount.into(), + memo: extension, + }) + } +} + +// https://github.com/cosmos/ibc/blob/main/spec/app/ics-020-fungible-token-transfer/README.md#data-structures +// SPEC: {ics20Port}/{ics20Channel}/{denom} +pub fn make_foreign_denom(endpoint: &IbcEndpoint, denom: &str) -> String { + format!("{}/{}/{}", endpoint.port_id, endpoint.channel_id, denom) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DenomOrigin<'a> { + Local { denom: &'a str }, + Remote { denom: &'a str }, +} + +impl<'a> From<(&'a str, &IbcEndpoint)> for DenomOrigin<'a> { + fn from((denom, remote_endpoint): (&'a str, &IbcEndpoint)) -> Self { + // https://github.com/cosmos/ibc/blob/main/spec/app/ics-020-fungible-token-transfer/README.md#data-structures + // SPEC: {ics20Port}/{ics20Channel}/{denom} + // The denom is local IFF we can strip all prefixes + match denom + .strip_prefix(&remote_endpoint.port_id) + .and_then(|denom| denom.strip_prefix("/")) + .and_then(|denom| denom.strip_prefix(&remote_endpoint.channel_id)) + .and_then(|denom| denom.strip_prefix("/")) + { + Some(denom) => DenomOrigin::Local { denom }, + None => DenomOrigin::Remote { denom }, + } + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{Binary, IbcEndpoint, Uint256}; + + use super::{Ics20Packet, TransferToken, Ucs01Ack, Ucs01TransferPacket}; + use crate::types::{DenomOrigin, Ics20Ack}; + + #[test] + fn ucs01_packet_encode_decode_iso() { + let packet = Ucs01TransferPacket { + sender: "a".into(), + receiver: "b".into(), + tokens: vec![ + TransferToken { + denom: "denom-0".into(), + amount: Uint256::from(1u32), + }, + TransferToken { + denom: "denom-1".into(), + amount: Uint256::MAX, + }, + TransferToken { + denom: "denom-2".into(), + amount: Uint256::from(1337u32), + }, + ], + }; + assert_eq!( + packet, + Binary::try_from(packet.clone()) + .unwrap() + .try_into() + .unwrap() + ); + } + + #[test] + fn ucs01_ack_encode_decode_iso() { + assert_eq!( + Ok(Ucs01Ack::Success), + Binary::try_from(Ucs01Ack::Success).unwrap().try_into() + ); + assert_eq!( + Ok(Ucs01Ack::Failure), + Binary::try_from(Ucs01Ack::Failure).unwrap().try_into() + ); + } + + #[test] + fn ics20_packet_encode_decode_iso() { + let packet = Ics20Packet { + denom: "a".into(), + amount: Uint256::from(1337u32), + sender: "c".into(), + receiver: "d".into(), + memo: "bla".into(), + }; + assert_eq!( + packet, + Binary::try_from(packet.clone()) + .unwrap() + .try_into() + .unwrap() + ); + } + + #[test] + fn ics20_ack_encode_decode_iso() { + assert_eq!( + Ok(Ics20Ack::Result(b"blabla".into())), + Binary::try_from(Ics20Ack::Result(b"blabla".into())) + .unwrap() + .try_into() + ); + assert_eq!( + Ok(Ics20Ack::Error("ok".into())), + Binary::try_from(Ics20Ack::Error("ok".into())) + .unwrap() + .try_into() + ); + } + + #[test] + fn denom_origin_parse_local() { + assert_eq!( + DenomOrigin::try_from(( + "port-1433/channel-44/token-k", + &IbcEndpoint { + port_id: "port-1433".into(), + channel_id: "channel-44".into(), + } + )), + Ok(DenomOrigin::Local { denom: "token-k" }) + ); + } + + #[test] + fn denom_origin_parse_remote() { + assert_eq!( + DenomOrigin::try_from(( + "blabla/ok/-k", + &IbcEndpoint { + port_id: "port-1433".into(), + channel_id: "channel-44".into(), + } + )), + Ok(DenomOrigin::Remote { + denom: "blabla/ok/-k".into() + }) + ); + } + + #[test] + fn transfer_token_normalize_identity() { + assert_eq!( + TransferToken { + denom: "factory/0xDEADC0DE/blabla-1".into(), + amount: Uint256::MAX + } + .normalize_for_ibc_transfer( + "0xDEADC0DE", + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-332".into() + } + ), + TransferToken { + denom: "factory/0xDEADC0DE/blabla-1".into(), + amount: Uint256::MAX + } + ); + assert_eq!( + TransferToken { + denom: "factory/0xDEADC0DE/transfer/channel-441/blabla-1".into(), + amount: Uint256::MAX + } + .normalize_for_ibc_transfer( + "0xDEADC0DE", + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-332".into() + } + ), + TransferToken { + denom: "factory/0xDEADC0DE/transfer/channel-441/blabla-1".into(), + amount: Uint256::MAX + } + ); + } + + #[test] + fn transfer_token_normalize_strip() { + assert_eq!( + TransferToken { + denom: "factory/0xDEADC0DE/transfer/channel-332/blabla-1".into(), + amount: Uint256::MAX + } + .normalize_for_ibc_transfer( + "0xDEADC0DE", + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-332".into() + } + ), + TransferToken { + denom: "blabla-1".into(), + amount: Uint256::MAX + } + ); + } +} diff --git a/cosmwasm/ucs01-relay/Cargo.toml b/cosmwasm/ucs01-relay/Cargo.toml index ef1fd67bdf..d1a5d77ad9 100644 --- a/cosmwasm/ucs01-relay/Cargo.toml +++ b/cosmwasm/ucs01-relay/Cargo.toml @@ -14,14 +14,13 @@ library = [] [dependencies] ethabi = { version = "18.0.0", default-features = false } cosmwasm-schema = { version = "1.3" } -cw-utils = { version = "1.0.1" } cw2 = { version = "1.1" } -cw20 = { version = "1.1" } cw-controllers = { version = "1.1" } cw-storage-plus = { version = "1.1" } -cosmwasm-std = { version = "1.3", features = ["stargate"] } +cosmwasm-std = { version = "1.3", features = ["stargate", "ibc3", "cosmwasm_1_3"] } schemars = "0.8" semver = "1" serde = { version = "1.0", default-features = false, features = ["derive"] } thiserror = { version = "1.0" } token-factory-api = { workspace = true } +ucs01-relay-api = { workspace = true } diff --git a/cosmwasm/ucs01-relay/src/amount.rs b/cosmwasm/ucs01-relay/src/amount.rs deleted file mode 100644 index 247d826d91..0000000000 --- a/cosmwasm/ucs01-relay/src/amount.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::convert::TryInto; - -use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Coin, Uint128}; -use cw20::Cw20Coin; - -use crate::error::ContractError; - -const CW20_DENOM_PREFIX: &str = "cw20:"; - -#[cw_serde] -pub enum Amount { - Native(Coin), - // FIXME? USe Cw20CoinVerified, and validate cw20 addresses - Cw20(Cw20Coin), -} - -impl Amount { - pub fn from_parts(denom: String, amount: impl Into) -> Self { - let amount = amount.into(); - match denom.strip_prefix(CW20_DENOM_PREFIX) { - Some(address) => Amount::Cw20(Cw20Coin { - address: address.to_string(), - amount, - }), - None => Amount::Native(Coin { denom, amount }), - } - } - - pub fn cw20(amount: u128, addr: &str) -> Self { - Amount::Cw20(Cw20Coin { - address: addr.into(), - amount: Uint128::new(amount), - }) - } - - pub fn native(amount: u128, denom: &str) -> Self { - Amount::Native(Coin { - denom: denom.to_string(), - amount: Uint128::new(amount), - }) - } -} - -impl Amount { - pub fn denom(&self) -> String { - match self { - Amount::Native(c) => c.denom.clone(), - Amount::Cw20(c) => format!("{}{}", CW20_DENOM_PREFIX, c.address.as_str()), - } - } - - pub fn amount(&self) -> Uint128 { - match self { - Amount::Native(c) => c.amount, - Amount::Cw20(c) => c.amount, - } - } - - /// convert the amount into u64 - pub fn u64_amount(&self) -> Result { - Ok(self.amount().u128().try_into()?) - } - - pub fn is_empty(&self) -> bool { - match self { - Amount::Native(c) => c.amount.is_zero(), - Amount::Cw20(c) => c.amount.is_zero(), - } - } -} diff --git a/cosmwasm/ucs01-relay/src/contract.rs b/cosmwasm/ucs01-relay/src/contract.rs index ee99eb3a1f..b549e1b93a 100644 --- a/cosmwasm/ucs01-relay/src/contract.rs +++ b/cosmwasm/ucs01-relay/src/contract.rs @@ -1,74 +1,45 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - from_binary, to_binary, Addr, Binary, Deps, DepsMut, Env, IbcMsg, IbcQuery, MessageInfo, Order, + to_binary, Addr, Binary, Coins, Deps, DepsMut, Env, IbcQuery, MessageInfo, Order, PortIdResponse, Response, StdError, StdResult, }; -use cw2::{get_contract_version, set_contract_version}; -use cw20::{Cw20Coin, Cw20ReceiveMsg}; -use cw_storage_plus::Bound; -use cw_utils::{maybe_addr, nonpayable, one_coin}; -use semver::Version; +use cw2::set_contract_version; +use token_factory_api::TokenFactoryMsg; +use ucs01_relay_api::{ + protocol::{TransferInput, TransferProtocol}, + types::{NoExtension, TransferToken}, +}; use crate::{ - amount::Amount, error::ContractError, - ibc::{enforce_order_and_version, Ics20Packet}, - migrations::{v1, v2}, msg::{ - AllowMsg, AllowedInfo, AllowedResponse, ChannelResponse, ConfigResponse, ExecuteMsg, - InitMsg, ListAllowedResponse, ListChannelsResponse, MigrateMsg, PortResponse, QueryMsg, - TransferMsg, - }, - state::{ - increase_channel_balance, AllowInfo, ChannelInfo, Config, ADMIN, ALLOW_LIST, CHANNEL_INFO, - CHANNEL_STATE, CONFIG, + ChannelResponse, ConfigResponse, ExecuteMsg, InitMsg, ListChannelsResponse, MigrateMsg, + PortResponse, QueryMsg, ReceivePhase1Msg, TransferMsg, }, + protocol::{Ics20Protocol, ProtocolCommon, Ucs01Protocol}, + state::{Config, ADMIN, CHANNEL_INFO, CHANNEL_STATE, CONFIG}, }; -// version info for migration info const CONTRACT_NAME: &str = "crates.io:ucs01-relay"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( mut deps: DepsMut, - env: Env, + _env: Env, _info: MessageInfo, msg: InitMsg, ) -> Result { set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; let cfg = Config { default_timeout: msg.default_timeout, - default_gas_limit: msg.default_gas_limit, }; CONFIG.save(deps.storage, &cfg)?; let admin = deps.api.addr_validate(&msg.gov_contract)?; ADMIN.set(deps.branch(), Some(admin))?; - // add all allows - for allowed in msg.allowlist { - let contract = deps.api.addr_validate(&allowed.contract)?; - let info = AllowInfo { - gas_limit: allowed.gas_limit, - }; - ALLOW_LIST.save(deps.storage, &contract, &info)?; - } - - if let Some(mut channel) = msg.channel { - // We need this to be able to compute the contract address. Otherwise, the contract address - // would depend on the contract's address before it's initialization. - channel.endpoint.port_id = format!("wasm.{}", env.contract.address); - enforce_order_and_version(&channel, None)?; - let info = ChannelInfo { - id: channel.endpoint.channel_id, - counterparty_endpoint: channel.counterparty_endpoint, - connection_id: channel.connection_id, - }; - CHANNEL_INFO.save(deps.storage, &info.id, &info)?; - } - Ok(Response::default()) } @@ -78,212 +49,112 @@ pub fn execute( env: Env, info: MessageInfo, msg: ExecuteMsg, -) -> Result { +) -> Result, ContractError> { match msg { - ExecuteMsg::Receive(msg) => execute_receive(deps, env, info, msg), - ExecuteMsg::Transfer(msg) => { - let coin = one_coin(&info)?; - execute_transfer(deps, env, msg, Amount::Native(coin), info.sender) - } - ExecuteMsg::Allow(allow) => execute_allow(deps, env, info, allow), + ExecuteMsg::Transfer(msg) => execute_transfer(deps, env, info, msg), ExecuteMsg::UpdateAdmin { admin } => { let admin = deps.api.addr_validate(&admin)?; Ok(ADMIN.execute_update_admin(deps, info, Some(admin))?) } + ExecuteMsg::ReceivePhase1(msg) => execute_receive_phase1(deps, env, info, msg), } } -pub fn execute_receive( - deps: DepsMut, - env: Env, - info: MessageInfo, - wrapper: Cw20ReceiveMsg, -) -> Result { - nonpayable(&info)?; - - let msg: TransferMsg = from_binary(&wrapper.msg)?; - let amount = Amount::Cw20(Cw20Coin { - address: info.sender.to_string(), - amount: wrapper.amount, - }); - let api = deps.api; - execute_transfer(deps, env, msg, amount, api.addr_validate(&wrapper.sender)?) -} - pub fn execute_transfer( deps: DepsMut, env: Env, + info: MessageInfo, msg: TransferMsg, - amount: Amount, - sender: Addr, -) -> Result { - if amount.is_empty() { +) -> Result, ContractError> { + let tokens: Vec = Coins::try_from(info.funds.clone()) + .map_err(|_| StdError::generic_err("Couldn't decode funds to Coins"))? + .into_vec() + .into_iter() + .map(Into::into) + .collect(); + + // At least one token must be transferred + if tokens.is_empty() { return Err(ContractError::NoFunds {}); } - // ensure the requested channel is registered - if !CHANNEL_INFO.has(deps.storage, &msg.channel) { - return Err(ContractError::NoSuchChannel { id: msg.channel }); - } - let config = CONFIG.load(deps.storage)?; - // if cw20 token, validate and ensure it is whitelisted, or we set default gas limit - if let Amount::Cw20(coin) = &amount { - let addr = deps.api.addr_validate(&coin.address)?; - // if limit is set, then we always allow cw20 - if config.default_gas_limit.is_none() { - ALLOW_LIST - .may_load(deps.storage, &addr)? - .ok_or(ContractError::NotOnAllowList)?; - } - }; + let channel_info = CHANNEL_INFO.load(deps.storage, &msg.channel)?; - // delta from user is in seconds - let timeout_delta = match msg.timeout { - Some(t) => t, - None => config.default_timeout, - }; - // timeout is in nanoseconds - let timeout = env.block.time.plus_seconds(timeout_delta); - - // build ics20 packet - let packet = Ics20Packet::new( - amount.u64_amount()?, - amount.denom(), - sender.as_ref(), - &msg.remote_address, - ); - - // Update the balance now (optimistically) like ibctransfer modules. - // In on_packet_failure (ack with error message or a timeout), we reduce the balance appropriately. - // This means the channel works fine if success acks are not relayed. - increase_channel_balance(deps.storage, &msg.channel, &amount.denom(), amount.amount())?; - - let res = Response::new() - .add_attribute("action", "transfer") - .add_attribute("sender", &packet.sender) - .add_attribute("receiver", &packet.receiver) - .add_attribute("denom", &packet.denom) - .add_attribute("amount", packet.amount.to_string()); + let config = CONFIG.load(deps.storage)?; - // prepare ibc message - let msg = IbcMsg::SendPacket { - channel_id: msg.channel, - data: packet.eth_encode(), - timeout: timeout.into(), + let input = TransferInput { + current_time: env.block.time, + timeout_delta: config.default_timeout, + sender: info.sender.clone(), + receiver: msg.receiver, + tokens, }; - Ok(res.add_message(msg)) + match channel_info.protocol_version.as_str() { + Ics20Protocol::VERSION => Ics20Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .send(input, msg.memo), + Ucs01Protocol::VERSION => Ucs01Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .send(input, NoExtension), + v => Err(ContractError::UnknownProtocol { + channel_id: msg.channel, + protocol_version: v.into(), + }), + } } -/// The gov contract can allow new contracts, or increase the gas limit on existing contracts. -/// It cannot block or reduce the limit to avoid forcible sticking tokens in the channel. -pub fn execute_allow( +pub fn execute_receive_phase1( deps: DepsMut, - _env: Env, + env: Env, info: MessageInfo, - allow: AllowMsg, -) -> Result { - ADMIN.assert_admin(deps.as_ref(), &info.sender)?; - - let contract = deps.api.addr_validate(&allow.contract)?; - let set = AllowInfo { - gas_limit: allow.gas_limit, - }; - ALLOW_LIST.update(deps.storage, &contract, |old| { - if let Some(old) = old { - // we must ensure it increases the limit - match (old.gas_limit, set.gas_limit) { - (None, Some(_)) => return Err(ContractError::CannotLowerGas), - (Some(old), Some(new)) if new < old => return Err(ContractError::CannotLowerGas), - _ => {} - }; + msg: ReceivePhase1Msg, +) -> Result, ContractError> { + let channel_info = CHANNEL_INFO.load(deps.storage, &msg.channel)?; + + match channel_info.protocol_version.as_str() { + Ics20Protocol::VERSION => Ics20Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, } - Ok(AllowInfo { - gas_limit: allow.gas_limit, - }) - })?; - - let gas = if let Some(gas) = allow.gas_limit { - gas.to_string() - } else { - "None".to_string() - }; - - let res = Response::new() - .add_attribute("action", "allow") - .add_attribute("contract", allow.contract) - .add_attribute("gas_limit", gas); - Ok(res) + .receive_phase1(msg.raw_packet), + Ucs01Protocol::VERSION => Ucs01Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .receive_phase1(msg.raw_packet), + v => Err(ContractError::UnknownProtocol { + channel_id: msg.channel, + protocol_version: v.into(), + }), + } } -const MIGRATE_MIN_VERSION: &str = "0.11.1"; -const MIGRATE_VERSION_2: &str = "0.12.0-alpha1"; -// the new functionality starts in 0.13.1, this is the last release that needs to be migrated to v3 -const MIGRATE_VERSION_3: &str = "0.13.0"; - #[cfg_attr(not(feature = "library"), entry_point)] -pub fn migrate(mut deps: DepsMut, env: Env, msg: MigrateMsg) -> Result { - let version: Version = CONTRACT_VERSION.parse().map_err(from_semver)?; - let stored = get_contract_version(deps.storage)?; - let storage_version: Version = stored.version.parse().map_err(from_semver)?; - - // First, ensure we are working from an equal or older version of this contract - // wrong type - if CONTRACT_NAME != stored.contract { - return Err(ContractError::CannotMigrate { - previous_contract: stored.contract, - }); - } - // existing one is newer - if storage_version > version { - return Err(ContractError::CannotMigrateVersion { - previous_version: stored.version, - }); - } - - // Then, run the proper migration - if storage_version < MIGRATE_MIN_VERSION.parse().map_err(from_semver)? { - return Err(ContractError::CannotMigrateVersion { - previous_version: stored.version, - }); - } - // run the v1->v2 converstion if we are v1 style - if storage_version <= MIGRATE_VERSION_2.parse().map_err(from_semver)? { - let old_config = v1::CONFIG.load(deps.storage)?; - ADMIN.set(deps.branch(), Some(old_config.gov_contract))?; - let config = Config { - default_timeout: old_config.default_timeout, - default_gas_limit: None, - }; - CONFIG.save(deps.storage, &config)?; - } - // run the v2->v3 converstion if we are v2 style - if storage_version <= MIGRATE_VERSION_3.parse().map_err(from_semver)? { - v2::update_balances(deps.branch(), &env)?; - } - // otherwise no migration (yet) - add them here - - // always allow setting the default gas limit via MigrateMsg, even if same version - // (Note this doesn't allow unsetting it now) - if msg.default_gas_limit.is_some() { - CONFIG.update(deps.storage, |mut old| -> StdResult<_> { - old.default_gas_limit = msg.default_gas_limit; - Ok(old) - })?; - } - - // we don't need to save anything if migrating from the same version - if storage_version < version { - set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - } - +pub fn migrate(_: DepsMut, _: Env, _: MigrateMsg) -> Result { Ok(Response::new()) } -fn from_semver(err: semver::Error) -> StdError { - StdError::generic_err(format!("Semver: {}", err)) -} - #[cfg_attr(not(feature = "library"), entry_point)] pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { @@ -291,10 +162,6 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { QueryMsg::ListChannels {} => to_binary(&query_list(deps)?), QueryMsg::Channel { id } => to_binary(&query_channel(deps, id)?), QueryMsg::Config {} => to_binary(&query_config(deps)?), - QueryMsg::Allowed { contract } => to_binary(&query_allowed(deps, contract)?), - QueryMsg::ListAllowed { start_after, limit } => { - to_binary(&list_allowed(deps, start_after, limit)?) - } QueryMsg::Admin {} => to_binary(&ADMIN.query_admin(deps)?), } } @@ -316,26 +183,12 @@ fn query_list(deps: Deps) -> StdResult { // make public for ibc tests pub fn query_channel(deps: Deps, id: String) -> StdResult { let info = CHANNEL_INFO.load(deps.storage, &id)?; - // this returns Vec<(outstanding, total)> - let state = CHANNEL_STATE + let balances = CHANNEL_STATE .prefix(&id) .range(deps.storage, None, None, Order::Ascending) - .map(|r| { - r.map(|(denom, v)| { - let outstanding = Amount::from_parts(denom.clone(), v.outstanding); - let total = Amount::from_parts(denom, v.total_sent); - (outstanding, total) - }) - }) + .map(|r| r.map(|(denom, v)| (denom.clone(), v.outstanding))) .collect::>>()?; - // we want (Vec, Vec) - let (balances, total_sent) = state.into_iter().unzip(); - - Ok(ChannelResponse { - info, - balances, - total_sent, - }) + Ok(ChannelResponse { info, balances }) } fn query_config(deps: Deps) -> StdResult { @@ -343,329 +196,7 @@ fn query_config(deps: Deps) -> StdResult { let admin = ADMIN.get(deps)?.unwrap_or_else(|| Addr::unchecked("")); let res = ConfigResponse { default_timeout: cfg.default_timeout, - default_gas_limit: cfg.default_gas_limit, gov_contract: admin.into(), }; Ok(res) } - -fn query_allowed(deps: Deps, contract: String) -> StdResult { - let addr = deps.api.addr_validate(&contract)?; - let info = ALLOW_LIST.may_load(deps.storage, &addr)?; - let res = match info { - None => AllowedResponse { - is_allowed: false, - gas_limit: None, - }, - Some(a) => AllowedResponse { - is_allowed: true, - gas_limit: a.gas_limit, - }, - }; - Ok(res) -} - -// settings for pagination -const MAX_LIMIT: u32 = 30; -const DEFAULT_LIMIT: u32 = 10; - -fn list_allowed( - deps: Deps, - start_after: Option, - limit: Option, -) -> StdResult { - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; - let addr = maybe_addr(deps.api, start_after)?; - let start = addr.as_ref().map(Bound::exclusive); - - let allow = ALLOW_LIST - .range(deps.storage, start, None, Order::Ascending) - .take(limit) - .map(|item| { - item.map(|(addr, allow)| AllowedInfo { - contract: addr.into(), - gas_limit: allow.gas_limit, - }) - }) - .collect::>()?; - Ok(ListAllowedResponse { allow }) -} - -#[cfg(test)] -mod test { - use cosmwasm_std::{ - coin, coins, - testing::{mock_env, mock_info, MOCK_CONTRACT_ADDR}, - CosmosMsg, IbcMsg, StdError, Uint128, - }; - use cw_utils::PaymentError; - - use super::*; - use crate::{state::ChannelState, test_helpers::*}; - - #[test] - fn setup_and_query() { - let deps = setup(&["channel-3", "channel-7"], &[]); - - let raw_list = query(deps.as_ref(), mock_env(), QueryMsg::ListChannels {}).unwrap(); - let list_res: ListChannelsResponse = from_binary(&raw_list).unwrap(); - assert_eq!(2, list_res.channels.len()); - assert_eq!(mock_channel_info("channel-3"), list_res.channels[0]); - assert_eq!(mock_channel_info("channel-7"), list_res.channels[1]); - - let raw_channel = query( - deps.as_ref(), - mock_env(), - QueryMsg::Channel { - id: "channel-3".to_string(), - }, - ) - .unwrap(); - let chan_res: ChannelResponse = from_binary(&raw_channel).unwrap(); - assert_eq!(chan_res.info, mock_channel_info("channel-3")); - assert_eq!(0, chan_res.total_sent.len()); - assert_eq!(0, chan_res.balances.len()); - - let err = query( - deps.as_ref(), - mock_env(), - QueryMsg::Channel { - id: "channel-10".to_string(), - }, - ) - .unwrap_err(); - assert_eq!(err, StdError::not_found("ucs01_relay::state::ChannelInfo")); - } - - #[test] - fn proper_checks_on_execute_native() { - let send_channel = "channel-5"; - let mut deps = setup(&[send_channel, "channel-10"], &[]); - - let mut transfer = TransferMsg { - channel: send_channel.to_string(), - remote_address: "foreign-address".to_string(), - timeout: None, - }; - - // works with proper funds - let msg = ExecuteMsg::Transfer(transfer.clone()); - let info = mock_info("foobar", &coins(1234567, "ucosm")); - let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - assert_eq!(res.messages[0].gas_limit, None); - assert_eq!(1, res.messages.len()); - if let CosmosMsg::Ibc(IbcMsg::SendPacket { - channel_id, - data, - timeout, - }) = &res.messages[0].msg - { - let expected_timeout = mock_env().block.time.plus_seconds(DEFAULT_TIMEOUT); - assert_eq!(timeout, &expected_timeout.into()); - assert_eq!(channel_id.as_str(), send_channel); - let msg: Ics20Packet = data.try_into().unwrap(); - assert_eq!(msg.amount, 1234567); - assert_eq!(msg.denom.as_str(), "ucosm"); - assert_eq!(msg.sender.as_str(), "foobar"); - assert_eq!(msg.receiver.as_str(), "foreign-address"); - } else { - panic!("Unexpected return message: {:?}", res.messages[0]); - } - - // reject with no funds - let msg = ExecuteMsg::Transfer(transfer.clone()); - let info = mock_info("foobar", &[]); - let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); - assert_eq!(err, ContractError::Payment(PaymentError::NoFunds {})); - - // reject with multiple tokens funds - let msg = ExecuteMsg::Transfer(transfer.clone()); - let info = mock_info("foobar", &[coin(1234567, "ucosm"), coin(54321, "uatom")]); - let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); - assert_eq!(err, ContractError::Payment(PaymentError::MultipleDenoms {})); - - // reject with bad channel id - transfer.channel = "channel-45".to_string(); - let msg = ExecuteMsg::Transfer(transfer); - let info = mock_info("foobar", &coins(1234567, "ucosm")); - let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); - assert_eq!( - err, - ContractError::NoSuchChannel { - id: "channel-45".to_string() - } - ); - } - - #[test] - fn proper_checks_on_execute_cw20() { - let send_channel = "channel-15"; - let cw20_addr = "my-token"; - let mut deps = setup(&["channel-3", send_channel], &[(cw20_addr, 123456)]); - - let transfer = TransferMsg { - channel: send_channel.to_string(), - remote_address: "foreign-address".to_string(), - timeout: Some(7777), - }; - let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { - sender: "my-account".into(), - amount: Uint128::new(888777666), - msg: to_binary(&transfer).unwrap(), - }); - - // works with proper funds - let info = mock_info(cw20_addr, &[]); - let res = execute(deps.as_mut(), mock_env(), info, msg.clone()).unwrap(); - assert_eq!(1, res.messages.len()); - assert_eq!(res.messages[0].gas_limit, None); - if let CosmosMsg::Ibc(IbcMsg::SendPacket { - channel_id, - data, - timeout, - }) = &res.messages[0].msg - { - let expected_timeout = mock_env().block.time.plus_seconds(7777); - assert_eq!(timeout, &expected_timeout.into()); - assert_eq!(channel_id.as_str(), send_channel); - let msg: Ics20Packet = data.try_into().unwrap(); - assert_eq!(msg.amount, 888777666); - assert_eq!(msg.denom, format!("cw20:{}", cw20_addr)); - assert_eq!(msg.sender.as_str(), "my-account"); - assert_eq!(msg.receiver.as_str(), "foreign-address"); - } else { - panic!("Unexpected return message: {:?}", res.messages[0]); - } - - // reject with tokens funds - let info = mock_info("foobar", &coins(1234567, "ucosm")); - let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err(); - assert_eq!(err, ContractError::Payment(PaymentError::NonPayable {})); - } - - #[test] - fn execute_cw20_fails_if_not_whitelisted_unless_default_gas_limit() { - let send_channel = "channel-15"; - let mut deps = setup(&[send_channel], &[]); - - let cw20_addr = "my-token"; - let transfer = TransferMsg { - channel: send_channel.to_string(), - remote_address: "foreign-address".to_string(), - timeout: Some(7777), - }; - let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { - sender: "my-account".into(), - amount: Uint128::new(888777666), - msg: to_binary(&transfer).unwrap(), - }); - - // rejected as not on allow list - let info = mock_info(cw20_addr, &[]); - let err = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap_err(); - assert_eq!(err, ContractError::NotOnAllowList); - - // add a default gas limit - migrate( - deps.as_mut(), - mock_env(), - MigrateMsg { - default_gas_limit: Some(123456), - }, - ) - .unwrap(); - - // try again - execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - } - - #[test] - fn v3_migration_works() { - // basic state with one channel - let send_channel = "channel-15"; - let cw20_addr = "my-token"; - let native = "ucosm"; - let mut deps = setup(&[send_channel], &[(cw20_addr, 123456)]); - - // mock that we sent some tokens in both native and cw20 (TODO: cw20) - // balances set high - deps.querier - .update_balance(MOCK_CONTRACT_ADDR, coins(50000, native)); - // pretend this is an old contract - set version explicitly - set_contract_version(deps.as_mut().storage, CONTRACT_NAME, MIGRATE_VERSION_3).unwrap(); - - // channel state a bit lower (some in-flight acks) - let state = ChannelState { - // 14000 not accounted for (in-flight) - outstanding: Uint128::new(36000), - total_sent: Uint128::new(100000), - }; - CHANNEL_STATE - .save(deps.as_mut().storage, (send_channel, native), &state) - .unwrap(); - - // run migration - migrate( - deps.as_mut(), - mock_env(), - MigrateMsg { - default_gas_limit: Some(123456), - }, - ) - .unwrap(); - - // check new channel state - let chan = query_channel(deps.as_ref(), send_channel.into()).unwrap(); - assert_eq!(chan.balances, vec![Amount::native(50000, native)]); - assert_eq!(chan.total_sent, vec![Amount::native(114000, native)]); - - // check config updates - let config = query_config(deps.as_ref()).unwrap(); - assert_eq!(config.default_gas_limit, Some(123456)); - } - - fn test_with_memo() { - let send_channel = "channel-5"; - let mut deps = setup(&[send_channel, "channel-10"], &[]); - - let transfer = TransferMsg { - channel: send_channel.to_string(), - remote_address: "foreign-address".to_string(), - timeout: None, - }; - - // works with proper funds - let msg = ExecuteMsg::Transfer(transfer); - let info = mock_info("foobar", &coins(1234567, "ucosm")); - let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - assert_eq!(res.messages[0].gas_limit, None); - assert_eq!(1, res.messages.len()); - if let CosmosMsg::Ibc(IbcMsg::SendPacket { - channel_id, - data, - timeout, - }) = &res.messages[0].msg - { - let expected_timeout = mock_env().block.time.plus_seconds(DEFAULT_TIMEOUT); - assert_eq!(timeout, &expected_timeout.into()); - assert_eq!(channel_id.as_str(), send_channel); - let msg: Ics20Packet = data.try_into().unwrap(); - assert_eq!(msg.amount, 1234567); - assert_eq!(msg.denom.as_str(), "ucosm"); - assert_eq!(msg.sender.as_str(), "foobar"); - assert_eq!(msg.receiver.as_str(), "foreign-address"); - } else { - panic!("Unexpected return message: {:?}", res.messages[0]); - } - } - - #[test] - fn execute_with_memo_works() { - test_with_memo(); - } - - #[test] - fn execute_with_empty_string_memo_works() { - test_with_memo(); - } -} diff --git a/cosmwasm/ucs01-relay/src/error.rs b/cosmwasm/ucs01-relay/src/error.rs index e683fb2858..302c927902 100644 --- a/cosmwasm/ucs01-relay/src/error.rs +++ b/cosmwasm/ucs01-relay/src/error.rs @@ -1,9 +1,9 @@ -use std::{num::TryFromIntError, string::FromUtf8Error}; +use std::string::FromUtf8Error; -use cosmwasm_std::StdError; +use cosmwasm_std::{IbcOrder, StdError, SubMsgResult}; use cw_controllers::AdminError; -use cw_utils::PaymentError; use thiserror::Error; +use ucs01_relay_api::{protocol::ProtocolError, types::EncodingError}; /// Never is a placeholder to ensure we don't return any errors #[derive(Error, Debug)] @@ -14,9 +14,6 @@ pub enum ContractError { #[error("{0}")] Std(#[from] StdError), - #[error("{0}")] - Payment(#[from] PaymentError), - #[error("{0}")] Admin(#[from] AdminError), @@ -24,49 +21,31 @@ pub enum ContractError { NoSuchChannel { id: String }, #[error("Didn't send any funds")] - NoFunds {}, - - #[error("Amount larger than 2**64, not supported by ics20 packets")] - AmountOverflow {}, - - #[error("Only supports channel with ibc version ics20-1, got {version}")] - InvalidIbcVersion { version: String }, - - #[error("Only supports unordered channel")] - OnlyOrderedChannel {}, - - #[error("Insufficient funds to redeem voucher on channel")] - InsufficientFunds {}, - - #[error("Only accepts tokens that originate on this chain, not native tokens of remote chain")] - NoForeignTokens {}, - - #[error("Parsed port from denom ({port}) doesn't match packet")] - FromOtherPort { port: String }, + NoFunds, - #[error("Parsed channel from denom ({channel}) doesn't match packet")] - FromOtherChannel { channel: String }, + #[error("Expected {expected:?} channel ordering but got {actual:?}")] + InvalidChannelOrdering { + expected: IbcOrder, + actual: IbcOrder, + }, - #[error("Cannot migrate from different contract type: {previous_contract}")] - CannotMigrate { previous_contract: String }, + #[error("Insufficient funds to redeem on channel")] + InsufficientFunds, - #[error("Cannot migrate from unsupported version: {previous_version}")] - CannotMigrateVersion { previous_version: String }, + #[error("Got a submessage reply with unknown id: {id} and variant: {variant:?}")] + UnknownReply { id: u64, variant: SubMsgResult }, - #[error("Got a submessage reply with unknown id: {id}")] - UnknownReplyId { id: u64 }, - - #[error("You cannot lower the gas limit for a contract on the allow list")] - CannotLowerGas, - - #[error("Only the governance contract can do this")] - Unauthorized, + #[error("{0}")] + Protocol(#[from] ProtocolError), - #[error("You can only send cw20 tokens that have been explicitly allowed by governance")] - NotOnAllowList, + #[error("{0}")] + ProtocolEncoding(#[from] EncodingError), - #[error("The packet has not been serialized using ETH ABI")] - EthAbiDecoding, + #[error("Channel {channel_id} has unknown protocol version {protocol_version}")] + UnknownProtocol { + channel_id: String, + protocol_version: String, + }, } impl From for ContractError { @@ -74,9 +53,3 @@ impl From for ContractError { ContractError::Std(StdError::invalid_utf8("parsing denom key")) } } - -impl From for ContractError { - fn from(_: TryFromIntError) -> Self { - ContractError::AmountOverflow {} - } -} diff --git a/cosmwasm/ucs01-relay/src/ibc.rs b/cosmwasm/ucs01-relay/src/ibc.rs index 5811b795b1..d5e44f94d2 100644 --- a/cosmwasm/ucs01-relay/src/ibc.rs +++ b/cosmwasm/ucs01-relay/src/ibc.rs @@ -1,147 +1,47 @@ -use cosmwasm_schema::cw_serde; use cosmwasm_std::{ - attr, entry_point, from_binary, to_binary, BankMsg, Binary, CosmosMsg, Deps, DepsMut, Env, - IbcBasicResponse, IbcChannel, IbcChannelCloseMsg, IbcChannelConnectMsg, IbcChannelOpenMsg, - IbcEndpoint, IbcOrder, IbcPacket, IbcPacketAckMsg, IbcPacketReceiveMsg, IbcPacketTimeoutMsg, - IbcReceiveResponse, Reply, Response, SubMsg, SubMsgResult, Uint128, WasmMsg, + entry_point, DepsMut, Env, Ibc3ChannelOpenResponse, IbcBasicResponse, IbcChannel, + IbcChannelCloseMsg, IbcChannelConnectMsg, IbcChannelOpenMsg, IbcPacketAckMsg, + IbcPacketReceiveMsg, IbcPacketTimeoutMsg, IbcReceiveResponse, MessageInfo, Reply, Response, + SubMsgResult, }; -use cw20::Cw20ExecuteMsg; -use ethabi::{ParamType, Token}; +use token_factory_api::TokenFactoryMsg; +use ucs01_relay_api::protocol::TransferProtocol; use crate::{ - amount::Amount, - error::{ContractError, Never}, - state::{ - reduce_channel_balance, undo_reduce_channel_balance, ChannelInfo, ReplyArgs, ALLOW_LIST, - CHANNEL_INFO, CONFIG, REPLY_ARGS, - }, + error::ContractError, + protocol::{protocol_ordering, Ics20Protocol, ProtocolCommon, Ucs01Protocol}, + state::{ChannelInfo, CHANNEL_INFO}, }; -pub const ICS20_VERSION: &str = "ics20-1"; -pub const ICS20_ORDERING: IbcOrder = IbcOrder::Unordered; - -/// The format for sending an ics20 packet. -/// Proto defined here: https://github.com/cosmos/cosmos-sdk/blob/v0.42.0/proto/ibc/applications/transfer/v1/transfer.proto#L11-L20 -#[derive(Clone, PartialEq, Eq, Debug, Default)] -pub struct Ics20Packet { - /// amount of tokens to transfer is encoded as a string, but limited to u64 max - pub amount: u64, - /// the token denomination to be transferred - pub denom: String, - /// the recipient address on the destination chain - pub receiver: String, - /// the sender address - pub sender: String, +fn to_response( + IbcReceiveResponse { + acknowledgement, + messages, + attributes, + events, + .. + }: IbcReceiveResponse, +) -> Response { + Response::::new() + .add_submessages(messages) + .add_attributes(attributes) + .add_events(events) + .set_data(acknowledgement) } -impl Ics20Packet { - pub fn new>(amount: u64, denom: T, sender: &str, receiver: &str) -> Self { - Ics20Packet { - denom: denom.into(), - amount, - sender: sender.to_string(), - receiver: receiver.to_string(), +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(_: DepsMut, _: Env, reply: Reply) -> Result, ContractError> { + match (reply.id, reply.result) { + (Ics20Protocol::RECEIVE_REPLY_ID, SubMsgResult::Err(err)) => { + Ok(to_response(Ics20Protocol::receive_error(err))) } - } - - pub fn eth_encode(self) -> Binary { - ethabi::encode(&[ - Token::Uint(self.amount.into()), - Token::String(self.denom), - Token::String(self.receiver), - Token::String(self.sender), - ]) - .into() - } -} - -impl TryFrom<&Binary> for Ics20Packet { - type Error = ContractError; - fn try_from(value: &Binary) -> Result { - let values = ethabi::decode( - &[ - ParamType::Uint(256), - ParamType::String, - ParamType::String, - ParamType::String, - ], - &value.0, - ) - .map_err(|_| ContractError::EthAbiDecoding)?; - match &values[..] { - [Token::Uint(amount), Token::String(denom), Token::String(receiver), Token::String(sender)] => { - Ok(Ics20Packet { - denom: denom.clone(), - amount: (*amount) - .try_into() - .map_err(|_| ContractError::AmountOverflow {})?, - sender: sender.clone(), - receiver: receiver.clone(), - }) - } - _ => Err(ContractError::EthAbiDecoding), + (Ucs01Protocol::RECEIVE_REPLY_ID, SubMsgResult::Err(err)) => { + Ok(to_response(Ucs01Protocol::receive_error(err))) } - } -} - -/// This is a generic ICS acknowledgement format. -/// Proto defined here: https://github.com/cosmos/cosmos-sdk/blob/v0.42.0/proto/ibc/core/channel/v1/channel.proto#L141-L147 -/// This is compatible with the JSON serialization -#[cw_serde] -pub enum Ics20Ack { - Result(Binary), - Error(String), -} - -// create a serialized success message -fn ack_success() -> Binary { - let res = Ics20Ack::Result(b"1".into()); - to_binary(&res).unwrap() -} - -// create a serialized error message -fn ack_fail(err: String) -> Binary { - let res = Ics20Ack::Error(err); - to_binary(&res).unwrap() -} - -const RECEIVE_ID: u64 = 1337; -const ACK_FAILURE_ID: u64 = 0xfa17; - -#[cfg_attr(not(feature = "library"), entry_point)] -pub fn reply(deps: DepsMut, _env: Env, reply: Reply) -> Result { - match reply.id { - RECEIVE_ID => match reply.result { - SubMsgResult::Ok(_) => Ok(Response::new()), - SubMsgResult::Err(err) => { - // Important design note: with ibcv2 and wasmd 0.22 we can implement this all much easier. - // No reply needed... the receive function and submessage should return error on failure and all - // state gets reverted with a proper app-level message auto-generated - - // Since we need compatibility with Juno (Jan 2022), we need to ensure that optimisitic - // state updates in ibc_packet_receive get reverted in the (unlikely) chance of an - // error while sending the token - - // However, this requires passing some state between the ibc_packet_receive function and - // the reply handler. We do this with a singleton, with is "okay" for IBC as there is no - // reentrancy on these functions (cannot be called by another contract). This pattern - // should not be used for ExecuteMsg handlers - let reply_args = REPLY_ARGS.load(deps.storage)?; - undo_reduce_channel_balance( - deps.storage, - &reply_args.channel, - &reply_args.denom, - reply_args.amount, - )?; - - Ok(Response::new().set_data(ack_fail(err))) - } - }, - ACK_FAILURE_ID => match reply.result { - SubMsgResult::Ok(_) => Ok(Response::new()), - SubMsgResult::Err(err) => Ok(Response::new().set_data(ack_fail(err))), - }, - _ => Err(ContractError::UnknownReplyId { id: reply.id }), + (_, result) => Err(ContractError::UnknownReply { + id: reply.id, + variant: result, + }), } } @@ -151,9 +51,9 @@ pub fn ibc_channel_open( _deps: DepsMut, _env: Env, msg: IbcChannelOpenMsg, -) -> Result<(), ContractError> { +) -> Result, ContractError> { enforce_order_and_version(msg.channel(), msg.counterparty_version())?; - Ok(()) + Ok(None) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -163,17 +63,15 @@ pub fn ibc_channel_connect( _env: Env, msg: IbcChannelConnectMsg, ) -> Result { - // we need to check the counter party version in try and ack (sometimes here) enforce_order_and_version(msg.channel(), msg.counterparty_version())?; - let channel: IbcChannel = msg.into(); let info = ChannelInfo { - id: channel.endpoint.channel_id, + endpoint: channel.endpoint, counterparty_endpoint: channel.counterparty_endpoint, connection_id: channel.connection_id, + protocol_version: channel.version, }; - CHANNEL_INFO.save(deps.storage, &info.id, &info)?; - + CHANNEL_INFO.save(deps.storage, &info.endpoint.channel_id, &info)?; Ok(IbcBasicResponse::default()) } @@ -181,20 +79,24 @@ pub(crate) fn enforce_order_and_version( channel: &IbcChannel, counterparty_version: Option<&str>, ) -> Result<(), ContractError> { - if channel.version != ICS20_VERSION { - return Err(ContractError::InvalidIbcVersion { - version: channel.version.clone(), - }); - } + let channel_ordering = + protocol_ordering(&channel.version).ok_or(ContractError::UnknownProtocol { + channel_id: channel.endpoint.channel_id.clone(), + protocol_version: channel.version.clone(), + })?; if let Some(version) = counterparty_version { - if version != ICS20_VERSION { - return Err(ContractError::InvalidIbcVersion { - version: version.to_string(), + if protocol_ordering(version).is_none() { + return Err(ContractError::UnknownProtocol { + channel_id: channel.endpoint.channel_id.clone(), + protocol_version: version.to_string(), }); } } - if channel.order != ICS20_ORDERING { - return Err(ContractError::OnlyOrderedChannel {}); + if channel.order != channel_ordering { + return Err(ContractError::InvalidChannelOrdering { + expected: channel_ordering, + actual: channel.order.clone(), + }); } Ok(()) } @@ -205,8 +107,7 @@ pub fn ibc_channel_close( _env: Env, _channel: IbcChannelCloseMsg, ) -> Result { - // TODO: what to do here? - // we will have locked funds that need to be returned somehow + // Not allowed. unimplemented!(); } @@ -215,104 +116,39 @@ pub fn ibc_channel_close( /// We should not return an error if possible, but rather an acknowledgement of failure pub fn ibc_packet_receive( deps: DepsMut, - _env: Env, + env: Env, msg: IbcPacketReceiveMsg, -) -> Result { - let packet = msg.packet; - - do_ibc_packet_receive(deps, &packet).or_else(|err| { - Ok(IbcReceiveResponse::new() - .set_ack(ack_fail(err.to_string())) - .add_attributes(vec![ - attr("action", "receive"), - attr("success", "false"), - attr("error", err.to_string()), - ])) - }) -} +) -> Result, ContractError> { + let channel_info = CHANNEL_INFO.load(deps.storage, &msg.packet.dest.channel_id)?; -// Returns local denom if the denom is an encoded voucher from the expected endpoint -// Otherwise, error -fn parse_voucher_denom<'a>( - voucher_denom: &'a str, - remote_endpoint: &IbcEndpoint, -) -> Result<&'a str, ContractError> { - let split_denom: Vec<&str> = voucher_denom.splitn(3, '/').collect(); - if split_denom.len() != 3 { - return Err(ContractError::NoForeignTokens {}); - } - // a few more sanity checks - if split_denom[0] != remote_endpoint.port_id { - return Err(ContractError::FromOtherPort { - port: split_denom[0].into(), - }); - } - if split_denom[1] != remote_endpoint.channel_id { - return Err(ContractError::FromOtherChannel { - channel: split_denom[1].into(), - }); - } - - Ok(split_denom[2]) -} - -// this does the work of ibc_packet_receive, we wrap it to turn errors into acknowledgements -fn do_ibc_packet_receive( - deps: DepsMut, - packet: &IbcPacket, -) -> Result { - let msg: Ics20Packet = (&packet.data).try_into()?; - let channel = packet.dest.channel_id.clone(); - - // If the token originated on the remote chain, it looks like "ucosm". - // If it originated on our chain, it looks like "port/channel/ucosm". - let denom = parse_voucher_denom(&msg.denom, &packet.src)?; - - // make sure we have enough balance for this - reduce_channel_balance(deps.storage, &channel, denom, msg.amount)?; - - // we need to save the data to update the balances in reply - let reply_args = ReplyArgs { - channel, - denom: denom.to_string(), - amount: msg.amount, + let info = MessageInfo { + sender: msg.relayer, + funds: Default::default(), }; - REPLY_ARGS.save(deps.storage, &reply_args)?; - - let to_send = Amount::from_parts(denom.to_string(), msg.amount); - let gas_limit = check_gas_limit(deps.as_ref(), &to_send)?; - let send = send_amount(to_send, msg.receiver.clone()); - let mut submsg = SubMsg::reply_on_error(send, RECEIVE_ID); - submsg.gas_limit = gas_limit; - let res = IbcReceiveResponse::new() - .set_ack(ack_success()) - .add_submessage(submsg) - .add_attribute("action", "receive") - .add_attribute("sender", msg.sender) - .add_attribute("receiver", msg.receiver) - .add_attribute("denom", denom) - .add_attribute("amount", Uint128::from(msg.amount)) - .add_attribute("success", "true"); - - Ok(res) -} - -fn check_gas_limit(deps: Deps, amount: &Amount) -> Result, ContractError> { - match amount { - Amount::Cw20(coin) => { - // if cw20 token, use the registered gas limit, or error if not whitelisted - let addr = deps.api.addr_validate(&coin.address)?; - let allowed = ALLOW_LIST.may_load(deps.storage, &addr)?; - match allowed { - Some(allow) => Ok(allow.gas_limit), - None => match CONFIG.load(deps.storage)?.default_gas_limit { - Some(base) => Ok(Some(base)), - None => Err(ContractError::NotOnAllowList), - }, - } + match channel_info.protocol_version.as_str() { + Ics20Protocol::VERSION => Ok(Ics20Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, } - _ => Ok(None), + .receive_phase0(msg.packet.data)), + Ucs01Protocol::VERSION => Ok(Ucs01Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .receive_phase0(msg.packet.data)), + v => Err(ContractError::UnknownProtocol { + channel_id: msg.packet.dest.channel_id, + protocol_version: v.into(), + }), } } @@ -320,16 +156,39 @@ fn check_gas_limit(deps: Deps, amount: &Amount) -> Result, ContractE /// check if success or failure and update balance, or return funds pub fn ibc_packet_ack( deps: DepsMut, - _env: Env, + env: Env, msg: IbcPacketAckMsg, -) -> Result { - // Design decision: should we trap error like in receive? - // TODO: unsure... as it is now a failed ack handling would revert the tx and would be - // retried again and again. is that good? - let ics20msg: Ics20Ack = from_binary(&msg.acknowledgement.data)?; - match ics20msg { - Ics20Ack::Result(_) => on_packet_success(deps, msg.original_packet), - Ics20Ack::Error(err) => on_packet_failure(deps, msg.original_packet, err), +) -> Result, ContractError> { + let channel_info = CHANNEL_INFO.load(deps.storage, &msg.original_packet.dest.channel_id)?; + + let info = MessageInfo { + sender: msg.relayer, + funds: Default::default(), + }; + + match channel_info.protocol_version.as_str() { + Ics20Protocol::VERSION => Ics20Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .send_ack(msg.acknowledgement.data, msg.original_packet.data), + Ucs01Protocol::VERSION => Ucs01Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, + }, + } + .send_ack(msg.acknowledgement.data, msg.original_packet.data), + v => Err(ContractError::UnknownProtocol { + channel_id: msg.original_packet.dest.channel_id, + protocol_version: v.into(), + }), } } @@ -337,358 +196,38 @@ pub fn ibc_packet_ack( /// return fund to original sender (same as failure in ibc_packet_ack) pub fn ibc_packet_timeout( deps: DepsMut, - _env: Env, + env: Env, msg: IbcPacketTimeoutMsg, -) -> Result { - // TODO: trap error like in receive? (same question as ack above) - let packet = msg.packet; - on_packet_failure(deps, packet, "timeout".to_string()) -} - -// update the balance stored on this (channel, denom) index -fn on_packet_success(_deps: DepsMut, packet: IbcPacket) -> Result { - let msg: Ics20Packet = (&packet.data).try_into()?; +) -> Result, ContractError> { + let channel_info = CHANNEL_INFO.load(deps.storage, &msg.packet.dest.channel_id)?; - // similar event messages like ibctransfer module - let attributes = vec![ - attr("action", "acknowledge"), - attr("sender", &msg.sender), - attr("receiver", &msg.receiver), - attr("denom", &msg.denom), - attr("amount", Uint128::from(msg.amount)), - attr("success", "true"), - ]; - - Ok(IbcBasicResponse::new().add_attributes(attributes)) -} - -// return the tokens to sender -fn on_packet_failure( - deps: DepsMut, - packet: IbcPacket, - err: String, -) -> Result { - let msg: Ics20Packet = (&packet.data).try_into()?; - - // undo the balance update on failure (as we pre-emptively added it on send) - reduce_channel_balance(deps.storage, &packet.src.channel_id, &msg.denom, msg.amount)?; - - let to_send = Amount::from_parts(msg.denom.clone(), msg.amount); - let gas_limit = check_gas_limit(deps.as_ref(), &to_send)?; - let send = send_amount(to_send, msg.sender.clone()); - let mut submsg = SubMsg::reply_on_error(send, ACK_FAILURE_ID); - submsg.gas_limit = gas_limit; - - // similar event messages like ibctransfer module - let res = IbcBasicResponse::new() - .add_submessage(submsg) - .add_attribute("action", "acknowledge") - .add_attribute("sender", msg.sender) - .add_attribute("receiver", msg.receiver) - .add_attribute("denom", msg.denom) - .add_attribute("amount", msg.amount.to_string()) - .add_attribute("success", "false") - .add_attribute("error", err); - - Ok(res) -} - -fn send_amount(amount: Amount, recipient: String) -> CosmosMsg { - match amount { - Amount::Native(coin) => BankMsg::Send { - to_address: recipient, - amount: vec![coin], - } - .into(), - Amount::Cw20(coin) => { - let msg = Cw20ExecuteMsg::Transfer { - recipient, - amount: coin.amount, - }; - WasmMsg::Execute { - contract_addr: coin.address, - msg: to_binary(&msg).unwrap(), - funds: vec![], - } - .into() - } - } -} - -#[cfg(test)] -mod test { - use cosmwasm_std::{ - coins, - testing::{mock_env, mock_info}, - to_vec, IbcEndpoint, IbcMsg, IbcTimeout, Timestamp, + let info = MessageInfo { + sender: msg.relayer, + funds: Default::default(), }; - use cw20::Cw20ReceiveMsg; - use super::*; - use crate::{ - contract::{execute, migrate, query_channel}, - msg::{ExecuteMsg, MigrateMsg, TransferMsg}, - test_helpers::*, - }; - - #[test] - fn check_ack_json() { - let success = Ics20Ack::Result(b"1".into()); - let fail = Ics20Ack::Error("bad coin".into()); - - let success_json = String::from_utf8(to_vec(&success).unwrap()).unwrap(); - assert_eq!(r#"{"result":"MQ=="}"#, success_json.as_str()); - - let fail_json = String::from_utf8(to_vec(&fail).unwrap()).unwrap(); - assert_eq!(r#"{"error":"bad coin"}"#, fail_json.as_str()); - } - - #[test] - fn check_encode_decode_iso() { - let packet = Ics20Packet::new( - 12345, - "ucosm", - "cosmos1zedxv25ah8fksmg2lzrndrpkvsjqgk4zt5ff7n", - "wasm1fucynrfkrt684pm8jrt8la5h2csvs5cnldcgqc", - ); - assert_eq!(Ok(packet.clone()), (&packet.eth_encode()).try_into()); - } - - fn cw20_payment( - amount: u128, - address: &str, - recipient: &str, - gas_limit: Option, - ) -> SubMsg { - let msg = Cw20ExecuteMsg::Transfer { - recipient: recipient.into(), - amount: Uint128::new(amount), - }; - let exec = WasmMsg::Execute { - contract_addr: address.into(), - msg: to_binary(&msg).unwrap(), - funds: vec![], - }; - let mut msg = SubMsg::reply_on_error(exec, RECEIVE_ID); - msg.gas_limit = gas_limit; - msg - } - - fn native_payment(amount: u128, denom: &str, recipient: &str) -> SubMsg { - SubMsg::reply_on_error( - BankMsg::Send { - to_address: recipient.into(), - amount: coins(amount, denom), + match channel_info.protocol_version.as_str() { + Ics20Protocol::VERSION => Ics20Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, }, - RECEIVE_ID, - ) - } - - fn mock_receive_packet( - my_channel: &str, - amount: u64, - denom: &str, - receiver: &str, - ) -> IbcPacket { - let data = Ics20Packet { - // this is returning a foreign (our) token, thus denom is // - denom: format!("{}/{}/{}", REMOTE_PORT, "channel-1234", denom), - amount, - sender: "remote-sender".to_string(), - receiver: receiver.to_string(), - }; - print!("Packet denom: {}", &data.denom); - IbcPacket::new( - data.eth_encode(), - IbcEndpoint { - port_id: REMOTE_PORT.to_string(), - channel_id: "channel-1234".to_string(), - }, - IbcEndpoint { - port_id: CONTRACT_PORT.to_string(), - channel_id: my_channel.to_string(), - }, - 3, - Timestamp::from_seconds(1665321069).into(), - ) - } - - #[test] - fn send_receive_cw20() { - let send_channel = "channel-9"; - let cw20_addr = "token-addr"; - let cw20_denom = "cw20:token-addr"; - let gas_limit = 1234567; - let mut deps = setup( - &["channel-1", "channel-7", send_channel], - &[(cw20_addr, gas_limit)], - ); - - // prepare some mock packets - let recv_packet = mock_receive_packet(send_channel, 876543210, cw20_denom, "local-rcpt"); - let recv_high_packet = - mock_receive_packet(send_channel, 1876543210, cw20_denom, "local-rcpt"); - - // cannot receive this denom yet - let msg = IbcPacketReceiveMsg::new(recv_packet.clone()); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert!(res.messages.is_empty()); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - let no_funds = Ics20Ack::Error(ContractError::InsufficientFunds {}.to_string()); - assert_eq!(ack, no_funds); - - // we send some cw20 tokens over - let transfer = TransferMsg { - channel: send_channel.to_string(), - remote_address: "remote-rcpt".to_string(), - timeout: None, - }; - let msg = ExecuteMsg::Receive(Cw20ReceiveMsg { - sender: "local-sender".to_string(), - amount: Uint128::new(987654321), - msg: to_binary(&transfer).unwrap(), - }); - let info = mock_info(cw20_addr, &[]); - let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - assert_eq!(1, res.messages.len()); - let expected = Ics20Packet { - denom: cw20_denom.into(), - amount: 987654321, - sender: "local-sender".to_string(), - receiver: "remote-rcpt".to_string(), - }; - let timeout = mock_env().block.time.plus_seconds(DEFAULT_TIMEOUT); - assert_eq!( - &res.messages[0], - &SubMsg::new(IbcMsg::SendPacket { - channel_id: send_channel.to_string(), - data: expected.eth_encode(), - timeout: IbcTimeout::with_timestamp(timeout), - }) - ); - - // query channel state|_| - let state = query_channel(deps.as_ref(), send_channel.to_string()).unwrap(); - assert_eq!(state.balances, vec![Amount::cw20(987654321, cw20_addr)]); - assert_eq!(state.total_sent, vec![Amount::cw20(987654321, cw20_addr)]); - - // cannot receive more than we sent - let msg = IbcPacketReceiveMsg::new(recv_high_packet); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert!(res.messages.is_empty()); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - assert_eq!(ack, no_funds); - - // we can receive less than we sent - let msg = IbcPacketReceiveMsg::new(recv_packet); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert_eq!(1, res.messages.len()); - assert_eq!( - cw20_payment(876543210, cw20_addr, "local-rcpt", Some(gas_limit)), - res.messages[0] - ); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - assert!(matches!(ack, Ics20Ack::Result(_))); - - // TODO: we need to call the reply block - - // query channel state - let state = query_channel(deps.as_ref(), send_channel.to_string()).unwrap(); - assert_eq!(state.balances, vec![Amount::cw20(111111111, cw20_addr)]); - assert_eq!(state.total_sent, vec![Amount::cw20(987654321, cw20_addr)]); - } - - #[test] - fn send_receive_native() { - let send_channel = "channel-9"; - let mut deps = setup(&["channel-1", "channel-7", send_channel], &[]); - - let denom = "uatom"; - - // prepare some mock packets - let recv_packet = mock_receive_packet(send_channel, 876543210, denom, "local-rcpt"); - let recv_high_packet = mock_receive_packet(send_channel, 1876543210, denom, "local-rcpt"); - - // cannot receive this denom yet - let msg = IbcPacketReceiveMsg::new(recv_packet.clone()); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert!(res.messages.is_empty()); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - let no_funds = Ics20Ack::Error(ContractError::InsufficientFunds {}.to_string()); - assert_eq!(ack, no_funds); - - // we transfer some tokens - let msg = ExecuteMsg::Transfer(TransferMsg { - channel: send_channel.to_string(), - remote_address: "my-remote-address".to_string(), - timeout: None, - }); - let info = mock_info("local-sender", &coins(987654321, denom)); - execute(deps.as_mut(), mock_env(), info, msg).unwrap(); - - // query channel state|_| - let state = query_channel(deps.as_ref(), send_channel.to_string()).unwrap(); - assert_eq!(state.balances, vec![Amount::native(987654321, denom)]); - assert_eq!(state.total_sent, vec![Amount::native(987654321, denom)]); - - // cannot receive more than we sent - let msg = IbcPacketReceiveMsg::new(recv_high_packet); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert!(res.messages.is_empty()); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - assert_eq!(ack, no_funds); - - // we can receive less than we sent - let msg = IbcPacketReceiveMsg::new(recv_packet); - let res = ibc_packet_receive(deps.as_mut(), mock_env(), msg).unwrap(); - assert_eq!(1, res.messages.len()); - assert_eq!( - native_payment(876543210, denom, "local-rcpt"), - res.messages[0] - ); - let ack: Ics20Ack = from_binary(&res.acknowledgement).unwrap(); - assert!(matches!(ack, Ics20Ack::Result(_))); - - // only need to call reply block on error case - - // query channel state - let state = query_channel(deps.as_ref(), send_channel.to_string()).unwrap(); - assert_eq!(state.balances, vec![Amount::native(111111111, denom)]); - assert_eq!(state.total_sent, vec![Amount::native(987654321, denom)]); - } - - #[test] - fn check_gas_limit_handles_all_cases() { - let send_channel = "channel-9"; - let allowed = "foobar"; - let allowed_gas = 777666; - let mut deps = setup(&[send_channel], &[(allowed, allowed_gas)]); - - // allow list will get proper gas - let limit = check_gas_limit(deps.as_ref(), &Amount::cw20(500, allowed)).unwrap(); - assert_eq!(limit, Some(allowed_gas)); - - // non-allow list will error - let random = "tokenz"; - check_gas_limit(deps.as_ref(), &Amount::cw20(500, random)).unwrap_err(); - - // add default_gas_limit - let def_limit = 54321; - migrate( - deps.as_mut(), - mock_env(), - MigrateMsg { - default_gas_limit: Some(def_limit), + } + .send_timeout(msg.packet.data), + Ucs01Protocol::VERSION => Ucs01Protocol { + common: ProtocolCommon { + deps, + env, + info, + channel: channel_info, }, - ) - .unwrap(); - - // allow list still gets proper gas - let limit = check_gas_limit(deps.as_ref(), &Amount::cw20(500, allowed)).unwrap(); - assert_eq!(limit, Some(allowed_gas)); - - // non-allow list will now get default - let limit = check_gas_limit(deps.as_ref(), &Amount::cw20(500, random)).unwrap(); - assert_eq!(limit, Some(def_limit)); + } + .send_timeout(msg.packet.data), + v => Err(ContractError::UnknownProtocol { + channel_id: msg.packet.dest.channel_id, + protocol_version: v.into(), + }), } } diff --git a/cosmwasm/ucs01-relay/src/lib.rs b/cosmwasm/ucs01-relay/src/lib.rs index 5325226db0..58b9856acd 100644 --- a/cosmwasm/ucs01-relay/src/lib.rs +++ b/cosmwasm/ucs01-relay/src/lib.rs @@ -1,23 +1,6 @@ -/*! -This is an *IBC Enabled* contract that allows us to send CW20 tokens from one chain over the standard ICS20 -protocol to the bank module of another chain. In short, it lets us send our custom CW20 tokens with IBC and use -them just like native tokens on other chains. - -It is only designed to send tokens and redeem previously sent tokens. It will not mint tokens belonging -to assets originating on the foreign chain. This is different than the Golang `ibctransfer` module, but -we properly implement ICS20 and respond with an error message... let's hope the Go side handles this correctly. - -For more information on this contract, please check out the -[README](https://github.com/CosmWasm/cw-plus/blob/main/contracts/ucs01-relay/README.md). -*/ - -pub mod amount; pub mod contract; -mod error; +pub mod error; pub mod ibc; -mod migrations; pub mod msg; +pub mod protocol; pub mod state; -mod test_helpers; - -pub use crate::error::ContractError; diff --git a/cosmwasm/ucs01-relay/src/migrations.rs b/cosmwasm/ucs01-relay/src/migrations.rs deleted file mode 100644 index 618442039c..0000000000 --- a/cosmwasm/ucs01-relay/src/migrations.rs +++ /dev/null @@ -1,88 +0,0 @@ -// v1 format is anything older than 0.12.0 -pub mod v1 { - use cosmwasm_schema::cw_serde; - use cosmwasm_std::Addr; - use cw_storage_plus::Item; - - #[cw_serde] - pub struct Config { - pub default_timeout: u64, - pub gov_contract: Addr, - } - - pub const CONFIG: Item = Item::new("ics20_config"); -} - -// v2 format is anything older than 0.13.1 when we only updated the internal balances on success ack -pub mod v2 { - use cosmwasm_std::{to_binary, Addr, DepsMut, Env, Order, StdResult, WasmQuery}; - use cw20::{BalanceResponse, Cw20QueryMsg}; - - use crate::{ - amount::Amount, - state::{ChannelState, CHANNEL_INFO, CHANNEL_STATE}, - ContractError, - }; - - pub fn update_balances(mut deps: DepsMut, env: &Env) -> Result<(), ContractError> { - let channels = CHANNEL_INFO - .keys(deps.storage, None, None, Order::Ascending) - .collect::>>()?; - match channels.len() { - 0 => Ok(()), - 1 => { - let channel = &channels[0]; - let addr = &env.contract.address; - let states = CHANNEL_STATE - .prefix(channel) - .range(deps.storage, None, None, Order::Ascending) - .collect::>>()?; - for (denom, state) in states.into_iter() { - update_denom(deps.branch(), addr, channel, denom, state)?; - } - Ok(()) - } - _ => Err(ContractError::CannotMigrate { - previous_contract: "multiple channels open".into(), - }), - } - } - - fn update_denom( - deps: DepsMut, - contract: &Addr, - channel: &str, - denom: String, - mut state: ChannelState, - ) -> StdResult<()> { - // handle this for both native and cw20 - let balance = match Amount::from_parts(denom.clone(), state.outstanding) { - Amount::Native(coin) => deps.querier.query_balance(contract, coin.denom)?.amount, - Amount::Cw20(coin) => { - // FIXME: we should be able to do this with the following line, but QuerierWrapper doesn't play - // with the Querier generics - // `Cw20Contract(contract.clone()).balance(&deps.querier, contract)?` - let query = WasmQuery::Smart { - contract_addr: coin.address, - msg: to_binary(&Cw20QueryMsg::Balance { - address: contract.into(), - })?, - }; - let res: BalanceResponse = deps.querier.query(&query.into())?; - res.balance - } - }; - - // this checks if we have received some coins that are "in flight" and not yet accounted in the state - let diff = balance - state.outstanding; - // if they are in flight, we add them to the internal state now, as if we added them when sent (not when acked) - // to match the current logic - if !diff.is_zero() { - state.outstanding += diff; - state.total_sent += diff; - CHANNEL_STATE.save(deps.storage, (channel, &denom), &state)?; - } - - Ok(()) - } -} diff --git a/cosmwasm/ucs01-relay/src/msg.rs b/cosmwasm/ucs01-relay/src/msg.rs index b4a8b9aea0..9ba98f346f 100644 --- a/cosmwasm/ucs01-relay/src/msg.rs +++ b/cosmwasm/ucs01-relay/src/msg.rs @@ -1,8 +1,7 @@ use cosmwasm_schema::{cw_serde, QueryResponses}; -use cosmwasm_std::IbcChannel; -use cw20::Cw20ReceiveMsg; +use cosmwasm_std::{Binary, Uint512}; -use crate::{amount::Amount, state::ChannelInfo}; +use crate::state::ChannelInfo; #[cw_serde] pub struct InitMsg { @@ -10,36 +9,20 @@ pub struct InitMsg { pub default_timeout: u64, /// who can allow more contracts pub gov_contract: String, - /// initial allowlist - all cw20 tokens we will send must be previously allowed by governance - pub allowlist: Vec, - /// If set, contracts off the allowlist will run with this gas limit. - /// If unset, will refuse to accept any contract off the allow list. - pub default_gas_limit: Option, - /// If set, contract will setup the channel - pub channel: Option, } #[cw_serde] -pub struct AllowMsg { - pub contract: String, - pub gas_limit: Option, -} - -#[cw_serde] -pub struct MigrateMsg { - pub default_gas_limit: Option, -} +pub struct MigrateMsg {} #[cw_serde] pub enum ExecuteMsg { - /// This accepts a properly-encoded ReceiveMsg from a cw20 contract - Receive(Cw20ReceiveMsg), - /// This allows us to transfer *exactly one* native token + /// This allows us to transfer native tokens Transfer(TransferMsg), - /// This must be called by gov_contract, will allow a new cw20 token to be sent - Allow(AllowMsg), /// Change the admin (must be called by current admin) UpdateAdmin { admin: String }, + /// Execute the receive phase 1 of the relay protocol. The packet is opaque and + /// fully handled by the underlying implementation. + ReceivePhase1(ReceivePhase1Msg), } /// This is the message we accept via Receive @@ -48,11 +31,17 @@ pub struct TransferMsg { /// The local channel to send the packets on pub channel: String, /// The remote address to send to. - /// Don't use HumanAddress as this will likely have a different Bech32 prefix than we use - /// and cannot be validated locally - pub remote_address: String, + pub receiver: String, /// How long the packet lives in seconds. If not specified, use default_timeout pub timeout: Option, + /// The memo + pub memo: String, +} + +#[cw_serde] +pub struct ReceivePhase1Msg { + pub channel: String, + pub raw_packet: Binary, } #[cw_serde] @@ -72,15 +61,6 @@ pub enum QueryMsg { Config {}, #[returns(cw_controllers::AdminResponse)] Admin {}, - /// Query if a given cw20 contract is allowed. - #[returns(AllowedResponse)] - Allowed { contract: String }, - /// List all allowed cw20 contracts. - #[returns(ListAllowedResponse)] - ListAllowed { - start_after: Option, - limit: Option, - }, } #[cw_serde] @@ -93,10 +73,7 @@ pub struct ChannelResponse { /// Information on the channel's connection pub info: ChannelInfo, /// How many tokens we currently have pending over this channel - pub balances: Vec, - /// The total number of tokens that have been sent over this channel - /// (even if many have been returned, so balance is low) - pub total_sent: Vec, + pub balances: Vec<(String, Uint512)>, } #[cw_serde] @@ -107,23 +84,5 @@ pub struct PortResponse { #[cw_serde] pub struct ConfigResponse { pub default_timeout: u64, - pub default_gas_limit: Option, pub gov_contract: String, } - -#[cw_serde] -pub struct AllowedResponse { - pub is_allowed: bool, - pub gas_limit: Option, -} - -#[cw_serde] -pub struct ListAllowedResponse { - pub allow: Vec, -} - -#[cw_serde] -pub struct AllowedInfo { - pub contract: String, - pub gas_limit: Option, -} diff --git a/cosmwasm/ucs01-relay/src/protocol.rs b/cosmwasm/ucs01-relay/src/protocol.rs new file mode 100644 index 0000000000..7e2682e5e6 --- /dev/null +++ b/cosmwasm/ucs01-relay/src/protocol.rs @@ -0,0 +1,722 @@ +use cosmwasm_std::{ + Addr, BankMsg, Binary, Coin, CosmosMsg, DepsMut, Env, IbcEndpoint, IbcOrder, MessageInfo, + StdResult, Uint128, +}; +use token_factory_api::{TokenFactoryMsg, TokenMsg}; +use ucs01_relay_api::{ + protocol::TransferProtocol, + types::{ + make_foreign_denom, DenomOrigin, Ics20Ack, Ics20Packet, TransferToken, Ucs01Ack, + Ucs01TransferPacket, + }, +}; + +use crate::{ + error::ContractError, + msg::{ExecuteMsg, ReceivePhase1Msg}, + state::{ChannelInfo, CHANNEL_STATE, FOREIGN_TOKEN_CREATED}, +}; + +pub fn protocol_ordering(version: &str) -> Option { + match version { + Ics20Protocol::VERSION => Some(Ics20Protocol::ORDERING), + Ucs01Protocol::VERSION => Some(Ucs01Protocol::ORDERING), + _ => None, + } +} + +trait OnReceive { + fn foreign_toggle(&mut self, denom: &str) -> Result; + + fn local_unescrow( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result<(), ContractError>; + + fn receive_phase1_transfer( + &mut self, + contract_address: &Addr, + endpoint: &IbcEndpoint, + counterparty_endpoint: &IbcEndpoint, + receiver: &str, + tokens: Vec, + ) -> Result>, ContractError> { + tokens + .into_iter() + .map(|TransferToken { denom, amount }| { + let amount = amount + .try_into() + .expect("CosmWasm require transferred amount to be Uint128..."); + match DenomOrigin::from((denom.as_str(), endpoint)) { + DenomOrigin::Local { denom } => { + self.local_unescrow(&endpoint.channel_id, denom, amount)?; + Ok(vec![BankMsg::Send { + to_address: receiver.to_string(), + amount: vec![Coin { + denom: denom.to_string(), + amount, + }], + } + .into()]) + } + DenomOrigin::Remote { denom } => { + let foreign_denom = make_foreign_denom(counterparty_endpoint, denom); + let factory_denom = + format!("factory/{}/{}", contract_address, foreign_denom); + let exists = self.foreign_toggle(&factory_denom)?; + Ok(if exists { + vec![TokenMsg::MintTokens { + denom: factory_denom, + amount: amount, + mint_to_address: receiver.to_string(), + } + .into()] + } else { + vec![ + TokenMsg::CreateDenom { + subdenom: foreign_denom.clone(), + metadata: None, + } + .into(), + TokenMsg::MintTokens { + denom: factory_denom, + amount: amount, + mint_to_address: receiver.to_string(), + } + .into(), + ] + }) + } + } + }) + .collect::, _>>() + .map(|x| x.into_iter().flatten().collect()) + } +} + +pub struct StatefulOnReceive<'a> { + deps: DepsMut<'a>, +} +impl<'a> OnReceive for StatefulOnReceive<'a> { + fn foreign_toggle(&mut self, denom: &str) -> Result { + let exists = FOREIGN_TOKEN_CREATED.has(self.deps.storage, denom); + FOREIGN_TOKEN_CREATED.save(self.deps.storage, denom, &())?; + Ok(exists) + } + + fn local_unescrow( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result<(), ContractError> { + CHANNEL_STATE.update( + self.deps.storage, + (channel_id, denom), + |orig| -> Result<_, ContractError> { + let mut cur = orig.ok_or(ContractError::InsufficientFunds)?; + cur.outstanding = cur + .outstanding + .checked_sub(amount.into()) + .or(Err(ContractError::InsufficientFunds))?; + Ok(cur) + }, + )?; + Ok(()) + } +} + +trait ForTokens { + fn on_local( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError>; + + fn on_remote( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError>; + + fn execute( + &mut self, + contract_address: &Addr, + channel_id: &str, + counterparty_endpoint: &IbcEndpoint, + tokens: Vec, + ) -> Result>, ContractError> { + let mut messages = Vec::with_capacity(tokens.len()); + for TransferToken { denom, amount } in tokens { + let amount = amount + .try_into() + .expect("CosmWasm require transferred amount to be Uint128..."); + // This is the origin from the counterparty POV + match DenomOrigin::from((denom.as_str(), counterparty_endpoint)) { + DenomOrigin::Local { denom } => { + // the denom has been previously normalized (factory/{}/ prefix removed), we must reconstruct to burn + let foreign_denom = make_foreign_denom(counterparty_endpoint, denom); + let factory_denom = format!("factory/{}/{}", contract_address, foreign_denom); + messages.append(&mut self.on_remote(&channel_id, &factory_denom, amount)?); + } + DenomOrigin::Remote { denom } => { + messages.append(&mut self.on_local(&channel_id, &denom, amount)?); + } + } + } + Ok(messages) + } +} + +struct StatefulSendTokens<'a> { + deps: DepsMut<'a>, + contract_address: String, +} + +impl<'a> ForTokens for StatefulSendTokens<'a> { + fn on_local( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError> { + CHANNEL_STATE.update( + self.deps.storage, + (channel_id, &denom), + |state| -> StdResult<_> { + let state = state.unwrap_or_default(); + state.outstanding.checked_add(amount.into())?; + Ok(state) + }, + )?; + Ok(Default::default()) + } + + fn on_remote( + &mut self, + _channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError> { + Ok(vec![TokenMsg::BurnTokens { + denom: denom.into(), + amount, + burn_from_address: self.contract_address.clone(), + } + .into()]) + } +} + +struct StatefulRefundTokens<'a> { + deps: DepsMut<'a>, + receiver: String, +} + +impl<'a> ForTokens for StatefulRefundTokens<'a> { + fn on_local( + &mut self, + channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError> { + CHANNEL_STATE.update( + self.deps.storage, + (channel_id, &denom), + |state| -> StdResult<_> { + let state = state.unwrap_or_default(); + state.outstanding.checked_sub(amount.into())?; + Ok(state) + }, + )?; + Ok(vec![BankMsg::Send { + to_address: self.receiver.clone(), + amount: vec![Coin { + denom: denom.into(), + amount, + }], + } + .into()]) + } + + fn on_remote( + &mut self, + _channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result>, ContractError> { + Ok(vec![TokenMsg::MintTokens { + denom: denom.into(), + amount, + mint_to_address: self.receiver.clone(), + } + .into()]) + } +} + +fn make_phase1_execute( + contract_address: String, + channel_id: String, + raw_packet: Binary, +) -> Result, ContractError> { + Ok(cosmwasm_std::wasm_execute( + contract_address, + &ExecuteMsg::ReceivePhase1(ReceivePhase1Msg { + channel: channel_id, + raw_packet, + }), + Default::default(), + )? + .into()) +} + +pub struct ProtocolCommon<'a> { + pub deps: DepsMut<'a>, + pub env: Env, + pub info: MessageInfo, + pub channel: ChannelInfo, +} + +pub struct Ics20Protocol<'a> { + pub common: ProtocolCommon<'a>, +} + +impl<'a> TransferProtocol for Ics20Protocol<'a> { + const VERSION: &'static str = "ics20-1"; + const ORDERING: IbcOrder = IbcOrder::Unordered; + const RECEIVE_REPLY_ID: u64 = 0; + + type Packet = Ics20Packet; + type Ack = Ics20Ack; + type CustomMsg = TokenFactoryMsg; + type Error = ContractError; + + fn channel_endpoint(&self) -> &cosmwasm_std::IbcEndpoint { + &self.common.channel.endpoint + } + + fn caller(&self) -> &cosmwasm_std::Addr { + &self.common.info.sender + } + + fn self_addr(&self) -> &cosmwasm_std::Addr { + &self.common.env.contract.address + } + + fn ack_success() -> Self::Ack { + Ics20Ack::Result(b"1".into()) + } + + fn ack_failure(error: String) -> Self::Ack { + Ics20Ack::Error(error) + } + + fn send_tokens( + &mut self, + _sender: &str, + _receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error> { + StatefulSendTokens { + deps: self.common.deps.branch(), + contract_address: self.common.env.contract.address.to_string(), + } + .execute( + &self.common.env.contract.address, + &self.common.channel.endpoint.channel_id, + &self.common.channel.counterparty_endpoint, + tokens, + ) + } + + fn send_tokens_success( + &mut self, + _sender: &str, + _receiver: &str, + _tokens: Vec, + ) -> Result>, Self::Error> { + Ok(Default::default()) + } + + fn send_tokens_failure( + &mut self, + sender: &str, + _receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error> { + StatefulRefundTokens { + deps: self.common.deps.branch(), + receiver: sender.into(), + } + .execute( + &self.common.env.contract.address, + &self.common.channel.endpoint.channel_id, + &self.common.channel.counterparty_endpoint, + tokens, + ) + } + + fn make_receive_phase1_execute( + &mut self, + raw_packet: impl Into, + ) -> Result, Self::Error> { + make_phase1_execute( + self.common.env.contract.address.clone().into(), + self.common.channel.endpoint.channel_id.clone(), + raw_packet.into(), + ) + } + + fn receive_phase1_transfer( + &mut self, + receiver: &str, + tokens: Vec, + ) -> Result>, ContractError> { + StatefulOnReceive { + deps: self.common.deps.branch(), + } + .receive_phase1_transfer( + &self.common.env.contract.address, + &self.common.channel.endpoint, + &self.common.channel.counterparty_endpoint, + receiver, + tokens, + ) + } +} + +pub struct Ucs01Protocol<'a> { + pub common: ProtocolCommon<'a>, +} + +impl<'a> TransferProtocol for Ucs01Protocol<'a> { + const VERSION: &'static str = "ucs01-0"; + const ORDERING: IbcOrder = IbcOrder::Unordered; + const RECEIVE_REPLY_ID: u64 = 1; + + type Packet = Ucs01TransferPacket; + type Ack = Ucs01Ack; + type CustomMsg = TokenFactoryMsg; + type Error = ContractError; + + fn channel_endpoint(&self) -> &cosmwasm_std::IbcEndpoint { + &self.common.channel.endpoint + } + + fn caller(&self) -> &cosmwasm_std::Addr { + &self.common.info.sender + } + + fn self_addr(&self) -> &cosmwasm_std::Addr { + &self.common.env.contract.address + } + + fn ack_success() -> Self::Ack { + Ucs01Ack::Success + } + + fn ack_failure(_: String) -> Self::Ack { + Ucs01Ack::Failure + } + + fn send_tokens( + &mut self, + _sender: &str, + _receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error> { + StatefulSendTokens { + deps: self.common.deps.branch(), + contract_address: self.common.env.contract.address.to_string(), + } + .execute( + &self.common.env.contract.address, + &self.common.channel.endpoint.channel_id, + &self.common.channel.counterparty_endpoint, + tokens, + ) + } + + fn send_tokens_success( + &mut self, + _sender: &str, + _receiver: &str, + _tokens: Vec, + ) -> Result>, Self::Error> { + Ok(Default::default()) + } + + fn send_tokens_failure( + &mut self, + sender: &str, + _receiver: &str, + tokens: Vec, + ) -> Result>, Self::Error> { + StatefulRefundTokens { + deps: self.common.deps.branch(), + receiver: sender.into(), + } + .execute( + &self.common.env.contract.address, + &self.common.channel.endpoint.channel_id, + &self.common.channel.counterparty_endpoint, + tokens, + ) + } + + fn make_receive_phase1_execute( + &mut self, + raw_packet: impl Into, + ) -> Result, Self::Error> { + make_phase1_execute( + self.common.env.contract.address.clone().into(), + self.common.channel.endpoint.channel_id.clone(), + raw_packet.into(), + ) + } + + fn receive_phase1_transfer( + &mut self, + receiver: &str, + tokens: Vec, + ) -> Result>, ContractError> { + StatefulOnReceive { + deps: self.common.deps.branch(), + } + .receive_phase1_transfer( + &self.common.env.contract.address, + &self.common.channel.endpoint, + &self.common.channel.counterparty_endpoint, + receiver, + tokens, + ) + } +} + +#[cfg(test)] +mod tests { + use cosmwasm_std::{Addr, BankMsg, Coin, IbcEndpoint, Uint128, Uint256}; + use token_factory_api::TokenMsg; + use ucs01_relay_api::types::TransferToken; + + use super::{ForTokens, OnReceive}; + + struct TestOnReceive { + toggle: bool, + } + impl OnReceive for TestOnReceive { + fn foreign_toggle(&mut self, _denom: &str) -> Result { + Ok(self.toggle) + } + + fn local_unescrow( + &mut self, + _channel_id: &str, + _denom: &str, + _amount: Uint128, + ) -> Result<(), crate::error::ContractError> { + Ok(()) + } + } + + #[test] + fn receive_transfer_create_foreign() { + assert_eq!( + TestOnReceive { toggle: false }.receive_phase1_transfer( + &Addr::unchecked("0xDEADC0DE"), + &IbcEndpoint { + port_id: "wasm.0xDEADC0DE".into(), + channel_id: "channel-1".into(), + }, + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-34".into(), + }, + "receiver", + vec![TransferToken { + denom: "from-counterparty".into(), + amount: Uint256::from(100u128) + },], + ), + Ok(vec![ + TokenMsg::CreateDenom { + subdenom: "transfer/channel-34/from-counterparty".into(), + metadata: None + } + .into(), + TokenMsg::MintTokens { + denom: "factory/0xDEADC0DE/transfer/channel-34/from-counterparty".into(), + amount: Uint128::from(100u128), + mint_to_address: "receiver".into() + } + .into(), + ]) + ); + } + + #[test] + fn receive_transfer_foreign() { + assert_eq!( + TestOnReceive { toggle: true }.receive_phase1_transfer( + &Addr::unchecked("0xDEADC0DE"), + &IbcEndpoint { + port_id: "wasm.0xDEADC0DE".into(), + channel_id: "channel-1".into(), + }, + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-34".into(), + }, + "receiver", + vec![TransferToken { + denom: "from-counterparty".into(), + amount: Uint256::from(100u128) + },], + ), + Ok(vec![TokenMsg::MintTokens { + denom: "factory/0xDEADC0DE/transfer/channel-34/from-counterparty".into(), + amount: Uint128::from(100u128), + mint_to_address: "receiver".into() + } + .into(),]) + ); + } + + #[test] + fn receive_transfer_unwraps_local() { + assert_eq!( + TestOnReceive { toggle: true }.receive_phase1_transfer( + &Addr::unchecked("0xDEADC0DE"), + &IbcEndpoint { + port_id: "wasm.0xDEADC0DE".into(), + channel_id: "channel-1".into(), + }, + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-34".into(), + }, + "receiver", + vec![TransferToken { + denom: "wasm.0xDEADC0DE/channel-1/local-denom".into(), + amount: Uint256::from(119u128) + }], + ), + Ok(vec![BankMsg::Send { + to_address: "receiver".into(), + amount: vec![Coin { + denom: "local-denom".into(), + amount: Uint128::from(119u128) + }] + } + .into()]) + ); + } + + #[test] + fn send_tokens_burn_channel_remote() { + struct OnRemoteOnly; + impl ForTokens for OnRemoteOnly { + fn on_local( + &mut self, + _channel_id: &str, + _denom: &str, + _amount: Uint128, + ) -> Result< + Vec>, + crate::error::ContractError, + > { + todo!() + } + + fn on_remote( + &mut self, + _channel_id: &str, + denom: &str, + amount: Uint128, + ) -> Result< + Vec>, + crate::error::ContractError, + > { + Ok(vec![TokenMsg::BurnTokens { + denom: denom.into(), + amount, + burn_from_address: "0xCAFEBABE".into(), + } + .into()]) + } + } + assert_eq!( + OnRemoteOnly.execute( + &Addr::unchecked("0xCAFEBABE"), + "blabla", + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-1".into() + }, + vec![TransferToken { + denom: "transfer/channel-1/remote-denom".into(), + amount: Uint256::from(119u128) + }], + ), + Ok(vec![TokenMsg::BurnTokens { + denom: "factory/0xCAFEBABE/transfer/channel-1/remote-denom".into(), + amount: Uint128::from(119u128), + burn_from_address: "0xCAFEBABE".into() + } + .into()]) + ); + } + + #[test] + fn send_tokens_escrow_local() { + struct OnLocalOnly { + total: Uint128, + } + impl ForTokens for OnLocalOnly { + fn on_local( + &mut self, + _channel_id: &str, + _denom: &str, + amount: Uint128, + ) -> Result< + Vec>, + crate::error::ContractError, + > { + self.total += amount; + Ok(Default::default()) + } + + fn on_remote( + &mut self, + _channel_id: &str, + _denom: &str, + _amount: Uint128, + ) -> Result< + Vec>, + crate::error::ContractError, + > { + todo!() + } + } + let mut state = OnLocalOnly { total: 0u8.into() }; + assert_eq!( + state.execute( + &Addr::unchecked("0xCAFEBABE"), + "blabla", + &IbcEndpoint { + port_id: "transfer".into(), + channel_id: "channel-1".into() + }, + vec![TransferToken { + denom: "transfer/channel-2/remote-denom".into(), + amount: Uint256::from(119u128) + }], + ), + Ok(vec![]) + ); + assert_eq!(state.total, Uint128::from(119u128)); + } +} diff --git a/cosmwasm/ucs01-relay/src/state.rs b/cosmwasm/ucs01-relay/src/state.rs index 0f6b9216b5..076c15d68b 100644 --- a/cosmwasm/ucs01-relay/src/state.rs +++ b/cosmwasm/ucs01-relay/src/state.rs @@ -1,16 +1,11 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::{Addr, IbcEndpoint, StdResult, Storage, Uint128}; +use cosmwasm_std::{IbcEndpoint, Uint512}; use cw_controllers::Admin; use cw_storage_plus::{Item, Map}; -use crate::ContractError; - pub const ADMIN: Admin = Admin::new("admin"); -pub const CONFIG: Item = Item::new("ics20_config"); - -// Used to pass info from the ibc_packet_receive to the reply handler -pub const REPLY_ARGS: Item = Item::new("reply_args"); +pub const CONFIG: Item = Item::new("config"); /// static info on one channel that doesn't change pub const CHANNEL_INFO: Map<&str, ChannelInfo> = Map::new("channel_info"); @@ -18,94 +13,26 @@ pub const CHANNEL_INFO: Map<&str, ChannelInfo> = Map::new("channel_info"); /// indexed by (channel_id, denom) maintaining the balance of the channel in that currency pub const CHANNEL_STATE: Map<(&str, &str), ChannelState> = Map::new("channel_state"); -/// Every cw20 contract we allow to be sent is stored here, possibly with a gas_limit -pub const ALLOW_LIST: Map<&Addr, AllowInfo> = Map::new("allow_list"); +pub const FOREIGN_TOKEN_CREATED: Map<&str, ()> = Map::new("foreign_tokens"); #[cw_serde] #[derive(Default)] pub struct ChannelState { - pub outstanding: Uint128, - pub total_sent: Uint128, + pub outstanding: Uint512, } #[cw_serde] pub struct Config { pub default_timeout: u64, - pub default_gas_limit: Option, } #[cw_serde] pub struct ChannelInfo { - /// id of this channel - pub id: String, + pub endpoint: IbcEndpoint, /// the remote channel/port we connect to pub counterparty_endpoint: IbcEndpoint, /// the connection this exists on (you can use to query client/consensus info) pub connection_id: String, -} - -#[cw_serde] -pub struct AllowInfo { - pub gas_limit: Option, -} - -#[cw_serde] -pub struct ReplyArgs { - pub channel: String, - pub denom: String, - pub amount: u64, -} - -pub fn increase_channel_balance( - storage: &mut dyn Storage, - channel: &str, - denom: &str, - amount: impl Into, -) -> Result<(), ContractError> { - CHANNEL_STATE.update(storage, (channel, denom), |orig| -> StdResult<_> { - let mut state = orig.unwrap_or_default(); - let amount = amount.into(); - state.outstanding += amount; - state.total_sent += amount; - Ok(state) - })?; - Ok(()) -} - -pub fn reduce_channel_balance( - storage: &mut dyn Storage, - channel: &str, - denom: &str, - amount: impl Into, -) -> Result<(), ContractError> { - CHANNEL_STATE.update( - storage, - (channel, denom), - |orig| -> Result<_, ContractError> { - // this will return error if we don't have the funds there to cover the request (or no denom registered) - let mut cur = orig.ok_or(ContractError::InsufficientFunds {})?; - cur.outstanding = cur - .outstanding - .checked_sub(amount.into()) - .or(Err(ContractError::InsufficientFunds {}))?; - Ok(cur) - }, - )?; - Ok(()) -} - -// this is like increase, but it only "un-subtracts" (= adds) outstanding, not total_sent -// calling `reduce_channel_balance` and then `undo_reduce_channel_balance` should leave state unchanged. -pub fn undo_reduce_channel_balance( - storage: &mut dyn Storage, - channel: &str, - denom: &str, - amount: impl Into, -) -> Result<(), ContractError> { - CHANNEL_STATE.update(storage, (channel, denom), |orig| -> StdResult<_> { - let mut state = orig.unwrap_or_default(); - state.outstanding += amount.into(); - Ok(state) - })?; - Ok(()) + /// the protocol version, used to branch on the implementation + pub protocol_version: String, } diff --git a/cosmwasm/ucs01-relay/src/test_helpers.rs b/cosmwasm/ucs01-relay/src/test_helpers.rs deleted file mode 100644 index 3553f3a72c..0000000000 --- a/cosmwasm/ucs01-relay/src/test_helpers.rs +++ /dev/null @@ -1,86 +0,0 @@ -#![cfg(test)] - -use cosmwasm_std::{ - testing::{mock_dependencies, mock_env, mock_info, MockApi, MockQuerier, MockStorage}, - DepsMut, IbcChannel, IbcChannelConnectMsg, IbcChannelOpenMsg, IbcEndpoint, OwnedDeps, -}; - -use crate::{ - contract::instantiate, - ibc::{ibc_channel_connect, ibc_channel_open, ICS20_ORDERING, ICS20_VERSION}, - msg::{AllowMsg, InitMsg}, - state::ChannelInfo, -}; - -pub const DEFAULT_TIMEOUT: u64 = 3600; // 1 hour, -pub const CONTRACT_PORT: &str = "ibc:wasm1234567890abcdef"; -pub const REMOTE_PORT: &str = "transfer"; -pub const CONNECTION_ID: &str = "connection-2"; - -pub fn mock_channel(channel_id: &str) -> IbcChannel { - IbcChannel::new( - IbcEndpoint { - port_id: CONTRACT_PORT.into(), - channel_id: channel_id.into(), - }, - IbcEndpoint { - port_id: REMOTE_PORT.into(), - channel_id: format!("{}5", channel_id), - }, - ICS20_ORDERING, - ICS20_VERSION, - CONNECTION_ID, - ) -} - -pub fn mock_channel_info(channel_id: &str) -> ChannelInfo { - ChannelInfo { - id: channel_id.to_string(), - counterparty_endpoint: IbcEndpoint { - port_id: REMOTE_PORT.into(), - channel_id: format!("{}5", channel_id), - }, - connection_id: CONNECTION_ID.into(), - } -} - -// we simulate instantiate and ack here -pub fn add_channel(mut deps: DepsMut, channel_id: &str) { - let channel = mock_channel(channel_id); - let open_msg = IbcChannelOpenMsg::new_init(channel.clone()); - ibc_channel_open(deps.branch(), mock_env(), open_msg).unwrap(); - let connect_msg = IbcChannelConnectMsg::new_ack(channel, ICS20_VERSION); - ibc_channel_connect(deps.branch(), mock_env(), connect_msg).unwrap(); -} - -pub fn setup( - channels: &[&str], - allow: &[(&str, u64)], -) -> OwnedDeps { - let mut deps = mock_dependencies(); - - let allowlist = allow - .iter() - .map(|(contract, gas)| AllowMsg { - contract: contract.to_string(), - gas_limit: Some(*gas), - }) - .collect(); - - // instantiate an empty contract - let instantiate_msg = InitMsg { - default_gas_limit: None, - default_timeout: DEFAULT_TIMEOUT, - gov_contract: "gov".to_string(), - allowlist, - channel: None, - }; - let info = mock_info(&String::from("anyone"), &[]); - let res = instantiate(deps.as_mut(), mock_env(), info, instantiate_msg).unwrap(); - assert_eq!(0, res.messages.len()); - - for channel in channels { - add_channel(deps.as_mut(), channel); - } - deps -} diff --git a/dictionary.txt b/dictionary.txt index d83901109b..8b89145636 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -121,6 +121,7 @@ bitarray bitslice bitvec bitvector +blabla blcok blocksync blst @@ -509,6 +510,7 @@ stylesheet subdenom subdenoms submessage +submessages submsg subsec substituters @@ -563,6 +565,7 @@ unbonded unbonding undelegate undelegations +unescrow uninit unionbundle unioncustomquery