From 51edcce954bfd21685831203341a6e44f50ee59b Mon Sep 17 00:00:00 2001 From: Mykhailo Donchenko <91957742+Buckram123@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:20:10 +0200 Subject: [PATCH] [Feature] Command Line Interface (CLI) (#177) * Added better gas estimation for low gas txs * formatting * prototype * small improvements * instantiate msg * migrate * small refactor * Parse fields * small refactor * lints * Set admin and add coins * fix generics as variants * add state_interface to cw_parse Helps to read daemon state inside cw_parse * StateInterface is enough * parse into Empty * sloppy addons implementation * fmt * addons * from_cli for daemon * clean unused dependencies * move to trait and create custom error * main cli * addonscontext autoimpl for clone * add key to keyring * refactor * clean ups * edit comment * no need to confirm * messages fix * format toml * cosmwasm execute * execute wasm * finished execute wasm * query method * fix comment * instantiate and upload * small renames * rename actions as actions * taplo fmt * add chain selector * transfers * get and transfer ownership cw-ownable * auto-claim if given receiver * accept and renounce ownerships * fix show address * disable warning * Change parse_network return type + exposed supported networks * Added env variable for disabling all logs * Modified docs * Added granter flag * Stabilized snapshots * Using bTreeMap and better syntax * disable logs message * add comment on disabling log messages * fix macos keyring * fix coin parse * rename cw-ownable action * move cw-ownable * messages update * raw query * Save chain instead of chain-id after locking * add latest command to the history * fix cosmrs update * ux improvement and balance queries * initial readme * cut out contract cli * move cw-orch-cli upper * update authors * small description * Fix link * structure Cargo.toml * remove tonic * keys docs * update readme and some prompts * add spaces for emojis * Validate and re-prompt json/base64 if needed * format * Address book in commands * Address book sub-commands * format * fix cw-plus deps * merge fixes * cover null query responses * disable back on the first menu * restrict empty aliases * Fix of help messages * WIP: fetch cw_orch * cw_orch state fetcher * formatting of messages * Disable skip of expiration * small optimization * few fixes * multiline json * enable logs with verbose flag * Save signer and signer selector * empty-out file before writing * std fs fixes * file management quickfix * merge cw orch state option * update readme script * flag to merge cw-orch state * fix clippy * add file message input * few fixes * readme update * move contract cli * fix readme * include cw-orch-cli as workspace member * force disable cw-orch state merging for address_book::remove_address * Remove msg postfix for a file message type * clippy * bump msrv * merge-cw-orch-state > source-state-file * newline on errors * Log url of tx * add explorer to any address * base64 raw queries * quick readme fix * fix of explorers * versionbump * update to newest cw-orch * clippy * use daemon bank querier for querying balance * unused cosmrs * Use Daemon for sending coins * post-merge fixes * new contract queries * toml format * upgrade cw-orch-cli to 0.25 cw-orch * small formatting * cli doc formatting * update cosmos action docs * bump interactive clap * wasm upload fixed * remove rejected todos --------- Co-authored-by: Kayanski Co-authored-by: cyberhoward --- .github/codecov.yml | 1 + CHANGELOG.md | 1 + Cargo.toml | 1 + cw-orch-cli/Cargo.toml | 45 ++ cw-orch-cli/README.md | 69 ++++ cw-orch-cli/src/commands/action/asset/mod.rs | 35 ++ .../commands/action/asset/query_cw20/mod.rs | 50 +++ .../commands/action/asset/query_native/mod.rs | 47 +++ .../commands/action/asset/send_cw20/mod.rs | 66 +++ .../commands/action/asset/send_native/mod.rs | 62 +++ .../commands/action/cosmwasm/execute/mod.rs | 82 ++++ .../action/cosmwasm/instantiate/mod.rs | 99 +++++ .../src/commands/action/cosmwasm/mod.rs | 35 ++ .../commands/action/cosmwasm/msg_type/mod.rs | 187 +++++++++ .../commands/action/cosmwasm/query/code.rs | 29 ++ .../action/cosmwasm/query/contract_info.rs | 35 ++ .../src/commands/action/cosmwasm/query/mod.rs | 34 ++ .../src/commands/action/cosmwasm/query/raw.rs | 58 +++ .../commands/action/cosmwasm/query/smart.rs | 56 +++ .../src/commands/action/cosmwasm/store/mod.rs | 43 ++ .../commands/action/cw_ownable/accept/mod.rs | 55 +++ .../src/commands/action/cw_ownable/get/mod.rs | 39 ++ .../src/commands/action/cw_ownable/mod.rs | 49 +++ .../action/cw_ownable/renounce/mod.rs | 55 +++ .../action/cw_ownable/transfer/mod.rs | 96 +++++ cw-orch-cli/src/commands/action/mod.rs | 62 +++ .../src/commands/address_book/add_address.rs | 26 ++ .../commands/address_book/fetch_cw_orch.rs | 189 +++++++++ cw-orch-cli/src/commands/address_book/mod.rs | 61 +++ .../commands/address_book/remove_address.rs | 40 ++ .../src/commands/address_book/show_address.rs | 53 +++ cw-orch-cli/src/commands/keys/add_key/mod.rs | 98 +++++ cw-orch-cli/src/commands/keys/mod.rs | 30 ++ .../src/commands/keys/remove_key/mod.rs | 30 ++ .../src/commands/keys/show_address/mod.rs | 51 +++ cw-orch-cli/src/commands/keys/show_key/mod.rs | 28 ++ cw-orch-cli/src/commands/mod.rs | 28 ++ cw-orch-cli/src/common.rs | 115 ++++++ cw-orch-cli/src/fetch/explorers.rs | 26 ++ cw-orch-cli/src/fetch/mod.rs | 1 + cw-orch-cli/src/lib.rs | 53 +++ cw-orch-cli/src/log/mod.rs | 37 ++ cw-orch-cli/src/main.rs | 46 +++ cw-orch-cli/src/types/address_book.rs | 389 ++++++++++++++++++ cw-orch-cli/src/types/chain.rs | 60 +++ cw-orch-cli/src/types/cli_subdir.rs | 11 + cw-orch-cli/src/types/coins.rs | 35 ++ cw-orch-cli/src/types/expiration.rs | 42 ++ cw-orch-cli/src/types/keys.rs | 106 +++++ cw-orch-cli/src/types/mod.rs | 15 + cw-orch-cli/src/types/path_buf.rs | 21 + cw-orch-cli/src/types/skippable.rs | 33 ++ cw-orch-daemon/src/state.rs | 1 - docs/src/SUMMARY.md | 3 + docs/src/cli/cosmos_action.md | 47 +++ docs/src/cli/index.md | 23 ++ docs/src/cli/keys.md | 35 ++ 57 files changed, 3123 insertions(+), 1 deletion(-) create mode 100644 cw-orch-cli/Cargo.toml create mode 100644 cw-orch-cli/README.md create mode 100644 cw-orch-cli/src/commands/action/asset/mod.rs create mode 100644 cw-orch-cli/src/commands/action/asset/query_cw20/mod.rs create mode 100644 cw-orch-cli/src/commands/action/asset/query_native/mod.rs create mode 100644 cw-orch-cli/src/commands/action/asset/send_cw20/mod.rs create mode 100644 cw-orch-cli/src/commands/action/asset/send_native/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/execute/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/instantiate/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/msg_type/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/query/code.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/query/contract_info.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/query/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/query/raw.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/query/smart.rs create mode 100644 cw-orch-cli/src/commands/action/cosmwasm/store/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cw_ownable/accept/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cw_ownable/get/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cw_ownable/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cw_ownable/renounce/mod.rs create mode 100644 cw-orch-cli/src/commands/action/cw_ownable/transfer/mod.rs create mode 100644 cw-orch-cli/src/commands/action/mod.rs create mode 100644 cw-orch-cli/src/commands/address_book/add_address.rs create mode 100644 cw-orch-cli/src/commands/address_book/fetch_cw_orch.rs create mode 100644 cw-orch-cli/src/commands/address_book/mod.rs create mode 100644 cw-orch-cli/src/commands/address_book/remove_address.rs create mode 100644 cw-orch-cli/src/commands/address_book/show_address.rs create mode 100644 cw-orch-cli/src/commands/keys/add_key/mod.rs create mode 100644 cw-orch-cli/src/commands/keys/mod.rs create mode 100644 cw-orch-cli/src/commands/keys/remove_key/mod.rs create mode 100644 cw-orch-cli/src/commands/keys/show_address/mod.rs create mode 100644 cw-orch-cli/src/commands/keys/show_key/mod.rs create mode 100644 cw-orch-cli/src/commands/mod.rs create mode 100644 cw-orch-cli/src/common.rs create mode 100644 cw-orch-cli/src/fetch/explorers.rs create mode 100644 cw-orch-cli/src/fetch/mod.rs create mode 100644 cw-orch-cli/src/lib.rs create mode 100644 cw-orch-cli/src/log/mod.rs create mode 100644 cw-orch-cli/src/main.rs create mode 100644 cw-orch-cli/src/types/address_book.rs create mode 100644 cw-orch-cli/src/types/chain.rs create mode 100644 cw-orch-cli/src/types/cli_subdir.rs create mode 100644 cw-orch-cli/src/types/coins.rs create mode 100644 cw-orch-cli/src/types/expiration.rs create mode 100644 cw-orch-cli/src/types/keys.rs create mode 100644 cw-orch-cli/src/types/mod.rs create mode 100644 cw-orch-cli/src/types/path_buf.rs create mode 100644 cw-orch-cli/src/types/skippable.rs create mode 100644 docs/src/cli/cosmos_action.md create mode 100644 docs/src/cli/index.md create mode 100644 docs/src/cli/keys.md diff --git a/.github/codecov.yml b/.github/codecov.yml index c7e0de38d..cbd4fefb4 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -16,6 +16,7 @@ ignore: - "tests" - "**/examples" - "**/schema.rs" + - "cw-orch-cli" # Make comments less noisy comment: diff --git a/CHANGELOG.md b/CHANGELOG.md index b55ae7d60..ac5b507ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -112,6 +112,7 @@ ## 0.21.2 - Allow cw-orch wasm compilation without features +- Bumped MSRV to 1.74 because of dependency `clap_derive@4.5.3` - Transaction Response now inspects logs and events to find matching events. ## 0.21.1 diff --git a/Cargo.toml b/Cargo.toml index b2f9f8ccb..0af62121f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "cw-orch", + "cw-orch-cli", "cw-orch-daemon", "cw-orch-interchain", "packages/cw-orch-core", diff --git a/cw-orch-cli/Cargo.toml b/cw-orch-cli/Cargo.toml new file mode 100644 index 000000000..3fd84ed81 --- /dev/null +++ b/cw-orch-cli/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "cw-orch-cli" +version = "0.2.4" +authors = ["Buckram "] +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Command-line tool for managing Cosmos-based interaction." + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cw-orch = { workspace = true, features = ["daemon"] } + +# Logs +pretty_env_logger = { version = "0.5.0" } +async-trait = { version = "0.1" } + +# Cosmos +cosmwasm-std = { workspace = true } +cw-utils = "2.0.0" +cw20 = { version = "2.0" } +cw-ownable = { version = "2.0.0" } +cosmrs = { workspace = true, features = ["cosmwasm", "grpc"] } +ibc-chain-registry = { workspace = true } + +# Serde +serde_json = { workspace = true } +serde = { workspace = true } +base64 = { version = "0.22.1" } + +# Interactive clap +interactive-clap = "0.3.0" +interactive-clap-derive = "0.3.0" +clap = { version = "4.0.18", features = ["derive"] } +color-eyre = { version = "0.6" } +strum = { version = "0.24", features = ["derive"] } +derive_more = "0.99" +shell-words = "1.0.0" +inquire = { version = "0.6", features = ["editor"] } + +# Key management +keyring = "2.0.5" +bip32 = { version = "0.5", features = ["mnemonic"] } +rand_core = { version = "0.6", features = ["std"] } diff --git a/cw-orch-cli/README.md b/cw-orch-cli/README.md new file mode 100644 index 000000000..92085a6c8 --- /dev/null +++ b/cw-orch-cli/README.md @@ -0,0 +1,69 @@ +# CosmWasm Orch Command Line Interface (CLI) + +The CosmWasm Orch CLI is a tool designed to facilitate the development, deployment, and interaction with CosmWasm smart contracts on Cosmos blockchains. It enables developers to create, test, and manage contracts using the interactive CLI and easily deploy them onto supported Cosmos networks. + +## Installation + +### Prerequisites + +- Rust +- OpenSSL +- Access to keyring + +### Cargo + +```bash +cargo install cw-orch-cli +``` + +### Add last command to the shell history (Optional) + +If Cw Orch CLI ran in interactive mode it's executed command will **not** be appended to your shell history. This means you will not be able to `arrow up` to get the last command and tweak it to your liking. + +To solve this you can add the function below to your `~/.bashrc` or similar. +This function wraps the CLI and appends its executed action to your current shell history, enabling you to retrieve it from the history. + +```bash +cw-orch-cli() { + command=$(command cw-orch-cli "$@" | tee /dev/tty | grep 'Your console command' | cut -f2- -d':') + if [ "$command" != "cw-orch-cli" ] + then + history -s cw-orch-cli "$@" # if you still want to be able `arrow up` to the original command + fi + history -s $command +} +``` + +## Usage + +The CLI supports two modes of execution: interactive and non-interactive. + +### Interactive mode + +In interactive mode the CLI will guide you through complex tasks by reducing the initial command's complexity, and ensuring a more intuitive user experience. + +The interactive mode will prompt you for new information when needed as you go through the process of creating, testing, and deploying a contract. + +Example: + +```bash +cw-orch-cli --verbose +``` + +### Non-interactive mode + +You can utilize the non-interactive mode for scripting, automated operations, and tweaking of the interactive mode's commands. Often you'll find yourself using the interactive mode to get the command you need, and then debug it with the non-interactive mode. + +Example: + +```bash +cw-orch-cli action uni-6 cw query raw juno1czkm9gq96zwwncxusgzruvpuex4wjf4ak7lms6q698938k529q3shmfl90 raw contract_info +``` + +### Global optional arguments + +- `-v` or `--verbose` - enable verbose mode, this will log actions from cw-orch daemon executions that corresponds to your `RUST_LOG` level +- `-s` or `--source-state-file` - source cw-orch state file(`STATE_FILE` [cw-orch env variable]) to use together with address-book entries (address book have higher priority) +- --deployment-id - cw-orch state deployment-id, defaults to "default" + +[cw-orch env variable]: ../docs/src/contracts/env-variable.md diff --git a/cw-orch-cli/src/commands/action/asset/mod.rs b/cw-orch-cli/src/commands/action/asset/mod.rs new file mode 100644 index 000000000..171f7482b --- /dev/null +++ b/cw-orch-cli/src/commands/action/asset/mod.rs @@ -0,0 +1,35 @@ +use super::CosmosContext; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod query_cw20; +mod query_native; +mod send_cw20; +mod send_native; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(context = CosmosContext)] +pub struct AssetCommands { + #[interactive_clap(subcommand)] + action: AssetAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = CosmosContext)] +/// Select asset action +pub enum AssetAction { + /// Native or factory coin send + #[strum_discriminants(strum(message = "Send native coins"))] + SendNative(send_native::SendNativeCommands), + /// Cw20 coin transfer + #[strum_discriminants(strum(message = "Send cw20 coin"))] + SendCw20(send_cw20::Cw20TransferCommands), + /// Native or factory coins query + #[strum_discriminants(strum(message = "Query native coins"))] + QueryNative(query_native::QueryNativeCommands), + /// Cw20 coin query + #[strum_discriminants(strum(message = "Query cw20 coins"))] + QueryCw20(query_cw20::QueryCw20Commands), + // TODO: cw720? +} diff --git a/cw-orch-cli/src/commands/action/asset/query_cw20/mod.rs b/cw-orch-cli/src/commands/action/asset/query_cw20/mod.rs new file mode 100644 index 000000000..5466ce50a --- /dev/null +++ b/cw-orch-cli/src/commands/action/asset/query_cw20/mod.rs @@ -0,0 +1,50 @@ +use crate::types::CliAddress; + +use super::CosmosContext; + +use cw20::BalanceResponse; +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryCw20Output)] +pub struct QueryCw20Commands { + /// Cw20 Address or alias from address-book + cw20_address: CliAddress, + /// Address or alias from address-book + address: CliAddress, +} + +pub struct QueryCw20Output; + +impl QueryCw20Output { + fn from_previous_context( + previous_context: CosmosContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let cw20_account_id = scope + .cw20_address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let cw20_addr = Addr::unchecked(cw20_account_id); + + let account_id = scope + .address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + + let daemon = chain.daemon_querier()?; + + let balance: BalanceResponse = daemon.query( + &(cw20::Cw20QueryMsg::Balance { + address: account_id.to_string(), + }), + &cw20_addr, + )?; + println!("{}", serde_json::to_string_pretty(&balance)?); + + Ok(QueryCw20Output) + } +} diff --git a/cw-orch-cli/src/commands/action/asset/query_native/mod.rs b/cw-orch-cli/src/commands/action/asset/query_native/mod.rs new file mode 100644 index 000000000..bb81ef069 --- /dev/null +++ b/cw-orch-cli/src/commands/action/asset/query_native/mod.rs @@ -0,0 +1,47 @@ +use crate::types::{CliAddress, CliSkippable}; + +use super::CosmosContext; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryNativeOutput)] +pub struct QueryNativeCommands { + /// Input denom or leave empty to query all balances + denom: CliSkippable, + /// Address or alias from address-book + address: CliAddress, +} + +pub struct QueryNativeOutput; + +impl QueryNativeOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + let denom = scope.denom.0.clone(); + + let account_id = scope + .address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let addr = Addr::unchecked(account_id); + + let daemon = chain.daemon_querier()?; + + if let Some(denom) = denom { + let balance = daemon.balance(&addr, Some(denom))?.swap_remove(0); + println!("balance: {balance}") + } else { + let balances = daemon.balance(&addr, None)?; + // `cosmwasm_std::Coins` have nice display + let coins = cosmwasm_std::Coins::try_from(balances).unwrap(); + println!("balances: {coins}") + } + + Ok(QueryNativeOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/asset/send_cw20/mod.rs b/cw-orch-cli/src/commands/action/asset/send_cw20/mod.rs new file mode 100644 index 000000000..085e08a04 --- /dev/null +++ b/cw-orch-cli/src/commands/action/asset/send_cw20/mod.rs @@ -0,0 +1,66 @@ +use crate::{ + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress}, +}; + +use super::CosmosContext; + +use cosmwasm_std::Uint128; +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = SendCw20Output)] +pub struct Cw20TransferCommands { + /// Cw20 Address or alias from address-book + cw20_address: CliAddress, + /// Cw20 Amount + amount: u128, + /// Recipient address or alias from address-book + to_address: CliAddress, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl Cw20TransferCommands { + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct SendCw20Output; + +impl SendCw20Output { + fn from_previous_context( + previous_context: CosmosContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let to_address_account_id = scope + .to_address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + + let cw20_account_id = scope + .cw20_address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let cw20_addr = Addr::unchecked(cw20_account_id); + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let resp = daemon.execute( + &cw20::Cw20ExecuteMsg::Transfer { + recipient: to_address_account_id.to_string(), + amount: Uint128::new(scope.amount), + }, + &[], + &cw20_addr, + )?; + resp.log(chain.chain_info()); + + Ok(SendCw20Output) + } +} diff --git a/cw-orch-cli/src/commands/action/asset/send_native/mod.rs b/cw-orch-cli/src/commands/action/asset/send_native/mod.rs new file mode 100644 index 000000000..e3cc01d97 --- /dev/null +++ b/cw-orch-cli/src/commands/action/asset/send_native/mod.rs @@ -0,0 +1,62 @@ +use crate::{ + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress, CliCoins}, +}; + +use super::CosmosContext; + +use color_eyre::eyre::Context; +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = SendNativeOutput)] +pub struct SendNativeCommands { + #[interactive_clap(skip_default_input_arg)] + /// Input coins + coins: CliCoins, + /// Recipient Address or alias from address-book + to_address: CliAddress, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl SendNativeCommands { + fn input_coins(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::parse_coins() + .map(|c| Some(CliCoins(c))) + .wrap_err("Bad coins input") + } + + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct SendNativeOutput; + +impl SendNativeOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + let coins = scope.coins.clone().into(); + + let to_address = scope + .to_address + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let to_address = Addr::unchecked(to_address); + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let resp = daemon + .rt_handle + .block_on(daemon.sender().bank_send(&to_address, coins))?; + resp.log(chain.chain_info()); + + Ok(SendNativeOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/execute/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/execute/mod.rs new file mode 100644 index 000000000..d7411e950 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/execute/mod.rs @@ -0,0 +1,82 @@ +use crate::{ + commands::action::CosmosContext, + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress, CliCoins}, +}; + +use super::msg_type; + +use color_eyre::eyre::Context; +use cw_orch::daemon::TxSender; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = ExecuteWasmOutput)] +/// Execute contract method +pub struct ExecuteContractCommands { + /// Contract Address or alias from address-book + contract_addr: CliAddress, + #[interactive_clap(value_enum)] + #[interactive_clap(skip_default_input_arg)] + /// How do you want to pass the message arguments? + msg_type: msg_type::MsgType, + /// Enter message or filename + msg: String, + #[interactive_clap(skip_default_input_arg)] + /// Input coins + coins: CliCoins, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl ExecuteContractCommands { + fn input_msg_type( + _context: &CosmosContext, + ) -> color_eyre::eyre::Result> { + msg_type::input_msg_type() + } + + fn input_coins(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::parse_coins() + .map(|c| Some(CliCoins(c))) + .wrap_err("Bad coins input") + } + + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} +pub struct ExecuteWasmOutput; + +impl ExecuteWasmOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let msg = msg_type::msg_bytes(scope.msg.clone(), scope.msg_type.clone())?; + let coins = (&scope.coins).try_into()?; + + let contract_account_id = scope + .contract_addr + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let exec_msg = cosmrs::cosmwasm::MsgExecuteContract { + sender: daemon.sender().account_id(), + contract: contract_account_id, + msg, + funds: coins, + }; + let resp = daemon + .rt_handle + .block_on(daemon.sender().commit_tx(vec![exec_msg], None))?; + resp.log(chain.chain_info()); + + Ok(ExecuteWasmOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/instantiate/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/instantiate/mod.rs new file mode 100644 index 000000000..94090a7c0 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/instantiate/mod.rs @@ -0,0 +1,99 @@ +use crate::{ + commands::action::CosmosContext, + log::LogOutput, + types::{address_book, keys::seed_phrase_for_id, CliCoins, CliSkippable}, +}; + +use super::msg_type; + +use color_eyre::eyre::Context; +use cw_orch::{daemon::TxSender, prelude::*}; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = InstantiateWasmOutput)] +/// Execute contract method +pub struct InstantiateContractCommands { + /// Contract code id + code_id: u64, + #[interactive_clap(value_enum)] + #[interactive_clap(skip_default_input_arg)] + /// How do you want to pass the message arguments? + msg_type: msg_type::MsgType, + /// Enter message or filename + msg: String, + /// Label for the contract + label: String, + /// Admin address of the contract, leave empty to skip admin + admin: CliSkippable, + #[interactive_clap(skip_default_input_arg)] + /// Input coins + coins: CliCoins, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl InstantiateContractCommands { + fn input_msg_type( + _context: &CosmosContext, + ) -> color_eyre::eyre::Result> { + msg_type::input_msg_type() + } + + fn input_coins(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::parse_coins() + .map(|c| Some(CliCoins(c))) + .wrap_err("Bad coins input") + } + + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} +pub struct InstantiateWasmOutput; + +impl InstantiateWasmOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let coins = (&scope.coins).try_into()?; + let msg = msg_type::msg_bytes(scope.msg.clone(), scope.msg_type.clone())?; + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let init_msg = cosmrs::cosmwasm::MsgInstantiateContract { + sender: daemon.sender().account_id(), + admin: scope.admin.clone().0.map(|a| a.parse()).transpose()?, + code_id: scope.code_id, + label: Some(scope.label.clone()), + msg, + funds: coins, + }; + + let resp = daemon + .rt_handle + .block_on(daemon.sender().commit_tx(vec![init_msg], None))?; + resp.log(chain.chain_info()); + + let address = resp.instantiated_contract_address()?; + println!("Address of the instantiated contract: {address}"); + + // Maybe save it in Address Book + match inquire::Confirm::new("Would you like to save address in Address Book?").prompt()? { + true => { + let alias = inquire::Text::new("Input new contract alias") + // Use label as default value + .with_initial_value(&scope.label) + .prompt()?; + address_book::try_insert_account_id(chain.chain_info(), &alias, address.as_str())?; + } + false => (), + }; + + Ok(InstantiateWasmOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/mod.rs new file mode 100644 index 000000000..02fbf8a15 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/mod.rs @@ -0,0 +1,35 @@ +use super::CosmosContext; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod execute; +mod instantiate; +pub mod msg_type; +mod query; +mod store; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(context = CosmosContext)] +pub struct CwCommands { + #[interactive_clap(subcommand)] + action: CwAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = CosmosContext)] +/// Select cosmwasm action +pub enum CwAction { + /// Store contract + #[strum_discriminants(strum(message = "๐Ÿ“ค Store"))] + Store(store::StoreContractCommands), + /// Instantiate contract + #[strum_discriminants(strum(message = "๐Ÿš€ Instantiate"))] + Instantiate(instantiate::InstantiateContractCommands), + /// Execute contract method + #[strum_discriminants(strum(message = "โšก Execute"))] + Execute(execute::ExecuteContractCommands), + /// Query contract + #[strum_discriminants(strum(message = "๐Ÿ” Query"))] + Query(query::QueryCommands), +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/msg_type/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/msg_type/mod.rs new file mode 100644 index 000000000..6bd9394cb --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/msg_type/mod.rs @@ -0,0 +1,187 @@ +use std::str::FromStr; + +use base64::Engine; +use color_eyre::eyre::Context; +use inquire::Select; +use strum::{EnumDiscriminants, EnumIter, EnumMessage, IntoEnumIterator}; + +#[derive(Debug, EnumDiscriminants, Clone, clap::ValueEnum)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +/// How do you want to pass the message arguments? +pub enum MsgType { + #[strum_discriminants(strum(message = "json message"))] + /// Valid JSON string (e.g. {"foo": "bar"}) + JsonMsg, + #[strum_discriminants(strum(message = "base64 message"))] + /// Base64-encoded string (e.g. eyJmb28iOiJiYXIifQ==) + Base64Msg, + #[strum_discriminants(strum(message = "Read from a file"))] + /// Read from a file (e.g. file.json) + File, + #[strum_discriminants(strum(message = "Use your editor"))] + /// Open editor (uses EDITOR env variable) to input message + Editor, +} + +impl interactive_clap::ToCli for MsgType { + type CliVariant = MsgType; +} + +impl std::str::FromStr for MsgType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "json-msg" => Ok(Self::JsonMsg), + "base64-msg" => Ok(Self::Base64Msg), + "file" => Ok(Self::File), + "editor" => Ok(Self::Editor), + _ => Err("MsgType: incorrect message type".to_string()), + } + } +} + +impl std::fmt::Display for MsgType { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::JsonMsg => write!(f, "json-msg"), + Self::Base64Msg => write!(f, "base64-msg"), + Self::File => write!(f, "file"), + Self::Editor => write!(f, "editor"), + } + } +} + +impl std::fmt::Display for MsgTypeDiscriminants { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::JsonMsg => write!(f, "Json Msg"), + Self::Base64Msg => write!(f, "Base64 Msg"), + Self::File => write!(f, "File"), + Self::Editor => write!(f, "Editor"), + } + } +} + +pub fn input_msg_type() -> color_eyre::eyre::Result> { + let variants = MsgTypeDiscriminants::iter().collect::>(); + let selected = Select::new("Select message format", variants).prompt()?; + match selected { + MsgTypeDiscriminants::JsonMsg => Ok(Some(MsgType::JsonMsg)), + MsgTypeDiscriminants::Base64Msg => Ok(Some(MsgType::Base64Msg)), + MsgTypeDiscriminants::File => Ok(Some(MsgType::File)), + MsgTypeDiscriminants::Editor => Ok(Some(MsgType::Editor)), + } +} + +pub fn msg_bytes(message_or_file: String, msg_type: MsgType) -> color_eyre::eyre::Result> { + match msg_type { + MsgType::JsonMsg => { + let message_json = serde_json::Value::from_str(&message_or_file) + .wrap_err("Message not in JSON format")?; + + serde_json::to_vec(&message_json).wrap_err("Unexpected error") + } + MsgType::Base64Msg => crate::common::B64 + .decode(message_or_file) + .wrap_err("Failed to decode base64 string"), + MsgType::File => { + let file_path = std::path::PathBuf::from(message_or_file); + let msg_bytes = + std::fs::read(file_path.as_path()).wrap_err("Failed to read a message file")?; + Ok(msg_bytes) + } + MsgType::Editor => { + let mut prompt = inquire::Editor::new("Enter message"); + if message_or_file.is_empty() { + prompt = prompt + .with_predefined_text("{}") + .with_file_extension(".json"); + } else { + prompt = prompt.with_file_extension(&message_or_file) + }; + let message = prompt.prompt()?; + Ok(message.into_bytes()) + } + } +} + +#[derive(Debug, EnumDiscriminants, Clone, Copy, clap::ValueEnum)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +/// How do you want to pass the key arguments? +pub enum KeyType { + #[strum_discriminants(strum(message = "Raw string"))] + /// Raw string (e.g. contract_info) + Raw, + #[strum_discriminants(strum(message = "base64 message"))] + /// Base64-encoded string (e.g. Y29udHJhY3QtaW5mbw==) + Base64, +} + +impl interactive_clap::ToCli for KeyType { + type CliVariant = KeyType; +} + +impl std::str::FromStr for KeyType { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "raw" => Ok(Self::Raw), + "base64" => Ok(Self::Base64), + _ => Err("KeyType: incorrect key type".to_string()), + } + } +} + +impl std::fmt::Display for KeyType { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Raw => write!(f, "raw"), + Self::Base64 => write!(f, "base64"), + } + } +} + +impl std::fmt::Display for KeyTypeDiscriminants { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Raw => write!(f, "Raw"), + Self::Base64 => write!(f, "Base64"), + } + } +} + +pub fn input_key_type() -> color_eyre::eyre::Result> { + let variants = KeyTypeDiscriminants::iter().collect::>(); + let selected = Select::new("Select key format", variants).prompt()?; + match selected { + KeyTypeDiscriminants::Raw => Ok(Some(KeyType::Raw)), + KeyTypeDiscriminants::Base64 => Ok(Some(KeyType::Base64)), + } +} + +pub fn key_bytes(key: String, key_type: KeyType) -> color_eyre::eyre::Result> { + match key_type { + KeyType::Raw => Ok(key.into_bytes()), + KeyType::Base64 => crate::common::B64 + .decode(key) + .wrap_err("Failed to decode base64 string"), + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn check_message() { + let b_64_msg = msg_bytes( + "eyJsYXRlc3RfY29udHJhY3RzIjp7fX0=".to_owned(), + MsgType::Base64Msg, + ) + .unwrap(); + let json_msg = + msg_bytes(r#"{"latest_contracts":{}}"#.to_owned(), MsgType::JsonMsg).unwrap(); + + assert_eq!(b_64_msg, json_msg); + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/query/code.rs b/cw-orch-cli/src/commands/action/cosmwasm/query/code.rs new file mode 100644 index 000000000..8f039ef71 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/query/code.rs @@ -0,0 +1,29 @@ +use crate::commands::action::CosmosContext; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryCodeOutput)] +pub struct QueryCodeCommands { + /// Enter code id + code_id: u64, +} + +pub struct QueryCodeOutput; + +impl QueryCodeOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let daemon = chain.daemon_querier()?; + + let code_info = daemon.wasm_querier().code(scope.code_id)?; + println!("{}", serde_json::to_string_pretty(&code_info)?); + + Ok(QueryCodeOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/query/contract_info.rs b/cw-orch-cli/src/commands/action/cosmwasm/query/contract_info.rs new file mode 100644 index 000000000..f6c6c8832 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/query/contract_info.rs @@ -0,0 +1,35 @@ +use crate::{commands::action::CosmosContext, types::CliAddress}; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryContractInfoOutput)] +pub struct QueryContractInfoCommands { + /// Contract Address or alias from address-book + contract: CliAddress, +} + +pub struct QueryContractInfoOutput; + +impl QueryContractInfoOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let addr = Addr::unchecked(account_id); + + let daemon = chain.daemon_querier()?; + + let contract_info = daemon.wasm_querier().contract_info(&addr)?; + println!("{}", serde_json::to_string_pretty(&contract_info)?); + + Ok(QueryContractInfoOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/query/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/query/mod.rs new file mode 100644 index 000000000..7e300a7e3 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/query/mod.rs @@ -0,0 +1,34 @@ +use crate::commands::action::CosmosContext; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod code; +mod contract_info; +mod raw; +mod smart; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(context = CosmosContext)] +pub struct QueryCommands { + #[interactive_clap(subcommand)] + action: QueryAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = CosmosContext)] +/// Select cosmwasm action +pub enum QueryAction { + /// Query wasm smart + #[strum_discriminants(strum(message = "๐Ÿค“ Smart"))] + Smart(smart::QuerySmartCommands), + /// Query wasm raw state + #[strum_discriminants(strum(message = "๐Ÿ‘‰ Raw"))] + Raw(raw::QueryRawCommands), + /// Query code + #[strum_discriminants(strum(message = "๐Ÿ”ข Code"))] + Code(code::QueryCodeCommands), + /// Query contract info + #[strum_discriminants(strum(message = "๐Ÿ” Contract Info"))] + ContractInfo(contract_info::QueryContractInfoCommands), +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/query/raw.rs b/cw-orch-cli/src/commands/action/cosmwasm/query/raw.rs new file mode 100644 index 000000000..ddc748cf8 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/query/raw.rs @@ -0,0 +1,58 @@ +use crate::{ + commands::action::{ + cosmwasm::msg_type::{self, key_bytes, KeyType}, + CosmosContext, + }, + types::CliAddress, +}; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryWasmOutput)] +pub struct QueryRawCommands { + /// Contract Address or alias from address-book + contract: CliAddress, + /// Enter key type + #[interactive_clap(skip_default_input_arg)] + key_type: KeyType, + /// Enter key + key: String, +} + +impl QueryRawCommands { + fn input_key_type(_context: &CosmosContext) -> color_eyre::eyre::Result> { + msg_type::input_key_type() + } +} + +pub struct QueryWasmOutput; + +impl QueryWasmOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let query_data = key_bytes(scope.key.clone(), scope.key_type)?; + + let daemon = chain.daemon_querier()?; + + let resp_data = daemon + .wasm_querier() + .raw_query(&contract_addr, query_data)?; + let parsed_output: Option = serde_json::from_slice(&resp_data)?; + let output = parsed_output.unwrap_or_default(); + println!("{}", serde_json::to_string_pretty(&output)?); + + Ok(QueryWasmOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/query/smart.rs b/cw-orch-cli/src/commands/action/cosmwasm/query/smart.rs new file mode 100644 index 000000000..bc34a9dae --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/query/smart.rs @@ -0,0 +1,56 @@ +use crate::{commands::action::CosmosContext, types::CliAddress}; + +use super::super::msg_type; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = QueryWasmOutput)] +pub struct QuerySmartCommands { + /// Contract Address or alias from address-book + contract: CliAddress, + #[interactive_clap(value_enum)] + #[interactive_clap(skip_default_input_arg)] + /// How do you want to pass the message arguments? + msg_type: msg_type::MsgType, + /// Enter message or filename + msg: String, +} + +impl QuerySmartCommands { + fn input_msg_type( + _context: &CosmosContext, + ) -> color_eyre::eyre::Result> { + msg_type::input_msg_type() + } +} +pub struct QueryWasmOutput; + +impl QueryWasmOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let msg = msg_type::msg_bytes(scope.msg.clone(), scope.msg_type.clone())?; + + let daemon = chain.daemon_querier()?; + + let resp_data = daemon + .rt_handle + .block_on(daemon.wasm_querier()._contract_state(&contract_addr, msg))?; + let parsed_output: Option = serde_json::from_slice(&resp_data)?; + let output = parsed_output.unwrap_or_default(); + println!("{}", serde_json::to_string_pretty(&output)?); + + Ok(QueryWasmOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cosmwasm/store/mod.rs b/cw-orch-cli/src/commands/action/cosmwasm/store/mod.rs new file mode 100644 index 000000000..8f2cc79b6 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cosmwasm/store/mod.rs @@ -0,0 +1,43 @@ +use crate::{commands::action::CosmosContext, types::keys::seed_phrase_for_id}; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = StoreWasmOutput)] +/// Execute contract method +pub struct StoreContractCommands { + /// Input path to the wasm + wasm_path: crate::types::PathBuf, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl StoreContractCommands { + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct StoreWasmOutput; + +impl StoreWasmOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + let wasm_path = WasmPath::new(&scope.wasm_path)?; + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let resp = daemon + .rt_handle + .block_on(daemon.sender().upload_wasm(wasm_path))?; + let code_id = resp.uploaded_code_id().unwrap(); + println!("code_id: {code_id}"); + + Ok(StoreWasmOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cw_ownable/accept/mod.rs b/cw-orch-cli/src/commands/action/cw_ownable/accept/mod.rs new file mode 100644 index 000000000..65d43ead4 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cw_ownable/accept/mod.rs @@ -0,0 +1,55 @@ +use crate::{ + commands::action::CosmosContext, + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress}, +}; + +use super::ContractExecuteMsg; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = AcceptOwnershipOutput)] +pub struct AcceptOwnership { + /// Contract Address or alias from address-book + contract: CliAddress, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl AcceptOwnership { + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct AcceptOwnershipOutput; + +impl AcceptOwnershipOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let action = cw_ownable::Action::AcceptOwnership {}; + let resp = daemon.execute( + &ContractExecuteMsg::UpdateOwnership(action), + &[], + &contract_addr, + )?; + resp.log(chain.chain_info()); + + Ok(AcceptOwnershipOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cw_ownable/get/mod.rs b/cw-orch-cli/src/commands/action/cw_ownable/get/mod.rs new file mode 100644 index 000000000..fe2885e9d --- /dev/null +++ b/cw-orch-cli/src/commands/action/cw_ownable/get/mod.rs @@ -0,0 +1,39 @@ +use crate::{commands::action::CosmosContext, types::CliAddress}; + +use super::ContractQueryMsg; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = GetOwnershipOutput)] +pub struct GetOwnership { + /// Contract Address or alias from address-book + contract: CliAddress, +} + +pub struct GetOwnershipOutput; + +impl GetOwnershipOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let daemon = chain.daemon_querier()?; + + let output: serde_json::Value = daemon + .wasm_querier() + .smart_query(&contract_addr, &ContractQueryMsg::Ownership {})?; + println!("{}", serde_json::to_string_pretty(&output)?); + + Ok(GetOwnershipOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cw_ownable/mod.rs b/cw-orch-cli/src/commands/action/cw_ownable/mod.rs new file mode 100644 index 000000000..e615770c1 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cw_ownable/mod.rs @@ -0,0 +1,49 @@ +use crate::commands::action::CosmosContext; + +use serde::Serialize; +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod accept; +mod get; +mod renounce; +mod transfer; + +// Helper enum to serialize execute +#[derive(Serialize, Debug)] +#[serde(rename_all = "snake_case")] +enum ContractExecuteMsg { + UpdateOwnership(cw_ownable::Action), +} + +// Helper enum to serialize query +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +enum ContractQueryMsg { + Ownership {}, +} + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(context = CosmosContext)] +pub struct CwOwnableCommands { + #[interactive_clap(subcommand)] + action: CwOwnableAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = CosmosContext)] +/// Select cosmwasm action +pub enum CwOwnableAction { + /// Propose to transfer contract ownership to another address + #[strum_discriminants(strum(message = "๐Ÿ’ Propose ownership to another address."))] + Transfer(transfer::TransferOwnership), + /// Accept pending ownership + #[strum_discriminants(strum(message = "โœ… Accept pending ownership."))] + Accept(accept::AcceptOwnership), + /// Renounce pending ownership + #[strum_discriminants(strum(message = "๐Ÿšซ Renounce pending ownership"))] + Renounce(renounce::RenounceOwnership), + /// Get current ownership + #[strum_discriminants(strum(message = "โ“ Get current ownership"))] + Get(get::GetOwnership), +} diff --git a/cw-orch-cli/src/commands/action/cw_ownable/renounce/mod.rs b/cw-orch-cli/src/commands/action/cw_ownable/renounce/mod.rs new file mode 100644 index 000000000..b92b0a398 --- /dev/null +++ b/cw-orch-cli/src/commands/action/cw_ownable/renounce/mod.rs @@ -0,0 +1,55 @@ +use crate::{ + commands::action::CosmosContext, + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress}, +}; + +use super::ContractExecuteMsg; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = RenounceOwnershipOutput)] +pub struct RenounceOwnership { + /// Contract Address or alias from address-book + contract: CliAddress, + #[interactive_clap(skip_default_input_arg)] + signer: String, +} + +impl RenounceOwnership { + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct RenounceOwnershipOutput; + +impl RenounceOwnershipOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let action = cw_ownable::Action::RenounceOwnership {}; + let resp = daemon.execute( + &ContractExecuteMsg::UpdateOwnership(action), + &[], + &contract_addr, + )?; + resp.log(chain.chain_info()); + + Ok(RenounceOwnershipOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/cw_ownable/transfer/mod.rs b/cw-orch-cli/src/commands/action/cw_ownable/transfer/mod.rs new file mode 100644 index 000000000..03cc728ff --- /dev/null +++ b/cw-orch-cli/src/commands/action/cw_ownable/transfer/mod.rs @@ -0,0 +1,96 @@ +use crate::{ + commands::action::CosmosContext, + common::parse_expiration, + log::LogOutput, + types::{keys::seed_phrase_for_id, CliAddress, CliExpiration, CliSkippable}, +}; + +use super::ContractExecuteMsg; + +use cw_orch::prelude::*; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = CosmosContext)] +#[interactive_clap(output_context = TransferOwnershipOutput)] +pub struct TransferOwnership { + /// Contract Address or alias from address-book + contract: CliAddress, + /// New owner Address or alias from address-book + new_owner: CliAddress, + /// Expiration + #[interactive_clap(skip_default_input_arg)] + expiration: CliExpiration, + #[interactive_clap(skip_default_input_arg)] + signer: String, + /// New owner signer id, leave empty to skip auto-claim + new_signer: CliSkippable, +} + +impl TransferOwnership { + fn input_expiration(_: &CosmosContext) -> color_eyre::eyre::Result> { + let expiration = parse_expiration()?; + Ok(Some(CliExpiration(expiration))) + } + + fn input_signer(_context: &CosmosContext) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct TransferOwnershipOutput; + +impl TransferOwnershipOutput { + fn from_previous_context( + previous_context: CosmosContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + + let contract_account_id = scope + .contract + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + let contract_addr = Addr::unchecked(contract_account_id); + + let new_owner = scope + .new_owner + .clone() + .account_id(chain.chain_info(), &previous_context.global_config)?; + + let seed = seed_phrase_for_id(&scope.signer)?; + let daemon = chain.daemon(seed)?; + + let action = cw_ownable::Action::TransferOwnership { + new_owner: new_owner.to_string(), + expiry: Some(scope.expiration.0), + }; + let resp = daemon.execute( + &ContractExecuteMsg::UpdateOwnership(action), + &[], + &contract_addr, + )?; + resp.log(chain.chain_info()); + println!("Successfully transferred ownership, waiting for approval by {new_owner}",); + + let maybe_receiver_seed = scope + .new_signer + .0 + .as_deref() + .map(seed_phrase_for_id) + .transpose()?; + if let Some(receiver_seed) = maybe_receiver_seed { + let daemon = daemon.rebuild().mnemonic(receiver_seed).build()?; + + let action = cw_ownable::Action::AcceptOwnership {}; + let resp = daemon.execute( + &ContractExecuteMsg::UpdateOwnership(action), + &[], + &contract_addr, + )?; + resp.log(chain.chain_info()); + println!("{new_owner} successfully accepted ownership"); + } + + Ok(TransferOwnershipOutput) + } +} diff --git a/cw-orch-cli/src/commands/action/mod.rs b/cw-orch-cli/src/commands/action/mod.rs new file mode 100644 index 000000000..c92d3343e --- /dev/null +++ b/cw-orch-cli/src/commands/action/mod.rs @@ -0,0 +1,62 @@ +use crate::{types::CliLockedChain, GlobalConfig}; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod asset; +mod cosmwasm; +mod cw_ownable; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = GlobalConfig)] +#[interactive_clap(output_context = CosmosContext)] +pub struct CosmosCommands { + #[interactive_clap(skip_default_input_arg)] + /// Chain id + chain_id: CliLockedChain, + #[interactive_clap(subcommand)] + action: CosmosAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = CosmosContext)] +/// Select type of cosmos action +pub enum CosmosAction { + /// Cosmwasm Action: store, instantiate, execute or query cosmwasm contract + #[strum_discriminants(strum(message = "๐Ÿ”ฎ CosmWasm"))] + Cw(cosmwasm::CwCommands), + /// Asset Action + #[strum_discriminants(strum(message = "๐Ÿฆ Asset"))] + Asset(asset::AssetCommands), + /// CW-Ownable Action + #[strum_discriminants(strum(message = "๐Ÿ‘‘ CW-Ownable"))] + CwOwnable(cw_ownable::CwOwnableCommands), +} + +impl CosmosCommands { + fn input_chain_id(_context: &GlobalConfig) -> color_eyre::eyre::Result> { + crate::common::select_chain() + } +} + +impl From for () { + fn from(_value: CosmosContext) -> Self {} +} + +#[derive(Clone)] +pub struct CosmosContext { + pub chain: CliLockedChain, + pub global_config: GlobalConfig, +} + +impl CosmosContext { + fn from_previous_context( + previous_context: GlobalConfig, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + Ok(CosmosContext { + chain: scope.chain_id, + global_config: previous_context, + }) + } +} diff --git a/cw-orch-cli/src/commands/address_book/add_address.rs b/cw-orch-cli/src/commands/address_book/add_address.rs new file mode 100644 index 000000000..2f415cd5e --- /dev/null +++ b/cw-orch-cli/src/commands/address_book/add_address.rs @@ -0,0 +1,26 @@ +use crate::types::address_book; + +use super::AddresBookContext; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = AddresBookContext)] +#[interactive_clap(output_context = AddAddressOutput)] +pub struct AddAddress { + /// Alias on AddressBook + alias: String, + /// New Address for the alias + address: String, +} + +pub struct AddAddressOutput; + +impl AddAddressOutput { + fn from_previous_context( + previous_context: AddresBookContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain_info = previous_context.chain.chain_info(); + address_book::try_insert_account_id(chain_info, &scope.alias, &scope.address)?; + Ok(AddAddressOutput) + } +} diff --git a/cw-orch-cli/src/commands/address_book/fetch_cw_orch.rs b/cw-orch-cli/src/commands/address_book/fetch_cw_orch.rs new file mode 100644 index 000000000..0c3e9a0d5 --- /dev/null +++ b/cw-orch-cli/src/commands/address_book/fetch_cw_orch.rs @@ -0,0 +1,189 @@ +use std::str::FromStr; + +use crate::types::address_book::{self, cw_orch_state_contracts, CW_ORCH_STATE_FILE_DAMAGED_ERROR}; + +use super::AddresBookContext; + +use strum::IntoEnumIterator; + +#[derive(Debug, strum::EnumDiscriminants, strum::Display, Clone, clap::ValueEnum)] +#[strum_discriminants(derive(strum::EnumMessage, strum::EnumIter))] +pub enum AliasNameStrategy { + #[strum(serialize = "keep")] + #[strum_discriminants(strum(message = "Keep contract ids as name aliases"))] + /// Keep contract ids as name aliases + Keep, + #[strum(serialize = "rename")] + #[strum_discriminants(strum(message = "Give prompt to rename aliases"))] + /// Give prompt to rename aliases + Rename, +} + +impl interactive_clap::ToCli for AliasNameStrategy { + type CliVariant = AliasNameStrategy; +} + +impl std::fmt::Display for AliasNameStrategyDiscriminants { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AliasNameStrategyDiscriminants::Keep => write!(f, "Keep"), + AliasNameStrategyDiscriminants::Rename => write!(f, "Rename"), + } + } +} + +impl FromStr for AliasNameStrategy { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "keep" => Ok(Self::Keep), + "rename" => Ok(Self::Rename), + _ => Err("AliasNameStrategy: incorrect alias name strategy".to_string()), + } + } +} + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = AddresBookContext)] +#[interactive_clap(output_context = FetchAddressesOutput)] +pub struct FetchAddresses { + #[interactive_clap(value_enum)] + #[interactive_clap(skip_default_input_arg)] + /// Alias names strategy + name_strategy: AliasNameStrategy, +} + +impl FetchAddresses { + fn input_name_strategy( + _context: &AddresBookContext, + ) -> color_eyre::eyre::Result> { + let variants = AliasNameStrategyDiscriminants::iter().collect::>(); + let selected = inquire::Select::new("Select alias names strategy", variants).prompt()?; + match selected { + AliasNameStrategyDiscriminants::Keep => Ok(Some(AliasNameStrategy::Keep)), + AliasNameStrategyDiscriminants::Rename => Ok(Some(AliasNameStrategy::Rename)), + } + } +} + +pub struct FetchAddressesOutput; + +#[derive(Debug, strum::EnumDiscriminants, strum::Display, Clone)] +#[strum_discriminants(derive(strum::EnumMessage, strum::EnumIter))] +pub enum DuplicateResolve { + #[strum_discriminants(strum(message = "Rename duplicate"))] + Rename, + #[strum_discriminants(strum(message = "Skip duplicate"))] + Skip, + #[strum_discriminants(strum(message = "Override duplicate"))] + Override, + #[strum_discriminants(strum(message = "Skip all duplicates"))] + SkipAll, + #[strum_discriminants(strum(message = "Override all duplicates"))] + OverrideAll, +} + +impl std::fmt::Display for DuplicateResolveDiscriminants { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DuplicateResolveDiscriminants::Rename => write!(f, "Rename"), + DuplicateResolveDiscriminants::Skip => write!(f, "Skip"), + DuplicateResolveDiscriminants::Override => write!(f, "Override"), + DuplicateResolveDiscriminants::SkipAll => write!(f, "Skip All"), + DuplicateResolveDiscriminants::OverrideAll => write!(f, "Override All"), + } + } +} + +impl FetchAddressesOutput { + fn from_previous_context( + previous_context: AddresBookContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain_info = previous_context.chain.chain_info(); + let contracts = + cw_orch_state_contracts(chain_info, &previous_context.global_config.deployment_id)?; + + let mut duplicate_resolve_global = None; + for (contract_id, address) in contracts { + let address = address + .as_str() + .ok_or(color_eyre::eyre::eyre!(CW_ORCH_STATE_FILE_DAMAGED_ERROR))?; + let mut alias = match scope.name_strategy { + AliasNameStrategy::Keep => contract_id.clone(), + AliasNameStrategy::Rename => inquire::Text::new("Input new contract alias") + .with_initial_value(&contract_id) + .prompt()?, + }; + let maybe_address = address_book::get_account_id_address_book(chain_info, &alias)?; + + // Duplicate handle + if let Some(current) = maybe_address { + // Duplicate happened + let duplicate_resolve = match &duplicate_resolve_global { + // Check if it's already globally resolved + Some(global_resolved) => match global_resolved { + DuplicateResolve::SkipAll => DuplicateResolve::Skip, + DuplicateResolve::OverrideAll => DuplicateResolve::Override, + _ => unreachable!(), + }, + // Or resolve here + None => input_duplicate_resolve(&alias, current.as_ref(), address)?, + }; + + match duplicate_resolve { + // Skip + DuplicateResolve::Skip => { + continue; + } + DuplicateResolve::SkipAll => { + duplicate_resolve_global = Some(duplicate_resolve); + continue; + } + // Rename + DuplicateResolve::Rename => loop { + alias = inquire::Text::new("Rename contract alias") + .with_initial_value(&contract_id) + .prompt()?; + let is_duplicate = + address_book::get_account_id_address_book(chain_info, &alias)? + .is_some(); + if !is_duplicate { + break; + } + }, + // Override + DuplicateResolve::Override => {} + DuplicateResolve::OverrideAll => { + duplicate_resolve_global = Some(duplicate_resolve); + } + } + } + address_book::insert_account_id(chain_info.chain_id, &alias, address)?; + } + Ok(FetchAddressesOutput) + } +} + +fn input_duplicate_resolve( + original: &str, + stored: &str, + new: &str, +) -> color_eyre::eyre::Result { + let variants = DuplicateResolveDiscriminants::iter().collect::>(); + let selected = inquire::Select::new( + "A duplicate has occurred, what do you prefer to do?", + variants, + ) + .with_help_message(&format!("alias: {original} current: {stored} new: {new}")) + .prompt()?; + let selected = match selected { + DuplicateResolveDiscriminants::Rename => DuplicateResolve::Rename, + DuplicateResolveDiscriminants::Skip => DuplicateResolve::Skip, + DuplicateResolveDiscriminants::Override => DuplicateResolve::Override, + DuplicateResolveDiscriminants::SkipAll => DuplicateResolve::SkipAll, + DuplicateResolveDiscriminants::OverrideAll => DuplicateResolve::OverrideAll, + }; + Ok(selected) +} diff --git a/cw-orch-cli/src/commands/address_book/mod.rs b/cw-orch-cli/src/commands/address_book/mod.rs new file mode 100644 index 000000000..485e500e7 --- /dev/null +++ b/cw-orch-cli/src/commands/address_book/mod.rs @@ -0,0 +1,61 @@ +use crate::{types::CliLockedChain, GlobalConfig}; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +mod add_address; +mod fetch_cw_orch; +mod remove_address; +mod show_address; + +#[derive(Clone, Debug)] +pub struct AddresBookContext { + pub global_config: GlobalConfig, + pub chain: CliLockedChain, +} + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = GlobalConfig)] +#[interactive_clap(output_context = AddresBookContext)] +pub struct AddressBookCommands { + #[interactive_clap(skip_default_input_arg)] + chain_id: CliLockedChain, + #[interactive_clap(subcommand)] + key_actions: KeyAction, +} + +impl AddressBookCommands { + fn input_chain_id(_context: &GlobalConfig) -> color_eyre::eyre::Result> { + crate::common::select_chain() + } +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(context = AddresBookContext)] +/// Select key action +pub enum KeyAction { + /// Add or override an Address to your Address Book + #[strum_discriminants(strum(message = "๐Ÿ“ Add or override an Address to your Address Book"))] + Add(add_address::AddAddress), + /// Show address from address book + #[strum_discriminants(strum(message = "๐Ÿ“Œ Show Address from Address Book"))] + Show(show_address::ShowAddress), + /// Remove an Address from your Address Book + #[strum_discriminants(strum(message = "โŒ Remove an address from your address book"))] + Remove(remove_address::RemoveAddress), + /// Fetch addresses from cw-orchestrator state file + #[strum_discriminants(strum(message = "๐Ÿงท Fetch addresses from cw-orchestrator state file"))] + Fetch(fetch_cw_orch::FetchAddresses), +} + +impl AddresBookContext { + fn from_previous_context( + previous_context: GlobalConfig, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + Ok(AddresBookContext { + global_config: previous_context, + chain: scope.chain_id, + }) + } +} diff --git a/cw-orch-cli/src/commands/address_book/remove_address.rs b/cw-orch-cli/src/commands/address_book/remove_address.rs new file mode 100644 index 000000000..eb59dc686 --- /dev/null +++ b/cw-orch-cli/src/commands/address_book/remove_address.rs @@ -0,0 +1,40 @@ +use crate::types::address_book; + +use super::AddresBookContext; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = AddresBookContext)] +#[interactive_clap(output_context = RemoveAddressOutput)] +pub struct RemoveAddress { + /// Address Book Alias for the Address + #[interactive_clap(skip_default_input_arg)] + alias: String, +} + +impl RemoveAddress { + pub fn input_alias(context: &AddresBookContext) -> color_eyre::eyre::Result> { + // Disable state merging, CLI do not remove items from cw-orch state + let mut config = context.global_config.clone(); + config.source_state_file = false; + + address_book::select_alias(context.chain.chain_info(), &config) + } +} + +pub struct RemoveAddressOutput; + +impl RemoveAddressOutput { + fn from_previous_context( + previous_context: AddresBookContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + let removed = address_book::remove_account_id(chain.chain_info().chain_id, &scope.alias)?; + + match removed { + Some(val) => println!("removed: {val}"), + None => println!("No updates!"), + } + Ok(RemoveAddressOutput) + } +} diff --git a/cw-orch-cli/src/commands/address_book/show_address.rs b/cw-orch-cli/src/commands/address_book/show_address.rs new file mode 100644 index 000000000..45e74aa9a --- /dev/null +++ b/cw-orch-cli/src/commands/address_book/show_address.rs @@ -0,0 +1,53 @@ +use cw_orch::tokio::runtime::Runtime; + +use crate::{ + common::show_addr_explorer, + types::address_book::{self, select_alias}, +}; + +use super::AddresBookContext; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = AddresBookContext)] +#[interactive_clap(output_context = ShowAddressOutput)] +pub struct ShowAddress { + /// Address Book Alias for the Address + #[interactive_clap(skip_default_input_arg)] + alias: String, +} + +impl ShowAddress { + pub fn input_alias(context: &AddresBookContext) -> color_eyre::eyre::Result> { + select_alias(context.chain.chain_info(), &context.global_config) + } +} + +pub struct ShowAddressOutput; + +impl ShowAddressOutput { + fn from_previous_context( + previous_context: AddresBookContext, + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let chain = previous_context.chain; + let maybe_account_id = address_book::get_account_id( + chain.chain_info(), + &previous_context.global_config, + &scope.alias, + )?; + + match maybe_account_id { + Some(account_id) => { + println!("{account_id}"); + let runtime = Runtime::new()?; + let _ = runtime.block_on(show_addr_explorer( + chain.chain_info().clone(), + account_id.as_ref(), + )); + } + None => println!("Address not found"), + } + + Ok(ShowAddressOutput) + } +} diff --git a/cw-orch-cli/src/commands/keys/add_key/mod.rs b/cw-orch-cli/src/commands/keys/add_key/mod.rs new file mode 100644 index 000000000..8b1be29e1 --- /dev/null +++ b/cw-orch-cli/src/commands/keys/add_key/mod.rs @@ -0,0 +1,98 @@ +use base64::Engine; +use cosmrs::bip32; +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +use crate::common::B64; +use crate::types::keys::{entry_for_seed, read_entries, save_entry_if_required}; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = ())] +#[interactive_clap(output_context = AddKeyContext)] +pub struct AddKeyCommand { + #[interactive_clap(skip_default_input_arg)] + /// Id of they key + name: String, + #[interactive_clap(subcommand)] + key_actions: AddKeyActions, +} + +impl AddKeyCommand { + fn input_name(_context: &()) -> color_eyre::eyre::Result> { + let entries = read_entries()?; + let name = inquire::Text::new("Id of they key") + .with_validator(move |s: &str| { + if s.is_empty() { + return Ok(inquire::validator::Validation::Invalid( + inquire::validator::ErrorMessage::Custom( + "Empty key not allowed".to_owned(), + ), + )); + }; + if entries.entries.contains(s) { + return Ok(inquire::validator::Validation::Invalid( + inquire::validator::ErrorMessage::Custom("Key already exist".to_owned()), + )); + }; + Ok(inquire::validator::Validation::Valid) + }) + .prompt()?; + Ok(Some(name)) + } +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(input_context = AddKeyContext)] +#[interactive_clap(output_context = AddKeyOutput)] +/// How you want to create a new key? +pub enum AddKeyActions { + /// Generate new random key + #[strum_discriminants(strum(message = "Generate new random key"))] + New, + /// Recover key from the seed phrase + #[strum_discriminants(strum(message = "Recover key from the seed phrase"))] + FromSeed, +} + +#[derive(Clone)] +pub struct AddKeyContext(String); + +impl AddKeyContext { + fn from_previous_context( + _previous_context: (), + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + Ok(AddKeyContext(scope.name.clone())) + } +} + +pub struct AddKeyOutput; + +impl AddKeyOutput { + fn from_previous_context( + previous_context: AddKeyContext, + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let name = previous_context.0; + let mnemonic = match scope { + AddKeyActionsDiscriminants::New => { + bip32::Mnemonic::random(rand_core::OsRng, Default::default()) + } + AddKeyActionsDiscriminants::FromSeed => { + let mnemonic_seed = inquire::Password::new("Mnemonic ๐Ÿ”‘: ") + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .with_display_toggle_enabled() + .with_help_message("ctrl+R to unmask") + .without_confirmation() + .prompt()?; + bip32::Mnemonic::new(mnemonic_seed, Default::default())? + } + }; + let entry = entry_for_seed(&name)?; + let password = B64.encode(mnemonic.phrase().as_bytes()); + entry.set_password(&password)?; + save_entry_if_required(&name)?; + println!("New key \"{name}\" added"); + Ok(AddKeyOutput) + } +} diff --git a/cw-orch-cli/src/commands/keys/mod.rs b/cw-orch-cli/src/commands/keys/mod.rs new file mode 100644 index 000000000..c6cf202a2 --- /dev/null +++ b/cw-orch-cli/src/commands/keys/mod.rs @@ -0,0 +1,30 @@ +mod add_key; +mod remove_key; +mod show_address; +mod show_key; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +pub struct KeyCommands { + #[interactive_clap(subcommand)] + key_actions: KeyAction, +} + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +/// Select key action +pub enum KeyAction { + /// Add key to the keyring + #[strum_discriminants(strum(message = "๐Ÿ“ Add key to the keyring"))] + Add(add_key::AddKeyCommand), + /// Show seed from keyring + #[strum_discriminants(strum(message = "๐Ÿ” Show key of given id from the keyring"))] + Show(show_key::ShowKeyCommand), + /// Remove key from the keyring + #[strum_discriminants(strum(message = "โŒ Remove key from the keyring"))] + Remove(remove_key::RemoveKeyCommand), + /// Show address + #[strum_discriminants(strum(message = "๐Ÿ“Œ Show address"))] + ShowAddress(show_address::ShowAddressCommand), +} diff --git a/cw-orch-cli/src/commands/keys/remove_key/mod.rs b/cw-orch-cli/src/commands/keys/remove_key/mod.rs new file mode 100644 index 000000000..f4b38fe24 --- /dev/null +++ b/cw-orch-cli/src/commands/keys/remove_key/mod.rs @@ -0,0 +1,30 @@ +use crate::types::keys::entry_for_seed; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = ())] +#[interactive_clap(output_context = RemoveKeyOutput)] +pub struct RemoveKeyCommand { + #[interactive_clap(skip_default_input_arg)] + name: String, +} + +impl RemoveKeyCommand { + fn input_name(_: &()) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct RemoveKeyOutput; + +impl RemoveKeyOutput { + fn from_previous_context( + _previous_context: (), + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let entry = entry_for_seed(&scope.name)?; + entry.delete_password()?; + crate::types::keys::remove_entry(&scope.name)?; + println!("Key \"{}\" got removed", scope.name); + Ok(RemoveKeyOutput) + } +} diff --git a/cw-orch-cli/src/commands/keys/show_address/mod.rs b/cw-orch-cli/src/commands/keys/show_address/mod.rs new file mode 100644 index 000000000..955594f34 --- /dev/null +++ b/cw-orch-cli/src/commands/keys/show_address/mod.rs @@ -0,0 +1,51 @@ +use cw_orch::{daemon::DaemonAsync, tokio::runtime::Runtime}; + +use crate::{ + common::show_addr_explorer, + types::{keys::seed_phrase_for_id, CliLockedChain}, +}; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = ())] +#[interactive_clap(output_context = ShowAddressOutput)] +pub struct ShowAddressCommand { + #[interactive_clap(skip_default_input_arg)] + name: String, + #[interactive_clap(skip_default_input_arg)] + chain_id: CliLockedChain, +} + +impl ShowAddressCommand { + fn input_name(_: &()) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } + + fn input_chain_id(_: &()) -> color_eyre::eyre::Result> { + crate::common::select_chain() + } +} + +pub struct ShowAddressOutput; + +impl ShowAddressOutput { + fn from_previous_context( + _previous_context: (), + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let mnemonic = seed_phrase_for_id(&scope.name)?; + let chain = scope.chain_id; + + let rt = Runtime::new()?; + rt.block_on(async { + let daemon = DaemonAsync::builder(chain) + .mnemonic(mnemonic) + .build() + .await?; + let address = daemon.sender_addr(); + println!("Your address: {address}"); + let _ = show_addr_explorer(chain.chain_info().clone(), address.as_str()).await; + color_eyre::Result::<(), color_eyre::Report>::Ok(()) + })?; + Ok(ShowAddressOutput) + } +} diff --git a/cw-orch-cli/src/commands/keys/show_key/mod.rs b/cw-orch-cli/src/commands/keys/show_key/mod.rs new file mode 100644 index 000000000..fbd3dd1ed --- /dev/null +++ b/cw-orch-cli/src/commands/keys/show_key/mod.rs @@ -0,0 +1,28 @@ +use crate::types::keys::seed_phrase_for_id; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = ())] +#[interactive_clap(output_context = ShowKeyOutput)] +pub struct ShowKeyCommand { + #[interactive_clap(skip_default_input_arg)] + name: String, +} + +impl ShowKeyCommand { + fn input_name(_: &()) -> color_eyre::eyre::Result> { + crate::common::select_signer() + } +} + +pub struct ShowKeyOutput; + +impl ShowKeyOutput { + fn from_previous_context( + _previous_context: (), + scope:&::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + let phrase = seed_phrase_for_id(&scope.name)?; + println!("your seed phrase: {phrase}"); + Ok(ShowKeyOutput) + } +} diff --git a/cw-orch-cli/src/commands/mod.rs b/cw-orch-cli/src/commands/mod.rs new file mode 100644 index 000000000..b06d8beb4 --- /dev/null +++ b/cw-orch-cli/src/commands/mod.rs @@ -0,0 +1,28 @@ +mod action; +mod address_book; +mod keys; + +pub use action::CosmosContext; + +use strum::{EnumDiscriminants, EnumIter, EnumMessage}; + +use crate::GlobalConfig; + +#[derive(Debug, EnumDiscriminants, Clone, interactive_clap::InteractiveClap)] +#[strum_discriminants(derive(EnumMessage, EnumIter))] +#[interactive_clap(disable_back)] +#[interactive_clap(context = GlobalConfig)] +/// Select one of the options with up-down arrows and press enter to select action +pub enum Commands { + /// Select action + #[strum_discriminants(strum(message = "๐ŸŽฌ Action"))] + Action(action::CosmosCommands), + /// Add, View or Remove key + #[strum_discriminants(strum(message = "๐Ÿ”‘ Keys"))] + Key(keys::KeyCommands), + /// Handle Address Book + #[strum_discriminants(strum(message = "๐Ÿ“– Address Book"))] + AddressBook(address_book::AddressBookCommands), + // TODO: + // 1) Config management +} diff --git a/cw-orch-cli/src/common.rs b/cw-orch-cli/src/common.rs new file mode 100644 index 000000000..62a24d2d3 --- /dev/null +++ b/cw-orch-cli/src/common.rs @@ -0,0 +1,115 @@ +use crate::{fetch::explorers::Explorers, types::CliLockedChain}; +pub use base64::prelude::BASE64_STANDARD as B64; +use cw_orch::{ + daemon::networks::SUPPORTED_NETWORKS as NETWORKS, + environment::{ChainInfo, ChainKind}, +}; +use ibc_chain_registry::fetchable::Fetchable; +use inquire::{error::InquireResult, InquireError, Select}; + +pub fn get_cw_cli_exec_path() -> String { + std::env::args().next().unwrap() +} + +pub fn select_chain() -> color_eyre::eyre::Result> { + let chain_ids: Vec<_> = NETWORKS + .iter() + .map(|network| { + format!( + "{} {}({})", + network.network_info.chain_name.to_uppercase(), + network.kind.to_string().to_uppercase(), + network.chain_id + ) + }) + .collect(); + let selected = Select::new("Select chain", chain_ids).raw_prompt()?; + let locked_chain = CliLockedChain::new(selected.index); + Ok(Some(locked_chain)) +} + +pub fn select_signer() -> color_eyre::eyre::Result> { + let entries_set_result = crate::types::keys::read_entries(); + let signer_id = match entries_set_result { + // We have a file access and it has at least one signer + Ok(entries_set) if !entries_set.entries.is_empty() => { + let options = entries_set.entries.into_iter().collect(); + Select::new("Select signer id", options) + .with_help_message("Use CLI mode to add signer from previous version") + .prompt()? + } + // We don't have access or it's empty + _ => inquire::Text::new("Signer id").prompt()?, + }; + Ok(Some(signer_id)) +} + +pub fn parse_coins() -> InquireResult { + let mut coins = cosmwasm_std::Coins::default(); + loop { + let coin = inquire::Text::new("Add coin to transaction") + .with_help_message("Leave empty to stop adding coins") + .with_placeholder("0ucoin") + .prompt()?; + if !coin.is_empty() { + match coin.parse() { + Ok(c) => coins + .add(c) + .map_err(|e| InquireError::Custom(Box::new(e)))?, + Err(e) => { + println!("Failed to add coin: {e}") + } + } + } else { + break; + } + } + println!("attached coins: {coins}"); + Ok(coins) +} + +#[derive(Clone, Copy, strum::EnumIter, strum::EnumString, derive_more::Display)] +pub enum ExpirationType { + AtHeight, + AtTime, + Never, +} + +impl ExpirationType { + const VARIANTS: &'static [ExpirationType] = &[Self::AtHeight, Self::AtTime, Self::Never]; +} + +pub fn parse_expiration() -> InquireResult { + let locked = inquire::Select::new("Choose expiration type", ExpirationType::VARIANTS.to_vec()) + .prompt()?; + + let expiration = match locked { + ExpirationType::AtHeight => { + let block_height = inquire::CustomType::::new("Input block height").prompt()?; + cw_utils::Expiration::AtHeight(block_height) + } + ExpirationType::AtTime => { + let timestamp_nanos = + inquire::CustomType::::new("Input timestamp in nanos").prompt()?; + let timestamp = cosmwasm_std::Timestamp::from_nanos(timestamp_nanos); + cw_utils::Expiration::AtTime(timestamp) + } + ExpirationType::Never => cw_utils::Expiration::Never {}, + }; + Ok(expiration) +} + +pub async fn show_addr_explorer(chain_info: ChainInfo, addr: &str) -> color_eyre::eyre::Result<()> { + if let ChainKind::Mainnet = chain_info.kind { + let Explorers { explorers } = + Explorers::fetch(chain_info.network_info.chain_name.to_owned(), None).await?; + for explorer in explorers { + if let Some(tx_page) = explorer.account_page { + let url = tx_page.replace("${accountAddress}", addr); + println!("Explorer: {url}"); + break; + } + } + } + Ok(()) +} diff --git a/cw-orch-cli/src/fetch/explorers.rs b/cw-orch-cli/src/fetch/explorers.rs new file mode 100644 index 000000000..0f61879a6 --- /dev/null +++ b/cw-orch-cli/src/fetch/explorers.rs @@ -0,0 +1,26 @@ +use ibc_chain_registry::fetchable::Fetchable; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct Explorers { + pub explorers: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct Explorer { + pub kind: String, + pub url: String, + pub tx_page: Option, + pub account_page: Option, +} + +impl Fetchable for Explorers { + const DESC: &'static str = "Getting explorers list"; + + fn path(resource: &str) -> PathBuf { + [resource, "chain.json"].iter().collect() + } +} diff --git a/cw-orch-cli/src/fetch/mod.rs b/cw-orch-cli/src/fetch/mod.rs new file mode 100644 index 000000000..d918f7f8f --- /dev/null +++ b/cw-orch-cli/src/fetch/mod.rs @@ -0,0 +1 @@ +pub mod explorers; diff --git a/cw-orch-cli/src/lib.rs b/cw-orch-cli/src/lib.rs new file mode 100644 index 000000000..e59997e7e --- /dev/null +++ b/cw-orch-cli/src/lib.rs @@ -0,0 +1,53 @@ +pub mod commands; +pub mod common; +pub mod fetch; +pub(crate) mod log; +pub(crate) mod types; + +use cw_orch::daemon::DEFAULT_DEPLOYMENT; + +#[derive(Debug, Clone, interactive_clap::InteractiveClap)] +#[interactive_clap(input_context = ())] +#[interactive_clap(output_context = GlobalConfig)] +pub struct TLCommand { + /// Verbose mode + #[interactive_clap(short, long, global = true)] + verbose: bool, + /// Source cw-orch state file with address-book + #[interactive_clap(short, long, global = true)] + source_state_file: bool, + /// Deployment id, that will be used for merging cw_orch_state + #[interactive_clap(long, global = true)] + #[interactive_clap(skip_interactive_input)] + deployment_id: Option, + #[interactive_clap(subcommand)] + top_level: commands::Commands, +} + +#[derive(Debug, Clone)] +pub struct GlobalConfig { + source_state_file: bool, + deployment_id: String, +} + +impl GlobalConfig { + fn from_previous_context( + _previous_context: (), + scope: &::InteractiveClapContextScope, + ) -> color_eyre::eyre::Result { + if scope.verbose { + pretty_env_logger::init() + } + Ok(Self { + source_state_file: scope.source_state_file, + deployment_id: scope + .deployment_id + .clone() + .unwrap_or(DEFAULT_DEPLOYMENT.to_owned()), + }) + } +} + +impl From for () { + fn from(_value: GlobalConfig) -> Self {} +} diff --git a/cw-orch-cli/src/log/mod.rs b/cw-orch-cli/src/log/mod.rs new file mode 100644 index 000000000..cf1e1b258 --- /dev/null +++ b/cw-orch-cli/src/log/mod.rs @@ -0,0 +1,37 @@ +use cw_orch::{ + daemon::CosmTxResponse, + environment::{ChainInfo, ChainKind}, + tokio::runtime::Runtime, +}; +use ibc_chain_registry::fetchable::Fetchable; + +use crate::fetch::explorers::Explorers; + +pub trait LogOutput { + fn log(&self, chain_info: &ChainInfo); +} + +impl LogOutput for CosmTxResponse { + fn log(&self, chain_info: &ChainInfo) { + println!("Transaction hash: {}", self.txhash); + if let ChainKind::Mainnet = chain_info.kind { + let log_explorer_url = || -> cw_orch::anyhow::Result<()> { + let rt = Runtime::new()?; + let Explorers { explorers } = rt.block_on(Explorers::fetch( + chain_info.network_info.chain_name.to_owned(), + None, + ))?; + for explorer in explorers { + if let Some(tx_page) = explorer.tx_page { + let url = tx_page.replace("${txHash}", &self.txhash); + println!("Explorer: {url}"); + break; + } + } + Ok(()) + }; + // Ignore any errors + let _ = log_explorer_url(); + } + } +} diff --git a/cw-orch-cli/src/main.rs b/cw-orch-cli/src/main.rs new file mode 100644 index 000000000..1830dad11 --- /dev/null +++ b/cw-orch-cli/src/main.rs @@ -0,0 +1,46 @@ +use cw_orch::daemon::env::LOGS_ACTIVATION_MESSAGE_ENV_NAME; +use cw_orch_cli::{common, TLCommand}; + +use inquire::ui::{Attributes, RenderConfig, StyleSheet}; +use interactive_clap::{ResultFromCli, ToCliArgs}; + +fn main() -> color_eyre::Result<()> { + // We don't want to see cw-orch logs during cli + std::env::set_var(LOGS_ACTIVATION_MESSAGE_ENV_NAME, "false"); + let render_config = RenderConfig { + prompt: StyleSheet::new().with_attr(Attributes::BOLD), + ..Default::default() + }; + inquire::set_global_render_config(render_config); + // TODO: add some configuration like default chain/signer/etc + let cli_args = TLCommand::parse(); + + let cw_cli_path = common::get_cw_cli_exec_path(); + let args = ::from_cli(Some(cli_args.clone()), ()); + + match args { + interactive_clap::ResultFromCli::Ok(cli_args) | ResultFromCli::Cancel(Some(cli_args)) => { + println!( + "Your console command: {}", + shell_words::join(std::iter::once(cw_cli_path).chain(cli_args.to_cli_args())) + ); + Ok(()) + } + interactive_clap::ResultFromCli::Cancel(None) => { + println!("Goodbye!"); + Ok(()) + } + interactive_clap::ResultFromCli::Back => { + unreachable!("TLCommand does not have back option"); + } + interactive_clap::ResultFromCli::Err(cli_args, err) => { + if let Some(cli_args) = cli_args { + println!( + "\nYour console command: {}", + shell_words::join(std::iter::once(cw_cli_path).chain(cli_args.to_cli_args())) + ); + } + Err(err) + } + } +} diff --git a/cw-orch-cli/src/types/address_book.rs b/cw-orch-cli/src/types/address_book.rs new file mode 100644 index 000000000..1e7d5750d --- /dev/null +++ b/cw-orch-cli/src/types/address_book.rs @@ -0,0 +1,389 @@ +use crate::GlobalConfig; + +use super::cli_subdir::cli_path; + +// TODO: Three modes +// - Only alias (will allow making dropdown for addresses) +// - Only raw address +// - Hybrid (current) + +const ADDRESS_BOOK_FILENAME: &str = "address_book.json"; +pub const CW_ORCH_STATE_FILE_DAMAGED_ERROR: &str = "cw-orch state file is corrupted"; + +use std::{ + fs::{File, OpenOptions}, + path::PathBuf, + str::FromStr, +}; + +use color_eyre::eyre::Context; +use cosmrs::AccountId; +use cw_orch::environment::ChainInfo; +use serde_json::{json, Value}; + +fn address_book_path() -> color_eyre::Result { + Ok(cli_path()?.join(ADDRESS_BOOK_FILENAME)) +} + +/// Get account id only from address-book +pub fn get_account_id_address_book( + chain: &ChainInfo, + name_alias: &str, +) -> color_eyre::Result> { + let address_book_file = address_book_path()?; + let chain_id = chain.chain_id; + // open file pointer set read permissions to true + let file_result = OpenOptions::new() + .read(true) + .open(address_book_file.as_path()); + let file = match file_result { + Ok(file) => file, + // Unable to read/open file + Err(_) => return Ok(None), + }; + + let json: Value = serde_json::from_reader(file)?; + + if let Some(address) = json.get(chain_id).and_then(|chain| chain.get(name_alias)) { + if let Some(Ok(account_id)) = address.as_str().map(AccountId::from_str) { + Ok(Some(account_id)) + } else { + Err(color_eyre::eyre::eyre!( + "Address Book file is damaged. Unable to read address for the [{name_alias}] alias" + )) + } + } else { + Ok(None) + } +} + +/// Get account id from both address-book and cw-orch state(if merging enabled) +pub fn get_account_id( + chain: &ChainInfo, + global_config: &GlobalConfig, + name_alias: &str, +) -> color_eyre::Result> { + let account_id_address_book = get_account_id_address_book(chain, name_alias)?; + // If address found in address book or cw-orch state sourcing disabled - no need to read cw orch state + if account_id_address_book.is_some() || !global_config.source_state_file { + return Ok(account_id_address_book); + } + // Try to load cw orch state contract + let cw_orch_contracts = cw_orch_state_contracts(chain, &global_config.deployment_id)?; + if let Some(contract) = cw_orch_contracts.get(name_alias) { + let contract_addr = contract + .as_str() + .ok_or(color_eyre::eyre::eyre!(CW_ORCH_STATE_FILE_DAMAGED_ERROR))?; + // Ignore parse error, cw-orch can store non bech32 addresses which CLI does not support + Ok(AccountId::from_str(contract_addr).ok()) + } else { + Ok(None) + } +} + +pub fn insert_account_id( + chain_id: &str, + name_alias: &str, + address: &str, +) -> color_eyre::Result { + // Before doing anything - validate if address is valid + let account_id = AccountId::from_str(address)?; + let address_book_file = address_book_path()?; + // open file pointer set read/write permissions to true + // create it if it does not exists + // don't truncate it + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(address_book_file.as_path())?; + // return empty json object if file is empty + // return file content if not + let mut json: Value = if file.metadata()?.len().eq(&0) { + json!({}) + } else { + serde_json::from_reader(&file)? + }; + + // check and add chain_id path if it's missing + if json.get(chain_id).is_none() { + json[chain_id] = json!({ + name_alias: account_id + }); + } else { + json[chain_id][name_alias] = json!(account_id); + } + + // write JSON data + // use File::rewind so we don't append data to the file + // but rather write all (because we have read the data before) + serde_json::to_writer_pretty(File::create(address_book_file)?, &json)?; + + Ok(account_id) +} + +pub fn try_insert_account_id( + chain: &ChainInfo, + alias: &str, + address: &str, +) -> color_eyre::eyre::Result<()> { + let maybe_account_id = get_account_id_address_book(chain, alias)?; + + if let Some(account_id) = maybe_account_id { + let confirmed = + inquire::Confirm::new(&format!("Override {}({account_id})?", alias)).prompt()?; + if confirmed { + return Ok(()); + } + } + + let new_address = insert_account_id(chain.chain_id, alias, address)?; + println!("Wrote successfully:\n{}:{}", alias, new_address); + Ok(()) +} + +pub fn remove_account_id(chain_id: &str, name_alias: &str) -> color_eyre::Result> { + let address_book_file = address_book_path()?; + // open file pointer set read/write permissions to true + // create it if it does not exists + // don't truncate it + let file = OpenOptions::new() + .read(true) + .write(true) + .truncate(false) + .open(address_book_file.as_path())?; + + let mut json: serde_json::Map = serde_json::from_reader(file)?; + let aliases_map = match json.get_mut(chain_id) { + Some(aliases) => aliases.as_object_mut().unwrap(), + None => return Ok(None), + }; + let removed = aliases_map.remove(name_alias); + if aliases_map.is_empty() { + // Last alias - remove chain entry + json.remove(chain_id); + } + // write JSON data + // use File::create so we don't append data to the file + // but rather write all (because we have read the data before) + serde_json::to_writer_pretty(File::create(address_book_file)?, &json)?; + Ok(removed) +} + +pub fn get_or_prompt_account_id( + chain: &ChainInfo, + global_config: &GlobalConfig, + name_alias: &str, +) -> color_eyre::Result { + let address_book_file = address_book_path()?; + // open file pointer set read/write permissions to true + // create it if it does not exists + // don't truncate it + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(address_book_file.as_path())?; + // return empty json object if file is empty + // return file content if not + let mut json: Value = if file.metadata()?.len().eq(&0) { + json!({}) + } else { + serde_json::from_reader(file)? + }; + + // check and add chain_id path if it's missing + if json.get(chain.chain_id).is_none() { + json[chain.chain_id] = json!({}); + } + + // Try to retrieve existing alias + if let Some(address) = json[chain.chain_id].get(name_alias) { + return if let Some(Ok(account_id)) = address.as_str().map(AccountId::from_str) { + Ok(account_id) + } else { + Err(color_eyre::eyre::eyre!( + "Address Book file is damaged. Unable to read address for the [{name_alias}] alias" + )) + }; + } + // Try to retrieve from cw-orch state if merging enabled + if global_config.source_state_file { + let cw_orch_contracts = cw_orch_state_contracts(chain, &global_config.deployment_id)?; + if let Some(contract) = cw_orch_contracts.get(name_alias) { + let contract_addr = contract + .as_str() + .ok_or(color_eyre::eyre::eyre!(CW_ORCH_STATE_FILE_DAMAGED_ERROR))?; + // Ignore parse error, cw-orch can store non bech32 addresses which CLI does not support + if let Ok(account_id) = AccountId::from_str(contract_addr) { + return Ok(account_id); + } + } + } + + // add name alias to chain_id path + let message = format!("Write down the address for the [{name_alias}] alias"); + let account_id = loop { + let address = inquire::Text::new(&message).prompt()?; + if let Ok(account_id) = cosmrs::AccountId::from_str(&address) { + break account_id; + } + + eprintln!("Failed to parse bech32 address"); + }; + + json[chain.chain_id][name_alias] = json!(account_id); + + // write JSON data + // use File::create so we don't append data to the file + // but rather write all (because we have read the data before) + serde_json::to_writer_pretty(File::create(address_book_file)?, &json).unwrap(); + Ok(account_id) +} + +pub fn select_alias( + chain_info: &ChainInfo, + global_config: &GlobalConfig, +) -> color_eyre::eyre::Result> { + let chain_id = chain_info.chain_id; + + let cw_orch_contracts = if global_config.source_state_file { + cw_orch_state_contracts(chain_info, &global_config.deployment_id)? + } else { + Default::default() + }; + + let address_book_file = address_book_path()?; + + let file = OpenOptions::new() + .read(true) + .open(address_book_file.as_path()) + .map_err(|_| color_eyre::eyre::eyre!("Must have at least one address in address book"))?; + + let json: Value = serde_json::from_reader(file)?; + let chain_map = json + .as_object() + .ok_or(color_eyre::eyre::eyre!("Address Book file is damaged."))?; + let alias_map = match chain_map.get(chain_id) { + Some(aliases) => aliases.as_object().unwrap().clone(), + None => Default::default(), + }; + let aliases: Vec<_> = alias_map.keys().chain(cw_orch_contracts.keys()).collect(); + if aliases.is_empty() { + return Err(color_eyre::eyre::eyre!("Aliases for {chain_id} is empty")); + } + let chosen = inquire::Select::new("Select Address Alias", aliases).prompt()?; + Ok(Some(chosen.to_owned())) +} + +fn read_cw_orch_state() -> color_eyre::Result { + let state_file = cw_orch::daemon::DaemonState::state_file_path()?; + + let file = + File::open(&state_file).context(format!("File should be present at {state_file}"))?; + let json: Value = serde_json::from_reader(file)?; + Ok(json) +} + +pub fn cw_orch_state_contracts( + chain: &ChainInfo, + deployment_id: &str, +) -> color_eyre::Result> { + let chain_name = chain.network_info.chain_name; + let chain_id = chain.chain_id; + + let json = read_cw_orch_state()?; + + let chain_state = if let Some(chain_state) = json.get(chain_name) { + // In case old state + // TODO: should be able to remove in the future + chain_state + } else { + &json + }; + + let Some(chain_id_state) = chain_state.get(chain_id) else { + return Err(color_eyre::eyre::eyre!("State is empty for {chain_id}")); + }; + + let Some(deployment) = chain_id_state.get(deployment_id) else { + return Err(color_eyre::eyre::eyre!( + "State is empty for {chain_id}.{deployment_id}" + )); + }; + + let contracts = deployment + .as_object() + .ok_or(color_eyre::eyre::eyre!(CW_ORCH_STATE_FILE_DAMAGED_ERROR))?; + Ok(contracts.clone()) +} +/// Address or alias to the address +#[derive(Debug, Clone)] +pub enum Address { + Bech32(AccountId), + Alias(String), +} + +impl Address { + // TODO: handle CLI config + pub fn new(bech_or_addr: String, chain_info: &ChainInfo) -> color_eyre::Result { + match cosmrs::AccountId::from_str(&bech_or_addr) { + // Raw address + Ok(account_id) => { + if account_id.prefix() != chain_info.network_info.pub_address_prefix { + // Not recoverable at this point assuming user chose wrong chain + Err(color_eyre::eyre::eyre!( + "Prefix of bech32 address don't match for {}, expected_prefix: {}", + chain_info.chain_id, + chain_info.network_info.pub_address_prefix + )) + } else { + Ok(Address::Bech32(account_id)) + } + } + // Name alias + Err(_) => Ok(Address::Alias(bech_or_addr)), + } + } +} + +#[derive(Debug, Clone, derive_more::AsRef, derive_more::From, derive_more::Into)] +#[as_ref(forward)] +pub struct CliAddress(String); + +impl FromStr for CliAddress { + type Err = String; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Err("Address alias can't be empty".to_owned()); + } + + Ok(Self(s.to_owned())) + } +} + +impl interactive_clap::ToCli for CliAddress { + type CliVariant = CliAddress; +} + +impl CliAddress { + pub fn account_id( + self, + chain_info: &ChainInfo, + global_config: &GlobalConfig, + ) -> color_eyre::Result { + match Address::new(self.0, chain_info)? { + Address::Bech32(account_id) => Ok(account_id), + Address::Alias(alias) => get_or_prompt_account_id(chain_info, global_config, &alias), + } + } +} + +impl std::fmt::Display for CliAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/cw-orch-cli/src/types/chain.rs b/cw-orch-cli/src/types/chain.rs new file mode 100644 index 000000000..35530b767 --- /dev/null +++ b/cw-orch-cli/src/types/chain.rs @@ -0,0 +1,60 @@ +use std::str::FromStr; + +use cw_orch::{ + daemon::{ + networks::SUPPORTED_NETWORKS, senders::QueryOnlyDaemon, Daemon, DaemonBuilder, DaemonError, + }, + environment::{ChainInfo, ChainInfoOwned}, +}; + +#[derive(Default, Debug, Clone, Copy)] +pub struct CliLockedChain(usize); + +impl CliLockedChain { + pub fn new(index: usize) -> Self { + CliLockedChain(index) + } + + pub fn chain_info(&self) -> &ChainInfo { + &SUPPORTED_NETWORKS[self.0] + } + + pub fn daemon(&self, seed: String) -> Result { + DaemonBuilder::new(SUPPORTED_NETWORKS[self.0].clone()) + .mnemonic(seed) + .build() + } + + pub fn daemon_querier(&self) -> Result { + DaemonBuilder::new(SUPPORTED_NETWORKS[self.0].clone()).build_sender(()) + } +} + +impl From for ChainInfoOwned { + fn from(value: CliLockedChain) -> Self { + SUPPORTED_NETWORKS[value.0].clone().into() + } +} + +impl FromStr for CliLockedChain { + type Err = String; + + // Just parse chain id + fn from_str(s: &str) -> Result { + SUPPORTED_NETWORKS + .iter() + .position(|c| c.chain_id == s) + .map(CliLockedChain::new) + .ok_or("Unknown network".to_owned()) + } +} + +impl ::std::fmt::Display for CliLockedChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", SUPPORTED_NETWORKS[self.0].chain_id) + } +} + +impl interactive_clap::ToCli for CliLockedChain { + type CliVariant = CliLockedChain; +} diff --git a/cw-orch-cli/src/types/cli_subdir.rs b/cw-orch-cli/src/types/cli_subdir.rs new file mode 100644 index 000000000..536e8ea88 --- /dev/null +++ b/cw-orch-cli/src/types/cli_subdir.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +use cw_orch::env_vars::default_state_folder; + +pub const CLI_FOLDER: &str = "cli"; + +pub fn cli_path() -> color_eyre::Result { + let cli_path = default_state_folder()?.join(CLI_FOLDER); + std::fs::create_dir_all(cli_path.as_path())?; + Ok(cli_path) +} diff --git a/cw-orch-cli/src/types/coins.rs b/cw-orch-cli/src/types/coins.rs new file mode 100644 index 000000000..365b3c049 --- /dev/null +++ b/cw-orch-cli/src/types/coins.rs @@ -0,0 +1,35 @@ +#[derive(Default, PartialEq, Eq, Debug, Clone, derive_more::FromStr)] +pub struct CliCoins(pub cosmwasm_std::Coins); + +impl TryFrom<&CliCoins> for Vec { + type Error = color_eyre::Report; + + fn try_from(value: &CliCoins) -> Result { + value + .0 + .iter() + .map(|cosmwasm_std::Coin { amount, denom }| { + Ok(cosmrs::Coin { + amount: amount.u128(), + denom: denom.parse()?, + }) + }) + .collect() + } +} + +impl From for Vec { + fn from(value: CliCoins) -> Self { + value.0.into() + } +} + +impl std::fmt::Display for CliCoins { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl interactive_clap::ToCli for CliCoins { + type CliVariant = CliCoins; +} diff --git a/cw-orch-cli/src/types/expiration.rs b/cw-orch-cli/src/types/expiration.rs new file mode 100644 index 000000000..d6fa874ca --- /dev/null +++ b/cw-orch-cli/src/types/expiration.rs @@ -0,0 +1,42 @@ +use std::str::FromStr; + +use cosmwasm_std::Timestamp; +use cw_utils::Expiration; + +#[derive(Debug, Default, Clone, derive_more::AsRef, derive_more::From, derive_more::Into)] +#[as_ref(forward)] +pub struct CliExpiration(pub Expiration); + +impl std::fmt::Display for CliExpiration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl interactive_clap::ToCli for CliExpiration { + type CliVariant = CliExpiration; +} + +impl FromStr for CliExpiration { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + if s == "never" { + return Ok(Self(Expiration::Never {})); + } + let (t, num) = s.split_once(':').ok_or("Incorrect expiration format")?; + let expiration = match t { + "height" => { + let height: u64 = num.parse().map_err(|_| "Failed to parse height")?; + Expiration::AtHeight(height) + } + "time" => { + let timestamp: u64 = num.parse().map_err(|_| "Failed to parse timestamp")?; + Expiration::AtTime(Timestamp::from_nanos(timestamp)) + } + _ => return Err("Unknown expiration type".to_owned()), + }; + Ok(CliExpiration(expiration)) + } +} diff --git a/cw-orch-cli/src/types/keys.rs b/cw-orch-cli/src/types/keys.rs new file mode 100644 index 000000000..d21484137 --- /dev/null +++ b/cw-orch-cli/src/types/keys.rs @@ -0,0 +1,106 @@ +use crate::common::B64; + +use base64::Engine; +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs::{File, OpenOptions}, + path::PathBuf, +}; + +use super::cli_subdir::cli_path; + +// Should be possible to remove this file in a feature. +// Tracking issue: https://github.com/hwchen/keyring-rs/issues/144 +const ENTRIES_LIST_FILE: &str = "keys_entries.json"; + +#[derive(Default, Serialize, Deserialize)] +pub struct EntriesSet { + pub entries: BTreeSet, +} + +fn entries_list_path() -> color_eyre::Result { + Ok(cli_path()?.join(ENTRIES_LIST_FILE)) +} + +pub fn read_entries() -> color_eyre::Result { + let entries_list_file = entries_list_path()?; + + let maybe_file = OpenOptions::new() + .read(true) + .open(entries_list_file.as_path()); + // In case no file return empty + let Ok(file) = maybe_file else { + return Ok(Default::default()); + }; + let entries_set: EntriesSet = serde_json::from_reader(file)?; + Ok(entries_set) +} + +pub fn save_entry_if_required(entry: &str) -> color_eyre::Result<()> { + let entries_list_file = entries_list_path()?; + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(entries_list_file.as_path())?; + let mut entries_set: EntriesSet = if file.metadata()?.len().eq(&0) { + Default::default() + } else { + serde_json::from_reader(&file)? + }; + let need_to_write = entries_set.entries.insert(entry.to_owned()); + + if need_to_write { + // write JSON data + // use File::rewind so we don't append data to the file + // but rather write all (because we have read the data before) + + serde_json::to_writer_pretty(File::create(entries_list_file)?, &entries_set)?; + } + + Ok(()) +} + +pub fn remove_entry(entry: &str) -> color_eyre::Result<()> { + let entries_list_file = entries_list_path()?; + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(entries_list_file.as_path())?; + let mut entries_set: EntriesSet = if file.metadata()?.len().eq(&0) { + Default::default() + } else { + serde_json::from_reader(&file)? + }; + let need_to_write = entries_set.entries.remove(entry); + + if need_to_write { + // write JSON data + // use File::rewind so we don't append data to the file + // but rather write all (because we have read the data before) + + serde_json::to_writer_pretty(File::create(entries_list_file)?, &entries_set)?; + } + + Ok(()) +} + +pub fn entry_for_seed(name: &str) -> keyring::Result { + Entry::new("cw-cli", name) +} + +pub fn seed_phrase_for_id(name: &str) -> color_eyre::Result { + let entry = entry_for_seed(name)?; + let password = entry.get_password()?; + // Found password - so we can save entry + save_entry_if_required(name)?; + let phrase = String::from_utf8(B64.decode(password)?)?; + Ok(phrase) +} diff --git a/cw-orch-cli/src/types/mod.rs b/cw-orch-cli/src/types/mod.rs new file mode 100644 index 000000000..83ee000d6 --- /dev/null +++ b/cw-orch-cli/src/types/mod.rs @@ -0,0 +1,15 @@ +pub mod address_book; +mod chain; +pub mod cli_subdir; +mod coins; +mod expiration; +pub mod keys; +mod path_buf; +mod skippable; + +pub use address_book::CliAddress; +pub use chain::CliLockedChain; +pub use coins::CliCoins; +pub use expiration::CliExpiration; +pub use path_buf::PathBuf; +pub use skippable::CliSkippable; diff --git a/cw-orch-cli/src/types/path_buf.rs b/cw-orch-cli/src/types/path_buf.rs new file mode 100644 index 000000000..eb85c4b86 --- /dev/null +++ b/cw-orch-cli/src/types/path_buf.rs @@ -0,0 +1,21 @@ +#[derive( + Debug, + Default, + Clone, + derive_more::AsRef, + derive_more::From, + derive_more::Into, + derive_more::FromStr, +)] +#[as_ref(forward)] +pub struct PathBuf(pub std::path::PathBuf); + +impl std::fmt::Display for PathBuf { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.display()) + } +} + +impl interactive_clap::ToCli for PathBuf { + type CliVariant = PathBuf; +} diff --git a/cw-orch-cli/src/types/skippable.rs b/cw-orch-cli/src/types/skippable.rs new file mode 100644 index 000000000..8009f5242 --- /dev/null +++ b/cw-orch-cli/src/types/skippable.rs @@ -0,0 +1,33 @@ +#[derive(Default, Debug, Clone)] +pub struct CliSkippable(pub Option); + +impl std::fmt::Display for CliSkippable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0 { + Some(s) => s.fmt(f), + None => Ok(()), + } + } +} + +impl std::str::FromStr for CliSkippable +where + T: std::str::FromStr, + T::Err: std::fmt::Debug, +{ + type Err = String; + fn from_str(s: &str) -> Result { + if s.is_empty() { + Ok(CliSkippable(None)) + } else { + match T::from_str(s) { + Ok(output) => Ok(CliSkippable(Some(output))), + Err(e) => Err(format!("{e:?}")), + } + } + } +} + +impl interactive_clap::ToCli for CliSkippable { + type CliVariant = CliSkippable; +} diff --git a/cw-orch-daemon/src/state.rs b/cw-orch-daemon/src/state.rs index 97b9a91f5..d84dd93e9 100644 --- a/cw-orch-daemon/src/state.rs +++ b/cw-orch-daemon/src/state.rs @@ -60,7 +60,6 @@ pub enum DaemonStateFile { impl DaemonState { /// Creates a new state from the given chain data and deployment id. - /// Attempts to connect to any of the provided gRPC endpoints. pub fn new( mut json_file_path: String, chain_data: &Arc, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9f3aeb3fe..723fa3aad 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -49,6 +49,9 @@ - [Union](./chains/union.md) - [XION](./chains/xion.md) +- [CLI](./cli/index.md) + - [Keys](./cli/keys.md) + # Extras - [CI/CD](./ci-cd.md) diff --git a/docs/src/cli/cosmos_action.md b/docs/src/cli/cosmos_action.md new file mode 100644 index 000000000..5a89c400b --- /dev/null +++ b/docs/src/cli/cosmos_action.md @@ -0,0 +1,47 @@ +# Cosmos Action + +This command allows to perform an action on cosmos chain, if this action requires signing given [Signer](./keys.md) would be used + +## Chain id + +For doing any cosmos action chain id is needed, so it have to be provided before selecting any of the subcommand + +## Features + +### CosmWasm Action + +Interact with CosmWasm smart contract + +- Store smart contract: `cw-orch-cli action [CHAIN_ID] cw store [WASM_PATH] [SIGNER]` +- Instantiate smart contract: `cw-orch-cli action [CHAIN_ID] cw instantiate [CODE_ID] [MSG_TYPE] [MSG] [LABEL] [ADMIN] [COINS] [SIGNER]` +- Execute smart contract method: `cw-orch-cli action [CHAIN_ID] cw execute [OPTIONS] [CONTRACT_ADDR] [MSG_TYPE] [MSG] [COINS] [SIGNER]` +- Query smart contract: + - Smart query: `cw-orch-cli action [CHAIN_ID] cw query smart [OPTIONS] [CONTRACT] [MSG_TYPE] [MSG]` + - Raw state query: `cw-orch-cli action [CHAIN_ID] cw query raw [OPTIONS] [CONTRACT] [KEY_TYPE] [KEY]` + - [KEY_TYPE] supports 2 types: `raw`, `base64`(for non-human-readable keys) + +### Asset Action + +Send or query assets on cosmos chain + +- Send native or factory coin: `cw-orch-cli action [CHAIN_ID] asset send-native [OPTIONS] [COINS] [TO_ADDRESS] [SIGNER]` +- Send cw20 coin: `cw-orch-cli action [CHAIN_ID] asset send-cw20 [OPTIONS] [CW20_ADDRESS] [AMOUNT] [TO_ADDRESS] [SIGNER]` +- Query native or factory coin balance: `cw-orch-cli action [CHAIN_ID] asset query-native [OPTIONS] [DENOM] [ADDRESS]` + - For querying all balances use empty string ("") instead of [DENOM] +- Query cw20 balance: `cw-orch-cli action [CHAIN_ID] asset query-cw20 [OPTIONS] [CW20_ADDRESS] [ADDRESS]` + +### CW-Ownable Action + +Interact with cw-ownable controller on CosmWasm smart contract + +- Propose to transfer contract ownership to another address: `cw-orch-cli action [CHAIN_ID] cw-ownable transfer [OPTIONS] [CONTRACT] [NEW_OWNER] [EXPIRATION] [SIGNER] [NEW_SIGNER]` + - [EXPIRATION] supports three variants: `never`, `height:{block_height}`, `time:{time_nanos}` + - If you cannot sign [NEW_SIGNER] use empty string("") instead +- Accept pending ownership: `cw-orch-cli action [CHAIN_ID] cw-ownable accept [OPTIONS] [CONTRACT] [SIGNER]` +- Renounce pending ownership: `cw-orch-cli action [CHAIN_ID] cw-ownable renounce [OPTIONS] [CONTRACT] [SIGNER]` +- Get current ownership: `cw-orch-cli action [CHAIN_ID] cw-ownable get [OPTIONS] [CONTRACT]` + +#### Arguments reference + +- [MSG_TYPE] is a format for provided `[MSG]`, possible values: `json-msg`, `base64-msg`, `file`, `editor` +- [COINS] formatted and parsed same way as `cosmwasm_std::Coins`, for example: "5ujunox,15utestx" diff --git a/docs/src/cli/index.md b/docs/src/cli/index.md new file mode 100644 index 000000000..26afdb3ad --- /dev/null +++ b/docs/src/cli/index.md @@ -0,0 +1,23 @@ +# Orchestrator Command Line Interface (CLI) + +Currently, each chain has its own CLI based on wasmd, which are incompatible with each other. With this in mind, we created cw-orch-cli. cw-orchestrator allows for easy chain switching, which is essential for cross-chain solutions. + +## Prerequisites + +- Rust +- OpenSSL + +## Setup + +```bash +cargo install cw-orch-cli +``` + +## Features + +Supported features of cw-orch-cli: + +- **[Keys management](./keys.md)**: Add, show or remove key for the CLI +- **[Action](./cosmos_action.md)**: Perform cosmos action + +Feel free to request new features by [opening an issue](https://github.com/AbstractSDK/cw-orchestrator/issues/new)! diff --git a/docs/src/cli/keys.md b/docs/src/cli/keys.md new file mode 100644 index 000000000..e90c5f847 --- /dev/null +++ b/docs/src/cli/keys.md @@ -0,0 +1,35 @@ +# Key management + +To sign transactions, you need to have a stored key in the keyring. This is currently the only way to sign transactions, feel free to request other signing methods. + +## Safety + +The keys are kept in an underlying platform-specific secure store(keyring) as seeds. To support different derivation paths we can't save it as key pair + +## Features + +### Add key + +Add key command saves provided or generated seed to the keyring: + +- Generate new random seed : `cw-orch-cli key add [NAME] new` +- Recover from seed phrase: `cw-orch-cli key add [NAME] from-seed` + - This command will give you prompt for your mnemonic + +### Show seed of saved key + +Show seed command loads saved seed phrase from the keyring and outputs it: + +- Shows seed phrase of the key: `cw-orch-cli key show [NAME]` + +### Show address + +Show address command generates public address for this key on chosen network: + +- Show address: `cw-orch-cli key show-address [NAME] [CHAIN_ID]` + +### Remove key + +Remove key command deletes entry of provided key-id from the keyring: + +- Remove saved key: `cw-orch-cli key remove [NAME]`