From 59b6d2b87759d98aabc9e0aab400cbdaded27bdb Mon Sep 17 00:00:00 2001 From: Tiago Carvalho Date: Fri, 11 Oct 2024 09:59:51 +0100 Subject: [PATCH] Add osmosis swap CLI command --- crates/apps/src/bin/namada/cli.rs | 1 + crates/apps_lib/src/cli.rs | 113 ++++++++++++++++++++++ crates/apps_lib/src/cli/client.rs | 12 +++ crates/sdk/src/args.rs | 155 +++++++++++++++++++++++++++++- 4 files changed, 280 insertions(+), 1 deletion(-) diff --git a/crates/apps/src/bin/namada/cli.rs b/crates/apps/src/bin/namada/cli.rs index f3165a4a41..bbe37d5959 100644 --- a/crates/apps/src/bin/namada/cli.rs +++ b/crates/apps/src/bin/namada/cli.rs @@ -50,6 +50,7 @@ fn handle_command(cmd: cli::cmds::Namada, raw_sub_cmd: String) -> Result<()> { | cli::cmds::Namada::TxShieldingTransfer(_) | cli::cmds::Namada::TxUnshieldingTransfer(_) | cli::cmds::Namada::TxIbcTransfer(_) + | cli::cmds::Namada::TxOsmosisSwap(_) | cli::cmds::Namada::TxUpdateAccount(_) | cli::cmds::Namada::TxRevealPk(_) | cli::cmds::Namada::TxInitProposal(_) diff --git a/crates/apps_lib/src/cli.rs b/crates/apps_lib/src/cli.rs index 36e0de0d7c..0e7b660adc 100644 --- a/crates/apps_lib/src/cli.rs +++ b/crates/apps_lib/src/cli.rs @@ -63,6 +63,7 @@ pub mod cmds { TxShieldingTransfer(TxShieldingTransfer), TxUnshieldingTransfer(TxUnshieldingTransfer), TxIbcTransfer(TxIbcTransfer), + TxOsmosisSwap(TxOsmosisSwap), TxUpdateAccount(TxUpdateAccount), TxInitProposal(TxInitProposal), TxVoteProposal(TxVoteProposal), @@ -84,6 +85,7 @@ pub mod cmds { .subcommand(TxShieldingTransfer::def().display_order(2)) .subcommand(TxUnshieldingTransfer::def().display_order(2)) .subcommand(TxIbcTransfer::def().display_order(2)) + .subcommand(TxOsmosisSwap::def().display_order(2)) .subcommand(TxUpdateAccount::def().display_order(2)) .subcommand(TxInitProposal::def().display_order(2)) .subcommand(TxVoteProposal::def().display_order(2)) @@ -107,6 +109,8 @@ pub mod cmds { SubCmd::parse(matches).map(Self::TxUnshieldingTransfer); let tx_ibc_transfer = SubCmd::parse(matches).map(Self::TxIbcTransfer); + let tx_osmosis_swap = + SubCmd::parse(matches).map(Self::TxOsmosisSwap); let tx_update_account = SubCmd::parse(matches).map(Self::TxUpdateAccount); let tx_init_proposal = @@ -124,6 +128,7 @@ pub mod cmds { .or(tx_shielding_transfer) .or(tx_unshielding_transfer) .or(tx_ibc_transfer) + .or(tx_osmosis_swap) .or(tx_update_account) .or(tx_init_proposal) .or(tx_vote_proposal) @@ -239,6 +244,7 @@ pub mod cmds { .subcommand(TxShieldingTransfer::def().display_order(1)) .subcommand(TxUnshieldingTransfer::def().display_order(1)) .subcommand(TxIbcTransfer::def().display_order(1)) + .subcommand(TxOsmosisSwap::def().display_order(1)) .subcommand(TxUpdateAccount::def().display_order(1)) .subcommand(TxInitAccount::def().display_order(1)) .subcommand(TxRevealPk::def().display_order(1)) @@ -313,6 +319,7 @@ pub mod cmds { let tx_unshielding_transfer = Self::parse_with_ctx(matches, TxUnshieldingTransfer); let tx_ibc_transfer = Self::parse_with_ctx(matches, TxIbcTransfer); + let tx_osmosis_swap = Self::parse_with_ctx(matches, TxOsmosisSwap); let tx_update_account = Self::parse_with_ctx(matches, TxUpdateAccount); let tx_init_account = Self::parse_with_ctx(matches, TxInitAccount); @@ -402,6 +409,7 @@ pub mod cmds { .or(tx_shielding_transfer) .or(tx_unshielding_transfer) .or(tx_ibc_transfer) + .or(tx_osmosis_swap) .or(tx_update_account) .or(tx_init_account) .or(tx_reveal_pk) @@ -497,6 +505,7 @@ pub mod cmds { TxShieldingTransfer(TxShieldingTransfer), TxUnshieldingTransfer(TxUnshieldingTransfer), TxIbcTransfer(TxIbcTransfer), + TxOsmosisSwap(TxOsmosisSwap), QueryResult(QueryResult), TxUpdateAccount(TxUpdateAccount), TxInitAccount(TxInitAccount), @@ -1436,6 +1445,25 @@ pub mod cmds { } } + #[derive(Clone, Debug)] + pub struct TxOsmosisSwap(pub args::TxOsmosisSwap); + + impl SubCmd for TxOsmosisSwap { + const CMD: &'static str = "osmosis-swap"; + + fn parse(matches: &ArgMatches) -> Option { + matches.subcommand_matches(Self::CMD).map(|matches| { + TxOsmosisSwap(args::TxOsmosisSwap::parse(matches)) + }) + } + + fn def() -> App { + App::new(Self::CMD) + .about(wrap!("Swap two asset kinds using Osmosis.")) + .add_args::>() + } + } + #[derive(Clone, Debug)] pub struct TxUpdateAccount(pub args::TxUpdateAccount); @@ -3569,8 +3597,11 @@ pub mod args { pub const NUT: ArgFlag = flag("nut"); pub const OUT_FILE_PATH_OPT: ArgOpt = arg_opt("out-file-path"); pub const OUTPUT: ArgOpt = arg_opt("output"); + pub const OUTPUT_DENOM: Arg = arg("output-denom"); pub const OUTPUT_FOLDER_PATH: ArgOpt = arg_opt("output-folder-path"); + pub const OSMOSIS_POOL_HOP: ArgMulti = + arg_multi("pool-hop"); pub const OWNER: Arg = arg("owner"); pub const OWNER_OPT: ArgOpt = OWNER.opt(); pub const PATH: Arg = arg("path"); @@ -3623,6 +3654,7 @@ pub mod args { pub const SHIELDED: ArgFlag = flag("shielded"); pub const SHOW_IBC_TOKENS: ArgFlag = flag("show-ibc-tokens"); pub const SIGNER: ArgOpt = arg_opt("signer"); + pub const SLIPPAGE: Arg = arg("slippage-percentage"); pub const SIGNING_KEYS: ArgMulti = arg_multi("signing-keys"); pub const SIGNATURES: ArgMulti = arg_multi("signatures"); @@ -3679,6 +3711,7 @@ pub mod args { pub const WASM_CHECKSUMS_PATH: Arg = arg("wasm-checksums-path"); pub const WASM_DIR: ArgOpt = arg_opt("wasm-dir"); pub const WEBSITE_OPT: ArgOpt = arg_opt("website"); + pub const WINDOW_SECONDS: Arg = arg("window-seconds"); pub const WITH_INDEXER: ArgOpt = arg_opt("with-indexer"); pub const WRAPPER_SIGNATURE_OPT: ArgOpt = arg_opt("gas-signature"); pub const TX_PATH: Arg = arg("tx-path"); @@ -5108,6 +5141,86 @@ pub mod args { } } + impl CliToSdk> for TxOsmosisSwap { + type Error = std::io::Error; + + fn to_sdk( + self, + ctx: &mut Context, + ) -> Result, Self::Error> { + let chain_ctx = ctx.borrow_mut_chain_or_exit(); + let recipient = chain_ctx.get(&self.recipient); + Ok(TxOsmosisSwap { + transfer: self.transfer.to_sdk(ctx)?, + output_denom: self.output_denom, + recipient, + slippage_percent: self.slippage_percent, + window_seconds: self.window_seconds, + route: self.route, + }) + } + } + + impl Args for TxOsmosisSwap { + fn parse(matches: &ArgMatches) -> Self { + let transfer = TxIbcTransfer::parse(matches); + let output_denom = OUTPUT_DENOM.parse(matches); + let recipient = TARGET.parse(matches); + let slippage_percent = SLIPPAGE.parse(matches); + if slippage_percent > 100 { + panic!( + "The slippage percent must be an integer between 0 and \ + 100." + ) + } + let window_seconds = WINDOW_SECONDS.parse(matches); + let route = match OSMOSIS_POOL_HOP.parse(matches) { + r if r.is_empty() => None, + r => Some(r), + }; + Self { + transfer, + output_denom, + recipient, + slippage_percent, + window_seconds, + route, + } + } + + // TODO: + // - fix window_seconds help str + // - add osmo1... address to recover funds in case of ibc failures + // during packet forwarding + fn def(app: App) -> App { + app.add_args::>() + .arg(OSMOSIS_POOL_HOP.def().help(wrap!( + "Individual hop of the route to take through Osmosis \ + pools. This value takes the form \ + :. When unspecified, \ + the optimal route is queried on the fly." + ))) + .arg(OUTPUT_DENOM.def().help(wrap!( + "The IBC denomination (on Osmosis) of the desired asset." + ))) + .arg(TARGET.def().help(wrap!( + "The address that shall receive the swapped tokens." + ))) + .arg(SLIPPAGE.def().help(wrap!( + "The slippage percentage as an integer between 0 and 100." + ))) + .arg(WINDOW_SECONDS.def().help(wrap!( + "A mysterious thing that should be set to 10s." + ))) + .mut_arg(RECEIVER.name, |arg| { + arg.long("swap-contract").help(wrap!( + "The address of the Osmosis contract performing the \ + swap. It will be the receiver of the IBC transfer." + )) + }) + } + } + impl CliToSdk> for TxInitAccount { type Error = std::io::Error; diff --git a/crates/apps_lib/src/cli/client.rs b/crates/apps_lib/src/cli/client.rs index 6542a55b63..7647164dd3 100644 --- a/crates/apps_lib/src/cli/client.rs +++ b/crates/apps_lib/src/cli/client.rs @@ -112,6 +112,18 @@ impl CliApi { let namada = ctx.to_sdk(client, io); tx::submit_ibc_transfer(&namada, args).await?; } + Sub::TxOsmosisSwap(TxOsmosisSwap(args)) => { + let chain_ctx = ctx.borrow_mut_chain_or_exit(); + let ledger_address = + chain_ctx.get(&args.transfer.tx.ledger_address); + let client = client.unwrap_or_else(|| { + C::from_tendermint_address(&ledger_address) + }); + client.wait_until_node_is_synced(&io).await?; + let args = args.to_sdk(&mut ctx)?.assemble(); + let namada = ctx.to_sdk(client, io); + tx::submit_ibc_transfer(&namada, args).await?; + } Sub::TxUpdateAccount(TxUpdateAccount(args)) => { let chain_ctx = ctx.borrow_mut_chain_or_exit(); let ledger_address = diff --git a/crates/sdk/src/args.rs b/crates/sdk/src/args.rs index c4934750ac..74170b07a0 100644 --- a/crates/sdk/src/args.rs +++ b/crates/sdk/src/args.rs @@ -23,7 +23,7 @@ use namada_ibc::IbcShieldingData; use namada_token::masp::utils::RetryStrategy; use namada_tx::data::GasLimit; use namada_tx::Memo; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use zeroize::Zeroizing; use crate::eth_bridge::bridge_pool; @@ -460,6 +460,159 @@ impl TxUnshieldingTransfer { } } +/// Individual hop of some route to take through Osmosis pools. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct OsmosisPoolHop { + /// The id of the pool to use on Osmosis. + pub pool_id: String, + /// The output denomination expected from the + /// pool on Osmosis. + pub token_out_denom: String, +} + +impl FromStr for OsmosisPoolHop { + type Err = String; + + fn from_str(s: &str) -> Result { + s.split_once(':').map_or_else( + || { + Err(format!( + "Expected : string, but found \ + {s:?} instead" + )) + }, + |(pool_id, token_out_denom)| { + Ok(OsmosisPoolHop { + pool_id: pool_id.to_owned(), + token_out_denom: token_out_denom.to_owned(), + }) + }, + ) + } +} + +/// An token swap on Osmosis +#[derive(Debug, Clone)] +pub struct TxOsmosisSwap { + /// The IBC transfer data + pub transfer: TxIbcTransfer, + /// The token we wish to receive + pub output_denom: String, + /// Recipient address + pub recipient: C::Address, + /// Slippage percent + pub slippage_percent: u64, + /// TODO! Figure out what this is + pub window_seconds: u64, + /// The route to take through Osmosis pools + pub route: Option>, +} + +impl TxOsmosisSwap { + /// Create an IBC transfer from the input arguments + pub fn assemble(self) -> TxIbcTransfer { + #[derive(Serialize)] + struct MemoFromNamada { + namada: NamadaContext, + } + + #[derive(Serialize)] + struct NamadaContext { + memo: String, + } + + #[derive(Serialize)] + struct Memo { + wasm: Wasm, + } + + #[derive(Serialize)] + struct Wasm { + contract: String, + msg: Message, + } + + #[derive(Serialize)] + struct Message { + osmosis_swap: OsmosisSwap, + } + + #[derive(Serialize)] + struct OsmosisSwap { + output_denom: String, + slippage: Slippage, + receiver: String, + #[serde(skip_serializing_if = "Option::is_none")] + next_memo: Option, + on_failed_delivery: String, + #[serde(skip_serializing_if = "Option::is_none")] + route: Option>, + } + + #[derive(Serialize)] + struct Slippage { + twap: Twap, + } + + #[derive(Serialize)] + struct Twap { + #[serde(serialize_with = "serialize_slippage")] + slippage_percentage: u64, + window_seconds: u64, + } + + fn serialize_slippage( + val: &u64, + serializer: S, + ) -> Result + where + S: Serializer, + { + serializer.serialize_str(&val.to_string()) + } + + let Self { + mut transfer, + output_denom, + recipient, + slippage_percent, + window_seconds, + route, + } = self; + + let next_memo = transfer.ibc_memo.take().map(|memo| { + serde_json::to_string(&MemoFromNamada { + namada: NamadaContext { memo }, + }) + .unwrap() + }); + + let memo = Memo { + wasm: Wasm { + contract: transfer.receiver.clone(), + msg: Message { + osmosis_swap: OsmosisSwap { + output_denom: output_denom.to_string(), + slippage: Slippage { + twap: Twap { + slippage_percentage: slippage_percent, + window_seconds, + }, + }, + next_memo, + receiver: recipient.to_string(), + on_failed_delivery: "do_nothing".to_string(), + route, + }, + }, + }, + }; + + transfer.ibc_memo = Some(serde_json::to_string(&memo).unwrap()); + transfer + } +} + /// IBC transfer transaction arguments #[derive(Clone, Debug)] pub struct TxIbcTransfer {