diff --git a/.changelog/unreleased/features/626-ibc-transfer-cmd.md b/.changelog/unreleased/features/626-ibc-transfer-cmd.md new file mode 100644 index 0000000000..23fd574b3b --- /dev/null +++ b/.changelog/unreleased/features/626-ibc-transfer-cmd.md @@ -0,0 +1,2 @@ +- Add client command 'ibc-transfer'. + ([#626](https://github.com/anoma/namada/pull/626)) \ No newline at end of file diff --git a/apps/src/bin/anoma-client/cli.rs b/apps/src/bin/anoma-client/cli.rs index b87cdb5c66..5b084c842e 100644 --- a/apps/src/bin/anoma-client/cli.rs +++ b/apps/src/bin/anoma-client/cli.rs @@ -18,6 +18,9 @@ pub async fn main() -> Result<()> { Sub::TxTransfer(TxTransfer(args)) => { tx::submit_transfer(ctx, args).await; } + Sub::TxIbcTransfer(TxIbcTransfer(args)) => { + tx::submit_ibc_transfer(ctx, args).await; + } Sub::TxUpdateVp(TxUpdateVp(args)) => { tx::submit_update_vp(ctx, args).await; } diff --git a/apps/src/bin/anoma/cli.rs b/apps/src/bin/anoma/cli.rs index ccde0c3618..cda1e8bc63 100644 --- a/apps/src/bin/anoma/cli.rs +++ b/apps/src/bin/anoma/cli.rs @@ -45,6 +45,7 @@ fn handle_command(cmd: cli::cmds::Anoma, raw_sub_cmd: String) -> Result<()> { cli::cmds::Anoma::Client(_) | cli::cmds::Anoma::TxCustom(_) | cli::cmds::Anoma::TxTransfer(_) + | cli::cmds::Anoma::TxIbcTransfer(_) | cli::cmds::Anoma::TxUpdateVp(_) | cli::cmds::Anoma::TxInitNft(_) | cli::cmds::Anoma::TxMintNft(_) diff --git a/apps/src/lib/cli.rs b/apps/src/lib/cli.rs index 4534d5bc0d..ce53859ee8 100644 --- a/apps/src/lib/cli.rs +++ b/apps/src/lib/cli.rs @@ -46,6 +46,7 @@ pub mod cmds { // Inlined commands from the client. TxCustom(TxCustom), TxTransfer(TxTransfer), + TxIbcTransfer(TxIbcTransfer), TxUpdateVp(TxUpdateVp), TxInitNft(TxInitNft), TxMintNft(TxMintNft), @@ -61,6 +62,7 @@ pub mod cmds { .subcommand(Ledger::def()) .subcommand(TxCustom::def()) .subcommand(TxTransfer::def()) + .subcommand(TxIbcTransfer::def()) .subcommand(TxUpdateVp::def()) .subcommand(TxInitNft::def()) .subcommand(TxMintNft::def()) @@ -75,6 +77,8 @@ pub mod cmds { let ledger = SubCmd::parse(matches).map(Self::Ledger); let tx_custom = SubCmd::parse(matches).map(Self::TxCustom); let tx_transfer = SubCmd::parse(matches).map(Self::TxTransfer); + let tx_ibc_transfer = + SubCmd::parse(matches).map(Self::TxIbcTransfer); let tx_update_vp = SubCmd::parse(matches).map(Self::TxUpdateVp); let tx_nft_create = SubCmd::parse(matches).map(Self::TxInitNft); let tx_nft_mint = SubCmd::parse(matches).map(Self::TxMintNft); @@ -87,6 +91,7 @@ pub mod cmds { .or(ledger) .or(tx_custom) .or(tx_transfer) + .or(tx_ibc_transfer) .or(tx_update_vp) .or(tx_nft_create) .or(tx_nft_mint) @@ -152,6 +157,7 @@ pub mod cmds { // Simple transactions .subcommand(TxCustom::def().display_order(1)) .subcommand(TxTransfer::def().display_order(1)) + .subcommand(TxIbcTransfer::def().display_order(1)) .subcommand(TxUpdateVp::def().display_order(1)) .subcommand(TxInitAccount::def().display_order(1)) .subcommand(TxInitValidator::def().display_order(1)) @@ -184,6 +190,7 @@ pub mod cmds { use AnomaClientWithContext::*; let tx_custom = Self::parse_with_ctx(matches, TxCustom); let tx_transfer = Self::parse_with_ctx(matches, TxTransfer); + let tx_ibc_transfer = Self::parse_with_ctx(matches, TxIbcTransfer); let tx_update_vp = Self::parse_with_ctx(matches, TxUpdateVp); let tx_init_account = Self::parse_with_ctx(matches, TxInitAccount); let tx_init_validator = @@ -213,6 +220,7 @@ pub mod cmds { let utils = SubCmd::parse(matches).map(Self::WithoutContext); tx_custom .or(tx_transfer) + .or(tx_ibc_transfer) .or(tx_update_vp) .or(tx_init_account) .or(tx_init_validator) @@ -271,6 +279,7 @@ pub mod cmds { // Ledger cmds TxCustom(TxCustom), TxTransfer(TxTransfer), + TxIbcTransfer(TxIbcTransfer), QueryResult(QueryResult), TxUpdateVp(TxUpdateVp), TxInitAccount(TxInitAccount), @@ -794,6 +803,25 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct TxIbcTransfer(pub args::TxIbcTransfer); + + impl SubCmd for TxIbcTransfer { + const CMD: &'static str = "ibc-transfer"; + + fn parse(matches: &ArgMatches) -> Option { + matches.subcommand_matches(Self::CMD).map(|matches| { + TxIbcTransfer(args::TxIbcTransfer::parse(matches)) + }) + } + + fn def() -> App { + App::new(Self::CMD) + .about("Send a signed IBC transfer transaction.") + .add_args::() + } + } + #[derive(Clone, Debug)] pub struct TxUpdateVp(pub args::TxUpdateVp); @@ -1248,6 +1276,7 @@ pub mod args { use std::path::PathBuf; use std::str::FromStr; + use namada::ibc::core::ics24_host::identifier::{ChannelId, PortId}; use namada::types::address::Address; use namada::types::chain::{ChainId, ChainIdPrefix}; use namada::types::governance::ProposalVote; @@ -1281,6 +1310,7 @@ pub mod args { const CHAIN_ID: Arg = arg("chain-id"); const CHAIN_ID_OPT: ArgOpt = CHAIN_ID.opt(); const CHAIN_ID_PREFIX: Arg = arg("chain-prefix"); + const CHANNEL_ID: Arg = arg("channel-id"); const CODE_PATH: Arg = arg("code-path"); const CODE_PATH_OPT: ArgOpt = CODE_PATH.opt(); const CONSENSUS_TIMEOUT_COMMIT: ArgDefault = arg_default( @@ -1318,6 +1348,10 @@ pub mod args { const NET_ADDRESS: Arg = arg("net-address"); const NFT_ADDRESS: Arg
= arg("nft-address"); const OWNER: ArgOpt = arg_opt("owner"); + const PORT_ID: ArgDefault = arg_default( + "port-id", + DefaultFn(|| PortId::from_str("transfer").unwrap()), + ); const PROPOSAL_OFFLINE: ArgFlag = flag("offline"); const PROTOCOL_KEY: ArgOpt = arg_opt("protocol-key"); const PRE_GENESIS_PATH: ArgOpt = arg_opt("pre-genesis-path"); @@ -1328,6 +1362,7 @@ pub mod args { const RAW_ADDRESS: Arg
= arg("address"); const RAW_ADDRESS_OPT: ArgOpt
= RAW_ADDRESS.opt(); const RAW_PUBLIC_KEY_OPT: ArgOpt = arg_opt("public-key"); + const RECEIVER: Arg = arg("receiver"); const REWARDS_CODE_PATH: ArgOpt = arg_opt("rewards-code-path"); const REWARDS_KEY: ArgOpt = arg_opt("rewards-key"); const SCHEME: ArgDefault = @@ -1340,6 +1375,8 @@ pub mod args { const STORAGE_KEY: Arg = arg("storage-key"); const SUB_PREFIX: ArgOpt = arg_opt("sub-prefix"); const TARGET: Arg = arg("target"); + const TIMEOUT_HEIGHT: ArgOpt = arg_opt("timeout-height"); + const TIMEOUT_SEC_OFFSET: ArgOpt = arg_opt("timeout-sec-offset"); const TOKEN_OPT: ArgOpt = TOKEN.opt(); const TOKEN: Arg = arg("token"); const TX_HASH: Arg = arg("tx-hash"); @@ -1515,6 +1552,80 @@ pub mod args { } } + /// IBC transfer transaction arguments + #[derive(Clone, Debug)] + pub struct TxIbcTransfer { + /// Common tx arguments + pub tx: Tx, + /// Transfer source address + pub source: WalletAddress, + /// Transfer target address + pub receiver: String, + /// Transferred token address + pub token: WalletAddress, + /// Transferred token address + pub sub_prefix: Option, + /// Transferred token amount + pub amount: token::Amount, + /// Port ID + pub port_id: PortId, + /// Channel ID + pub channel_id: ChannelId, + /// Timeout height of the destination chain + pub timeout_height: Option, + /// Timeout timestamp offset + pub timeout_sec_offset: Option, + } + + impl Args for TxIbcTransfer { + fn parse(matches: &ArgMatches) -> Self { + let tx = Tx::parse(matches); + let source = SOURCE.parse(matches); + let receiver = RECEIVER.parse(matches); + let token = TOKEN.parse(matches); + let sub_prefix = SUB_PREFIX.parse(matches); + let amount = AMOUNT.parse(matches); + let port_id = PORT_ID.parse(matches); + let channel_id = CHANNEL_ID.parse(matches); + let timeout_height = TIMEOUT_HEIGHT.parse(matches); + let timeout_sec_offset = TIMEOUT_SEC_OFFSET.parse(matches); + Self { + tx, + source, + receiver, + token, + sub_prefix, + amount, + port_id, + channel_id, + timeout_height, + timeout_sec_offset, + } + } + + fn def(app: App) -> App { + app.add_args::() + .arg(SOURCE.def().about( + "The source account address. The source's key is used to \ + produce the signature.", + )) + .arg(RECEIVER.def().about( + "The receiver address on the destination chain as string.", + )) + .arg(TOKEN.def().about("The transfer token.")) + .arg(SUB_PREFIX.def().about("The token's sub prefix.")) + .arg(AMOUNT.def().about("The amount to transfer in decimal.")) + .arg(PORT_ID.def().about("The port ID.")) + .arg(CHANNEL_ID.def().about("The channel ID.")) + .arg( + TIMEOUT_HEIGHT + .def() + .about("The timeout height of the destination chain."), + ) + .arg(TIMEOUT_SEC_OFFSET.def().about("The timeout as seconds.")) + } + } + /// Transaction to initialize a new account #[derive(Clone, Debug)] pub struct TxInitAccount { diff --git a/apps/src/lib/client/tx.rs b/apps/src/lib/client/tx.rs index 7a4d542536..edb8c56f13 100644 --- a/apps/src/lib/client/tx.rs +++ b/apps/src/lib/client/tx.rs @@ -8,6 +8,12 @@ use async_std::io::prelude::WriteExt; use async_std::io::{self}; use borsh::BorshSerialize; use itertools::Either::*; +use namada::ibc::applications::ics20_fungible_token_transfer::msgs::transfer::MsgTransfer; +use namada::ibc::signer::Signer; +use namada::ibc::timestamp::Timestamp as IbcTimestamp; +use namada::ibc::tx_msg::Msg; +use namada::ibc::Height as IbcHeight; +use namada::ibc_proto::cosmos::base::v1beta1::Coin; use namada::ledger::governance::storage as gov_storage; use namada::ledger::pos::{BondId, Bonds, Unbonds}; use namada::proto::Tx; @@ -17,7 +23,8 @@ use namada::types::governance::{ }; use namada::types::key::*; use namada::types::nft::{self, Nft, NftToken}; -use namada::types::storage::Epoch; +use namada::types::storage::{Epoch, RESERVED_ADDRESS_PREFIX}; +use namada::types::time::DateTimeUtc; use namada::types::transaction::governance::{ InitProposalData, VoteProposalData, }; @@ -49,6 +56,7 @@ const TX_INIT_PROPOSAL: &str = "tx_init_proposal.wasm"; const TX_VOTE_PROPOSAL: &str = "tx_vote_proposal.wasm"; const TX_UPDATE_VP_WASM: &str = "tx_update_vp.wasm"; const TX_TRANSFER_WASM: &str = "tx_transfer.wasm"; +const TX_IBC_WASM: &str = "tx_ibc.wasm"; const TX_INIT_NFT: &str = "tx_init_nft.wasm"; const TX_MINT_NFT: &str = "tx_mint_nft.wasm"; const VP_USER_WASM: &str = "vp_user.wasm"; @@ -465,6 +473,116 @@ pub async fn submit_transfer(ctx: Context, args: args::TxTransfer) { process_tx(ctx, &args.tx, tx, Some(&args.source)).await; } +pub async fn submit_ibc_transfer(ctx: Context, args: args::TxIbcTransfer) { + let source = ctx.get(&args.source); + // Check that the source address exists on chain + let source_exists = + rpc::known_address(&source, args.tx.ledger_address.clone()).await; + if !source_exists { + eprintln!("The source address {} doesn't exist on chain.", source); + if !args.tx.force { + safe_exit(1) + } + } + + // We cannot check the receiver + + let token = ctx.get(&args.token); + // Check that the token address exists on chain + let token_exists = + rpc::known_address(&token, args.tx.ledger_address.clone()).await; + if !token_exists { + eprintln!("The token address {} doesn't exist on chain.", token); + if !args.tx.force { + safe_exit(1) + } + } + // Check source balance + let (sub_prefix, balance_key) = match args.sub_prefix { + Some(sub_prefix) => { + let sub_prefix = storage::Key::parse(sub_prefix).unwrap(); + let prefix = token::multitoken_balance_prefix(&token, &sub_prefix); + ( + Some(sub_prefix), + token::multitoken_balance_key(&prefix, &source), + ) + } + None => (None, token::balance_key(&token, &source)), + }; + let client = HttpClient::new(args.tx.ledger_address.clone()).unwrap(); + match rpc::query_storage_value::(&client, &balance_key).await + { + Some(balance) => { + if balance < args.amount { + eprintln!( + "The balance of the source {} of token {} is lower than \ + the amount to be transferred. Amount to transfer is {} \ + and the balance is {}.", + source, token, args.amount, balance + ); + if !args.tx.force { + safe_exit(1) + } + } + } + None => { + eprintln!( + "No balance found for the source {} of token {}", + source, token + ); + if !args.tx.force { + safe_exit(1) + } + } + } + let tx_code = ctx.read_wasm(TX_IBC_WASM); + + let denom = match sub_prefix { + // To parse IbcToken address, remove the address prefix + Some(sp) => sp.to_string().replace(RESERVED_ADDRESS_PREFIX, ""), + None => token.to_string(), + }; + let token = Some(Coin { + denom, + amount: args.amount.to_string(), + }); + + // this height should be that of the destination chain, not this chain + let timeout_height = match args.timeout_height { + Some(h) => IbcHeight::new(0, h), + None => IbcHeight::zero(), + }; + + let now: namada::tendermint::Time = DateTimeUtc::now().try_into().unwrap(); + let now: IbcTimestamp = now.into(); + let timeout_timestamp = if let Some(offset) = args.timeout_sec_offset { + (now + Duration::new(offset, 0)).unwrap() + } else if timeout_height.is_zero() { + // we cannot set 0 to both the height and the timestamp + (now + Duration::new(3600, 0)).unwrap() + } else { + IbcTimestamp::none() + }; + + let msg = MsgTransfer { + source_port: args.port_id, + source_channel: args.channel_id, + token, + sender: Signer::new(source.to_string()), + receiver: Signer::new(args.receiver), + timeout_height, + timeout_timestamp, + }; + tracing::debug!("IBC transfer message {:?}", msg); + let any_msg = msg.to_any(); + let mut data = vec![]; + prost::Message::encode(&any_msg, &mut data) + .expect("Encoding tx data shouldn't fail"); + + let tx = Tx::new(tx_code, Some(data)); + process_tx(ctx, &args.tx, tx, Some(&args.source)).await; +} + pub async fn submit_init_nft(ctx: Context, args: args::NftCreate) { let file = File::open(&args.nft_data).expect("File must exist."); let nft: Nft = serde_json::from_reader(file)