From bdd14c37ee63a2c9f8693243549880773b70e2ac Mon Sep 17 00:00:00 2001 From: Sophie Dankel <47993817+sdankel@users.noreply.github.com> Date: Wed, 24 Jul 2024 16:56:59 -0700 Subject: [PATCH] feat: Enhance forc-deploy UX with dialoguer (#6278) ## Description Improves the UX for forc-deploy in the following ways: - uses `dialoguer` for a nicer interface for entering password, selecting the wallet account from the list, and agreeing to sign. - displays the account information in a single line, with the ETH value shown rather than the raw gwei, similar to the browser wallet - only shows the base asset amount for accounts, rather than all assets, since only base asset can be used for gas fees. - for multiple-contract deployments, users now only have to choose the account and confirm once - added error handling for the case where multi-contract deployments have different networks specified in their manifests - Displays the network URL *before* deployment rather than after - After deployment, links to the contract and block in the block explorer rather than just showing the ID ### Single contract deployed ![Jul-17-2024 12-29-53](https://github.com/user-attachments/assets/f9ac8dbe-4473-4c71-95fa-7df758c550d8) ### Multiple contracts deployed (workspace) ![Jul-17-2024 12-33-11](https://github.com/user-attachments/assets/0270c05f-2495-4d90-a8e4-773cb1cd96b5) ## Checklist - [ ] I have linked to any relevant issues. - [ ] I have commented my code, particularly in hard-to-understand areas. - [ ] I have updated the documentation where relevant (API docs, the reference, and the Sway book). - [ ] If my change requires substantial documentation changes, I have [requested support from the DevRel team](https://github.com/FuelLabs/devrel-requests/issues/new/choose) - [ ] I have added tests that prove my fix is effective or that my feature works. - [ ] I have added (or requested a maintainer to add) the necessary `Breaking*` or `New Feature` labels where relevant. - [ ] I have done my best to ensure that my PR adheres to [the Fuel Labs Code Review Standards](https://github.com/FuelLabs/rfcs/blob/master/text/code-standards/external-contributors.md). - [ ] I have requested a review from the relevant team or maintainers. --- Cargo.lock | 51 ++++- forc-plugins/forc-client/Cargo.toml | 2 + forc-plugins/forc-client/src/constants.rs | 8 +- forc-plugins/forc-client/src/op/deploy.rs | 183 ++++++++++++------ forc-plugins/forc-client/src/op/run/mod.rs | 3 +- forc-plugins/forc-client/src/util/target.rs | 10 +- forc-plugins/forc-client/src/util/tx.rs | 153 ++++++++++----- .../test/data/contract_with_dep/Forc.lock | 19 ++ .../test/data/contract_with_dep/Forc.toml | 2 +- .../Forc.toml | 2 +- .../test/data/standalone_contract/Forc.lock | 13 ++ .../test/data/standalone_contract/Forc.toml | 2 +- .../test/data/standalone_contract_b/Forc.toml | 2 +- forc-plugins/forc-client/tests/deploy.rs | 49 ++++- 14 files changed, 374 insertions(+), 125 deletions(-) create mode 100644 forc-plugins/forc-client/test/data/contract_with_dep/Forc.lock create mode 100644 forc-plugins/forc-client/test/data/standalone_contract/Forc.lock diff --git a/Cargo.lock b/Cargo.lock index 86adc2fb162..9711886be94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,6 +834,12 @@ dependencies = [ "unreachable", ] +[[package]] +name = "comma" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" + [[package]] name = "common-multipart-rfc7578" version = "0.6.0" @@ -897,6 +903,7 @@ dependencies = [ "encode_unicode 0.3.6", "lazy_static", "libc", + "unicode-width", "windows-sys 0.52.0", ] @@ -1412,6 +1419,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "diff" version = "0.1.13" @@ -2014,6 +2034,7 @@ dependencies = [ "chrono", "clap 4.5.9", "devault", + "dialoguer", "forc", "forc-pkg", "forc-tracing 0.62.0", @@ -2032,6 +2053,7 @@ dependencies = [ "hex", "portpicker", "rand", + "rexpect 0.5.0", "rpassword", "serde", "serde_json", @@ -2086,7 +2108,7 @@ dependencies = [ "fuel-vm", "portpicker", "rayon", - "rexpect", + "rexpect 0.4.0", "serde", "serde_json", "shellfish", @@ -4317,6 +4339,20 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.2.1", + "cfg-if 1.0.0", + "libc", + "memoffset 0.6.5", + "pin-utils", +] + [[package]] name = "nix" version = "0.26.4" @@ -5598,6 +5634,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "rexpect" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01ff60778f96fb5a48adbe421d21bf6578ed58c0872d712e7e08593c195adff8" +dependencies = [ + "comma", + "nix 0.25.1", + "regex", + "tempfile", + "thiserror", +] + [[package]] name = "rfc6979" version = "0.4.0" diff --git a/forc-plugins/forc-client/Cargo.toml b/forc-plugins/forc-client/Cargo.toml index 46773485842..285462715a9 100644 --- a/forc-plugins/forc-client/Cargo.toml +++ b/forc-plugins/forc-client/Cargo.toml @@ -14,6 +14,7 @@ async-trait = "0.1.58" chrono = { version = "0.4", default-features = false, features = ["std"] } clap = { version = "4.5.4", features = ["derive", "env"] } devault = "0.1" +dialoguer = "0.11" forc = { version = "0.62.0", path = "../../forc" } forc-pkg = { version = "0.62.0", path = "../../forc-pkg" } forc-tracing = { version = "0.62.0", path = "../../forc-tracing" } @@ -42,6 +43,7 @@ tracing = "0.1" [dev-dependencies] portpicker = "0.1.1" +rexpect = "0.5" tempfile = "3" toml_edit = "0.21.1" diff --git a/forc-plugins/forc-client/src/constants.rs b/forc-plugins/forc-client/src/constants.rs index 927323a57b3..f6e1faa4201 100644 --- a/forc-plugins/forc-client/src/constants.rs +++ b/forc-plugins/forc-client/src/constants.rs @@ -4,14 +4,18 @@ pub const BETA_2_ENDPOINT_URL: &str = "https://node-beta-2.fuel.network"; pub const BETA_3_ENDPOINT_URL: &str = "https://beta-3.fuel.network"; pub const BETA_4_ENDPOINT_URL: &str = "https://beta-4.fuel.network"; pub const BETA_5_ENDPOINT_URL: &str = "https://beta-5.fuel.network"; +pub const DEVNET_ENDPOINT_URL: &str = "https://devnet.fuel.network"; +pub const TESTNET_ENDPOINT_URL: &str = "https://testnet.fuel.network"; + pub const BETA_2_FAUCET_URL: &str = "https://faucet-beta-2.fuel.network"; pub const BETA_3_FAUCET_URL: &str = "https://faucet-beta-3.fuel.network"; pub const BETA_4_FAUCET_URL: &str = "https://faucet-beta-4.fuel.network"; pub const BETA_5_FAUCET_URL: &str = "https://faucet-beta-5.fuel.network"; pub const DEVNET_FAUCET_URL: &str = "https://faucet-devnet.fuel.network"; -pub const DEVNET_ENDPOINT_URL: &str = "https://devnet.fuel.network"; pub const TESTNET_FAUCET_URL: &str = "https://faucet-testnet.fuel.network"; -pub const TESTNET_ENDPOINT_URL: &str = "https://testnet.fuel.network"; + +pub const TESTNET_EXPLORER_URL: &str = "https://app.fuel.network"; + /// Default PrivateKey to sign transactions submitted to local node. pub const DEFAULT_PRIVATE_KEY: &str = "0xde97d8624a438121b86a1956544bd72ed68cd69f2c99555b08b1e8c51ffd511c"; diff --git a/forc-plugins/forc-client/src/op/deploy.rs b/forc-plugins/forc-client/src/op/deploy.rs index e8a33e9644a..1dc9efd2e6e 100644 --- a/forc-plugins/forc-client/src/op/deploy.rs +++ b/forc-plugins/forc-client/src/op/deploy.rs @@ -4,33 +4,34 @@ use crate::{ util::{ node_url::get_node_url, pkg::built_pkgs, + target::Target, tx::{prompt_forc_wallet_password, select_secret_key, WalletSelectionMode}, }, }; use anyhow::{bail, Context, Result}; use forc_pkg::manifest::GenericManifestFile; use forc_pkg::{self as pkg, PackageManifestFile}; -use forc_tracing::println_warning; +use forc_tracing::{println_action_green, println_warning}; use forc_util::default_output_directory; use forc_wallet::utils::default_wallet_path; use fuel_core_client::client::types::TransactionStatus; use fuel_core_client::client::FuelClient; use fuel_crypto::fuel_types::ChainId; -use fuel_tx::Salt; +use fuel_tx::{Salt, Transaction}; use fuel_vm::prelude::*; use fuels_accounts::{provider::Provider, wallet::WalletUnlocked, Account}; use fuels_core::types::{transaction::TxPolicies, transaction_builders::CreateTransactionBuilder}; use futures::FutureExt; use pkg::{manifest::build_profile::ExperimentalFlags, BuildProfile, BuiltPackage}; use serde::{Deserialize, Serialize}; -use std::time::Duration; use std::{ collections::BTreeMap, path::{Path, PathBuf}, + str::FromStr, }; +use std::{sync::Arc, time::Duration}; use sway_core::language::parsed::TreeType; use sway_core::BuildTarget; -use tracing::info; #[derive(Debug, PartialEq, Eq)] pub struct DeployedContract { @@ -135,8 +136,17 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { let build_opts = build_opts_from_cmd(&command); let built_pkgs = built_pkgs(&curr_dir, &build_opts)?; - - if built_pkgs.is_empty() { + let pkgs_to_deploy = built_pkgs + .iter() + .filter(|pkg| { + pkg.descriptor + .manifest_file + .check_program_type(&[TreeType::Contract]) + .is_ok() + }) + .collect::>(); + + if pkgs_to_deploy.is_empty() { println_warning("No deployable contracts found in the current directory."); return Ok(contract_ids); } @@ -178,57 +188,105 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { None }; + // Ensure that all packages are being deployed to the same node. + let node_url = get_node_url( + &command.node, + &pkgs_to_deploy[0].descriptor.manifest_file.network, + )?; + if !pkgs_to_deploy.iter().all(|pkg| { + get_node_url(&command.node, &pkg.descriptor.manifest_file.network).ok() + == Some(node_url.clone()) + }) { + bail!("All contracts in a deployment should be deployed to the same node. Please ensure that the network specified in the Forc.toml files of all contracts is the same."); + } + + // Confirmation step. Summarize the transaction(s) for the deployment. + let (provider, signing_key) = + confirm_transaction_details(&pkgs_to_deploy, &command, node_url.clone()).await?; + + for pkg in pkgs_to_deploy { + let salt = match (&contract_salt_map, command.default_salt) { + (Some(map), false) => { + if let Some(salt) = map.get(pkg.descriptor.manifest_file.project_name()) { + *salt + } else { + Default::default() + } + } + (None, true) => Default::default(), + (None, false) => rand::random(), + (Some(_), true) => { + bail!("Both `--salt` and `--default-salt` were specified: must choose one") + } + }; + let contract_id = deploy_pkg( + &command, + pkg, + salt, + &provider, + &signing_key, + node_url.clone(), + ) + .await?; + contract_ids.push(contract_id); + } + Ok(contract_ids) +} + +/// Prompt the user to confirm the transactions required for deployment, as well as the signing key. +async fn confirm_transaction_details( + pkgs_to_deploy: &[&Arc], + command: &cmd::Deploy, + node_url: String, +) -> Result<(Provider, SecretKey)> { + // Confirmation step. Summarize the transaction(s) for the deployment. + let tx_summary = pkgs_to_deploy + .iter() + .map(|pkg| format!("deploy {}", pkg.descriptor.manifest_file.project_name())) + .collect::>() + .join(" + "); + + let tx_count = pkgs_to_deploy.len(); + + println_action_green("Confirming", &format!("transactions [{tx_summary}]")); + println_action_green("", &format!("Network: {node_url}")); + + let provider = Provider::connect(node_url.clone()).await?; + let wallet_mode = if command.default_signer || command.signing_key.is_some() { WalletSelectionMode::Manual } else { - let password = prompt_forc_wallet_password(&default_wallet_path())?; + println_action_green("", &format!("Wallet: {}", default_wallet_path().display())); + let password = prompt_forc_wallet_password()?; WalletSelectionMode::ForcWallet(password) }; - for pkg in built_pkgs { - if pkg - .descriptor - .manifest_file - .check_program_type(&[TreeType::Contract]) - .is_ok() - { - let salt = match (&contract_salt_map, command.default_salt) { - (Some(map), false) => { - if let Some(salt) = map.get(pkg.descriptor.manifest_file.project_name()) { - *salt - } else { - Default::default() - } - } - (None, true) => Default::default(), - (None, false) => rand::random(), - (Some(_), true) => { - bail!("Both `--salt` and `--default-salt` were specified: must choose one") - } - }; - let contract_id = deploy_pkg( - &command, - &pkg.descriptor.manifest_file, - &pkg, - salt, - &wallet_mode, - ) - .await?; - contract_ids.push(contract_id); - } - } - Ok(contract_ids) + // TODO: Display the estimated gas cost of the transaction(s). + // https://github.com/FuelLabs/sway/issues/6277 + + let signing_key = select_secret_key( + &wallet_mode, + command.default_signer || command.unsigned, + command.signing_key, + &provider, + tx_count, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("failed to select a signer for the transaction"))?; + + Ok((provider.clone(), signing_key)) } /// Deploy a single pkg given deploy command and the manifest file pub async fn deploy_pkg( command: &cmd::Deploy, - manifest: &PackageManifestFile, compiled: &BuiltPackage, salt: Salt, - wallet_mode: &WalletSelectionMode, + provider: &Provider, + signing_key: &SecretKey, + node_url: String, ) -> Result { - let node_url = get_node_url(&command.node, &manifest.network)?; + let manifest = &compiled.descriptor.manifest_file; let client = FuelClient::new(node_url.clone())?; let bytecode = &compiled.bytecode.bytes; @@ -247,8 +305,6 @@ pub async fn deploy_pkg( let root = contract.root(); let state_root = Contract::initial_state_root(storage_slots.iter()); let contract_id = contract.id(&salt, &root, &state_root); - - let provider = Provider::connect(node_url.clone()).await?; let tx_policies = TxPolicies::default(); let mut tb = CreateTransactionBuilder::prepare_contract_deployment( @@ -259,22 +315,15 @@ pub async fn deploy_pkg( storage_slots.clone(), tx_policies, ); - let signing_key = select_secret_key( - wallet_mode, - command.default_signer || command.unsigned, - command.signing_key, - &provider, - ) - .await? - .ok_or_else(|| anyhow::anyhow!("failed to select a signer for the transaction"))?; - let wallet = WalletUnlocked::new_from_private_key(signing_key, Some(provider.clone())); + let wallet = WalletUnlocked::new_from_private_key(*signing_key, Some(provider.clone())); wallet.add_witnesses(&mut tb)?; wallet.adjust_for_fee(&mut tb, 0).await?; let tx = tb.build(provider).await?; let tx = Transaction::from(tx); - let chain_id = client.chain_info().await?.consensus_parameters.chain_id(); + let chain_info = client.chain_info().await?; + let chain_id = chain_info.consensus_parameters.chain_id(); let deployment_request = client.submit_and_await_commit(&tx).map(|res| match res { Ok(logs) => match logs { @@ -283,11 +332,25 @@ pub async fn deploy_pkg( } TransactionStatus::Success { block_height, .. } => { let pkg_name = manifest.project_name(); - info!("\n\nContract {pkg_name} Deployed!"); - - info!("\nNetwork: {node_url}"); - info!("Contract ID: 0x{contract_id}"); - info!("Deployed in block {}", &block_height); + let target = Target::from_str(&chain_info.name).unwrap_or(Target::testnet()); + let (contract_url, block_url) = match target.explorer_url() { + Some(explorer_url) => ( + format!("{explorer_url}/contract/0x"), + format!("{explorer_url}/block/"), + ), + None => ("".to_string(), "".to_string()), + }; + println_action_green( + "Finished", + &format!("deploying {pkg_name} {contract_url}{contract_id}"), + ); + let block_height_formatted = + match u32::from_str_radix(&block_height.to_string(), 16) { + Ok(decimal) => format!("{block_url}{decimal}"), + Err(_) => block_height.to_string(), + }; + + println_action_green("Deployed", &format!("in block {block_height_formatted}")); // Create a deployment artifact. let deployment_size = bytecode.len(); diff --git a/forc-plugins/forc-client/src/op/run/mod.rs b/forc-plugins/forc-client/src/op/run/mod.rs index 49047de3a37..6144399dfb9 100644 --- a/forc-plugins/forc-client/src/op/run/mod.rs +++ b/forc-plugins/forc-client/src/op/run/mod.rs @@ -13,7 +13,6 @@ use anyhow::{anyhow, bail, Context, Result}; use forc_pkg::{self as pkg, fuel_core_not_running, PackageManifestFile}; use forc_tracing::println_warning; use forc_util::tx_utils::format_log_receipts; -use forc_wallet::utils::default_wallet_path; use fuel_core_client::client::FuelClient; use fuel_tx::{ContractId, Transaction, TransactionBuilder}; use fuels_accounts::provider::Provider; @@ -54,7 +53,7 @@ pub async fn run(command: cmd::Run) -> Result> { let wallet_mode = if command.default_signer || command.signing_key.is_some() { WalletSelectionMode::Manual } else { - let password = prompt_forc_wallet_password(&default_wallet_path())?; + let password = prompt_forc_wallet_password()?; WalletSelectionMode::ForcWallet(password) }; for built in built_pkgs_with_manifest { diff --git a/forc-plugins/forc-client/src/util/target.rs b/forc-plugins/forc-client/src/util/target.rs index 8c9d598ab4d..86b9b947666 100644 --- a/forc-plugins/forc-client/src/util/target.rs +++ b/forc-plugins/forc-client/src/util/target.rs @@ -1,7 +1,8 @@ use crate::constants::{ BETA_2_ENDPOINT_URL, BETA_2_FAUCET_URL, BETA_3_ENDPOINT_URL, BETA_3_FAUCET_URL, BETA_4_ENDPOINT_URL, BETA_4_FAUCET_URL, BETA_5_ENDPOINT_URL, BETA_5_FAUCET_URL, - DEVNET_ENDPOINT_URL, DEVNET_FAUCET_URL, NODE_URL, TESTNET_ENDPOINT_URL, TESTNET_FAUCET_URL, + DEVNET_ENDPOINT_URL, DEVNET_FAUCET_URL, NODE_URL, TESTNET_ENDPOINT_URL, TESTNET_EXPLORER_URL, + TESTNET_FAUCET_URL, }; use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; @@ -67,6 +68,13 @@ impl Target { Target::Local => "http://localhost:3000".to_string(), } } + + pub fn explorer_url(&self) -> Option { + match self { + Target::Testnet | Target::Devnet => Some(TESTNET_EXPLORER_URL.to_string()), + _ => None, + } + } } impl FromStr for Target { diff --git a/forc-plugins/forc-client/src/util/tx.rs b/forc-plugins/forc-client/src/util/tx.rs index a79f7359255..d2a82c7f493 100644 --- a/forc-plugins/forc-client/src/util/tx.rs +++ b/forc-plugins/forc-client/src/util/tx.rs @@ -1,29 +1,27 @@ -use std::{collections::BTreeMap, io::Write, path::Path, str::FromStr}; - +use crate::{constants::DEFAULT_PRIVATE_KEY, util::target::Target}; use anyhow::{Error, Result}; use async_trait::async_trait; +use dialoguer::{theme::ColorfulTheme, Confirm, Password, Select}; use forc_tracing::println_warning; - -use fuel_crypto::{Message, PublicKey, SecretKey, Signature}; -use fuel_tx::{field, Address, Buildable, ContractId, Input, Output, TransactionBuilder, Witness}; -use fuels_accounts::{provider::Provider, wallet::Wallet, ViewOnlyAccount}; -use fuels_core::types::{ - bech32::{Bech32Address, FUEL_BECH32_HRP}, - coin_type::CoinType, - transaction_builders::{create_coin_input, create_coin_message_input}, -}; - use forc_wallet::{ account::{derive_secret_key, new_at_index_cli}, balance::{ - collect_accounts_with_verification, print_account_balances, AccountBalances, - AccountVerification, AccountsMap, + collect_accounts_with_verification, AccountBalances, AccountVerification, AccountsMap, }, new::{new_wallet_cli, New}, utils::default_wallet_path, }; - -use crate::{constants::DEFAULT_PRIVATE_KEY, util::target::Target}; +use fuel_crypto::{Message, SecretKey, Signature}; +use fuel_tx::{ + field, Address, AssetId, Buildable, ContractId, Input, Output, TransactionBuilder, Witness, +}; +use fuels_accounts::{provider::Provider, wallet::Wallet, ViewOnlyAccount}; +use fuels_core::types::{ + bech32::Bech32Address, + coin_type::CoinType, + transaction_builders::{create_coin_input, create_coin_message_input}, +}; +use std::{collections::BTreeMap, io::Write, path::Path, str::FromStr}; #[derive(PartialEq, Eq)] pub enum WalletSelectionMode { @@ -50,15 +48,12 @@ fn prompt_signature(tx_id: fuel_tx::Bytes32) -> Result { } fn ask_user_yes_no_question(question: &str) -> Result { - print!("{question}"); - std::io::stdout().flush()?; - let mut ans = String::new(); - std::io::stdin().read_line(&mut ans)?; - // Pop trailing \n as users press enter to submit their answers. - ans.pop(); - // Trim the user input as it might have an additional space. - let ans = ans.trim(); - Ok(ans == "y" || ans == "Y") + let answer = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt(question) + .default(false) + .show_default(false) + .interact()?; + Ok(answer) } fn collect_user_accounts( @@ -76,11 +71,12 @@ fn collect_user_accounts( Ok(accounts) } -pub(crate) fn prompt_forc_wallet_password(wallet_path: &Path) -> Result { - let prompt = format!( - "\nPlease provide the password of your encrypted wallet vault at {wallet_path:?}: " - ); - let password = rpassword::prompt_password(prompt)?; +pub(crate) fn prompt_forc_wallet_password() -> Result { + let password = Password::with_theme(&ColorfulTheme::default()) + .with_prompt("Wallet password") + .allow_empty_password(true) + .interact()?; + Ok(password) } @@ -120,13 +116,6 @@ pub(crate) fn secret_key_from_forc_wallet( Ok(secret_key) } -pub(crate) fn bech32_from_secret(secret_key: &SecretKey) -> Result { - let public_key = PublicKey::from(secret_key); - let hashed = public_key.hash(); - let bech32 = Bech32Address::new(FUEL_BECH32_HRP, hashed); - Ok(bech32) -} - pub(crate) fn select_manual_secret_key( default_signer: bool, signing_key: Option, @@ -158,12 +147,33 @@ async fn collect_account_balances( .map_err(|e| anyhow::anyhow!("{e}")) } +/// Format collected account balances for each asset type, including only the balance of the base asset that can be used to pay gas. +pub fn format_base_asset_account_balances( + accounts_map: &AccountsMap, + account_balances: &AccountBalances, + base_asset_id: &AssetId, +) -> Vec { + accounts_map + .iter() + .zip(account_balances) + .map(|((ix, address), balance)| { + let base_asset_amount = balance + .get(&base_asset_id.to_string()) + .copied() + .unwrap_or(0); + let eth_amount = base_asset_amount as f64 / 1_000_000_000.0; + format!("[{ix}] {address} - {eth_amount} ETH") + }) + .collect() +} + // TODO: Simplify the function signature once https://github.com/FuelLabs/sway/issues/6071 is closed. pub(crate) async fn select_secret_key( wallet_mode: &WalletSelectionMode, default_sign: bool, signing_key: Option, provider: &Provider, + tx_count: usize, ) -> Result> { let chain_info = provider.chain_info().await?; let signing_key = match wallet_mode { @@ -174,6 +184,7 @@ pub(crate) async fn select_secret_key( // capabilities for selections and answer collection. let accounts = collect_user_accounts(&wallet_path, password)?; let account_balances = collect_account_balances(&accounts, provider).await?; + let base_asset_id = provider.base_asset_id(); let total_balance = account_balances .iter() @@ -190,15 +201,18 @@ pub(crate) async fn select_secret_key( \n-> {target} network faucet: {faucet_link}\ \nIf you are interacting with a local node, consider providing a chainConfig which funds your account.") } - print_account_balances(&accounts, &account_balances); + let selections = + format_base_asset_account_balances(&accounts, &account_balances, base_asset_id); let mut account_index; loop { - print!("\nPlease provide the index of account to use for signing: "); - std::io::stdout().flush()?; - let mut input_account_index = String::new(); - std::io::stdin().read_line(&mut input_account_index)?; - account_index = input_account_index.trim().parse::()?; + account_index = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Wallet account") + .max_length(5) + .items(&selections[..]) + .default(0) + .interact()?; + if accounts.contains_key(&account_index) { break; } @@ -212,11 +226,11 @@ pub(crate) async fn select_secret_key( let secret_key = secret_key_from_forc_wallet(&wallet_path, account_index, password)?; - let bech32 = bech32_from_secret(&secret_key)?; // TODO: Do this via forc-wallet once the functionality is exposed. + // TODO: calculate the number of transactions to sign and ask the user to confirm. let question = format!( - "Do you agree to sign this transaction with {}? [y/N]: ", - bech32 + "Do you agree to sign {tx_count} transaction{}?", + if tx_count > 1 { "s" } else { "" } ); let accepted = ask_user_yes_no_question(&question)?; if !accepted { @@ -323,7 +337,7 @@ impl TransactionBuilderExt for Tran let chain_info = provider.chain_info().await?; let params = chain_info.consensus_parameters; let signing_key = - select_secret_key(wallet_mode, default_sign, signing_key, &provider).await?; + select_secret_key(wallet_mode, default_sign, signing_key, &provider, 1).await?; // Get the address let address = if let Some(key) = signing_key { Address::from(*key.public_key().hash()) @@ -376,3 +390,48 @@ impl TransactionExt for T { self } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::collections::HashMap; + + #[test] + fn test_format_base_asset_account_balances() { + let mut accounts_map: AccountsMap = BTreeMap::new(); + + let address1 = Bech32Address::from_str( + "fuel1dved7k25uxadatl7l5kql309jnw07dcn4t3a6x9hm9nxyjcpqqns50p7n2", + ) + .expect("address1"); + let address2 = Bech32Address::from_str( + "fuel1x9f3ysyk7fmey5ac23s2p4rwg4gjye2kke3nu3pvrs5p4qc4m4qqwx56k3", + ) + .expect("address2"); + + let base_asset_id = AssetId::zeroed(); + + accounts_map.insert(0, address1.clone()); + accounts_map.insert(1, address2.clone()); + + let mut account_balances: AccountBalances = Vec::new(); + let mut balance1 = HashMap::new(); + balance1.insert(base_asset_id.to_string(), 1_500_000_000); + balance1.insert("other_asset".to_string(), 2_000_000_000); + account_balances.push(balance1); + + let mut balance2 = HashMap::new(); + balance2.insert("other_asset".to_string(), 3_000_000_000); + account_balances.push(balance2); + + let expected = vec![ + format!("[0] {address1} - 1.5 ETH"), + format!("[1] {address2} - 0 ETH"), + ]; + + let result = + format_base_asset_account_balances(&accounts_map, &account_balances, &base_asset_id); + assert_eq!(result, expected); + } +} diff --git a/forc-plugins/forc-client/test/data/contract_with_dep/Forc.lock b/forc-plugins/forc-client/test/data/contract_with_dep/Forc.lock new file mode 100644 index 00000000000..9722a217392 --- /dev/null +++ b/forc-plugins/forc-client/test/data/contract_with_dep/Forc.lock @@ -0,0 +1,19 @@ +[[package]] +name = "contract_with_dep" +source = "member" +dependencies = ["std"] +contract-dependencies = ["standalone_contract (0000000000000000000000000000000000000000000000000000000000000001)"] + +[[package]] +name = "core" +source = "path+from-root-9B9D657E3F1FCA11" + +[[package]] +name = "standalone_contract" +source = "path+from-root-9B9D657E3F1FCA11" +dependencies = ["std"] + +[[package]] +name = "std" +source = "path+from-root-9B9D657E3F1FCA11" +dependencies = ["core"] diff --git a/forc-plugins/forc-client/test/data/contract_with_dep/Forc.toml b/forc-plugins/forc-client/test/data/contract_with_dep/Forc.toml index 45be87cb3b6..4d1d6e8308b 100644 --- a/forc-plugins/forc-client/test/data/contract_with_dep/Forc.toml +++ b/forc-plugins/forc-client/test/data/contract_with_dep/Forc.toml @@ -6,7 +6,7 @@ license = "Apache-2.0" name = "contract_with_dep" [dependencies] -std = { path = "../../../../../../../../sway-lib-std/" } +std = { path = "../../../../../sway-lib-std/" } [contract-dependencies] standalone_contract = { path = "../standalone_contract", salt = "0x0000000000000000000000000000000000000000000000000000000000000001" } diff --git a/forc-plugins/forc-client/test/data/contract_with_dep_with_salt_conflict/Forc.toml b/forc-plugins/forc-client/test/data/contract_with_dep_with_salt_conflict/Forc.toml index 4123009928a..fe10c17663d 100644 --- a/forc-plugins/forc-client/test/data/contract_with_dep_with_salt_conflict/Forc.toml +++ b/forc-plugins/forc-client/test/data/contract_with_dep_with_salt_conflict/Forc.toml @@ -6,7 +6,7 @@ license = "Apache-2.0" name = "contract_with_dep_with_salt_conflict" [dependencies] -std = { path = "../../../../../../../../sway-lib-std/" } +std = { path = "../../../../../sway-lib-std/" } [contract-dependencies] contract_with_dep = { path = "../contract_with_dep" } diff --git a/forc-plugins/forc-client/test/data/standalone_contract/Forc.lock b/forc-plugins/forc-client/test/data/standalone_contract/Forc.lock new file mode 100644 index 00000000000..7b517045569 --- /dev/null +++ b/forc-plugins/forc-client/test/data/standalone_contract/Forc.lock @@ -0,0 +1,13 @@ +[[package]] +name = "core" +source = "path+from-root-79BB3EA8498403DE" + +[[package]] +name = "standalone_contract" +source = "member" +dependencies = ["std"] + +[[package]] +name = "std" +source = "path+from-root-79BB3EA8498403DE" +dependencies = ["core"] diff --git a/forc-plugins/forc-client/test/data/standalone_contract/Forc.toml b/forc-plugins/forc-client/test/data/standalone_contract/Forc.toml index de97c7bf31f..2324ff98894 100644 --- a/forc-plugins/forc-client/test/data/standalone_contract/Forc.toml +++ b/forc-plugins/forc-client/test/data/standalone_contract/Forc.toml @@ -6,4 +6,4 @@ license = "Apache-2.0" name = "standalone_contract" [dependencies] -std = { path = "../../../../../../../../sway-lib-std/" } +std = { path = "../../../../../sway-lib-std/" } diff --git a/forc-plugins/forc-client/test/data/standalone_contract_b/Forc.toml b/forc-plugins/forc-client/test/data/standalone_contract_b/Forc.toml index ccca650f3d5..e61b046039b 100644 --- a/forc-plugins/forc-client/test/data/standalone_contract_b/Forc.toml +++ b/forc-plugins/forc-client/test/data/standalone_contract_b/Forc.toml @@ -6,4 +6,4 @@ license = "Apache-2.0" name = "standalone_contract_b" [dependencies] -std = { path = "../../../../../../../../sway-lib-std/" } +std = { path = "../../../../../sway-lib-std/" } diff --git a/forc-plugins/forc-client/tests/deploy.rs b/forc-plugins/forc-client/tests/deploy.rs index 04b5a261a86..fa269b1dff7 100644 --- a/forc-plugins/forc-client/tests/deploy.rs +++ b/forc-plugins/forc-client/tests/deploy.rs @@ -1,10 +1,3 @@ -use std::{ - fs, - path::{Path, PathBuf}, - process::{Child, Command}, - str::FromStr, -}; - use forc::cli::shared::Pkg; use forc_client::{ cmd, @@ -13,6 +6,13 @@ use forc_client::{ }; use fuel_tx::{ContractId, Salt}; use portpicker::Port; +use rexpect::spawn; +use std::{ + fs, + path::{Path, PathBuf}, + process::{Child, Command}, + str::FromStr, +}; use tempfile::tempdir; use toml_edit::{Document, InlineTable, Item, Value}; @@ -78,7 +78,7 @@ fn patch_manifest_file_with_path_std(manifest_dir: &Path) -> anyhow::Result<()> } #[tokio::test] -async fn simple_deploy() { +async fn test_simple_deploy() { let (mut node, port) = run_node(); let tmp_dir = tempdir().unwrap(); let project_dir = test_data_path().join("standalone_contract"); @@ -114,3 +114,36 @@ async fn simple_deploy() { assert_eq!(contract_ids, expected) } + +// TODO: https://github.com/FuelLabs/sway/issues/6283 +// Add interactive tests for the happy path cases. This requires starting the node with funded accounts and setting up +// the wallet with the correct password. The tests should be run in a separate test suite that is not run by default. +// It would also require overriding `default_wallet_path` function for tests, so as not to interfere with the user's wallet. + +#[test] +fn test_deploy_interactive_wrong_password() -> Result<(), rexpect::error::Error> { + let (mut node, port) = run_node(); + let node_url = format!("http://127.0.0.1:{}/v1/graphql", port); + + // Spawn the forc-deploy binary using cargo run + let project_dir = test_data_path().join("standalone_contract"); + let mut process = spawn( + &format!( + "cargo run --bin forc-deploy -- --node-url {node_url} -p {}", + project_dir.display() + ), + Some(300000), + )?; + + // Confirmation prompts + process + .exp_string("\u{1b}[1;32mConfirming\u{1b}[0m transactions [deploy standalone_contract]")?; + process.exp_string(&format!("Network: {node_url}"))?; + process.exp_string("Wallet: ")?; + process.exp_string("Wallet password")?; + process.send_line("mock_password")?; + + process.process.exit()?; + node.kill().unwrap(); + Ok(()) +}