diff --git a/Cargo.lock b/Cargo.lock index eb012eee036..3b4d541389c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2032,6 +2032,7 @@ dependencies = [ "async-trait", "chrono", "clap 4.5.4", + "colored", "devault", "forc", "forc-pkg", @@ -2045,6 +2046,7 @@ dependencies = [ "fuel-crypto", "fuel-tx", "fuel-vm", + "fuels", "fuels-accounts", "fuels-core", "futures", @@ -2057,6 +2059,7 @@ dependencies = [ "sway-types", "sway-utils", "tokio", + "toml_edit 0.21.1", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 4518aa5d62b..453e66ed03c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ fuel-vm = "0.49.0" # Dependencies from the `fuels-rs` repository: fuels-core = "0.62.0" fuels-accounts = "0.62.0" +fuels = "0.62.0" # Dependencies from the `forc-wallet` repository: forc-wallet = "0.7.1" diff --git a/docs/book/src/forc/plugins/forc_client/index.md b/docs/book/src/forc/plugins/forc_client/index.md index ce9ca27d64e..ed8cfe9d859 100644 --- a/docs/book/src/forc/plugins/forc_client/index.md +++ b/docs/book/src/forc/plugins/forc_client/index.md @@ -1,26 +1,76 @@ # `forc-client` -Forc plugin for interacting with a Fuel node. +Forc plugin for interacting with a Fuel node. Since transactions are going to require some gas, you need to sign them with an account that has enough tokens to pay for them. -## Initializing the wallet and adding accounts +We offer multiple ways to sign the transaction: -If you don't have an initialized wallet or any account for your wallet you won't be able to sign transactions. + 1. Sign the transaction via your local wallet using forc-client which integrates with our CLI wallet, forc-wallet. + 2. Use the default signer to deploy to a local node + 3. Use forc-wallet to manually sign transactions, and copy the signed transaction back to forc-client. -To create a wallet you can use `forc wallet new`. It will ask you to choose a password to encrypt your wallet. After the initialization is done you will have your mnemonic phrase. +The easiest and recommended way to interact with deployed networks, such as our testnets is the option 1, using forc-client to sign your transactions which reads your default forc-wallet vault. For interacting with local node, we recommend using the second option, which leads forc-client to sign transactions with the a private key that comes pre-funded in local environments. -After you have created a wallet, you can derive a new account by running `forc wallet account new`. It will ask your password to decrypt the wallet before deriving an account. +## Option 1: Sign transactions via forc-client using your local forc-wallet vault -## Signing transactions using `forc-wallet` CLI +If you used forc-wallet before, a vault which securely holds your private key is created and written to your file-system in a password encrypted format. forc-client is compatible with forc-wallet such that it can read that vault by asking you your password and use your account to sign transactions. -To submit the transactions created by `forc deploy` or `forc run`, you need to sign them first (unless you are using a client without UTXO validation). To sign a transaction you can use `forc-wallet` CLI. This section is going to walk you through the whole signing process. +Example: -By default `fuel-core` runs without UTXO validation, this allows you to send invalid inputs to emulate different conditions. +```console +> forc deploy -If you want to run `fuel-core` with UTXO validation, you can pass `--utxo-validation` to `fuel-core run`. + Building /Users/test/test-projects/test-contract + Finished release [optimized + fuel] target(s) in 11.39s -To install `forc-wallet` please refer to `forc-wallet`'s [GitHub repo](https://github.com/FuelLabs/forc-wallet#forc-wallet). +Please provide the password of your encrypted wallet vault at "/Users/ceylinbormali/.fuel/wallets/.wallet": + Deploying contract: impl-contract -1. Construct the transaction by using either `forc deploy` or `forc run`. To do so simply run `forc deploy` or `forc run` with your desired parameters. For a list of parameters please refer to the [forc-deploy](./forc_deploy.md) or [forc-run](./forc_run.md) section of the book. Once you run either command you will be asked the address of the wallet you are going to be signing with. After the address is given the transaction will be generated and you will be given a transaction ID. At this point CLI will actively wait for you to insert the signature. +--------------------------------------------------------------------------- +Account 0: fuel12pls73y9hnqdqthvduy2x44x48zt8s50pkerf32kq26f2afeqdwq6rj9ar + +Asset ID : f8f8b6283d7fa5b672b530cbb84fcccb4ff8dc40f8176ef4544ddb1f1952ad07 +Amount : 2197245 +--------------------------------------------------------------------------- + +Please provide the index of account to use for signing: 0 +Do you agree to sign this transaction with fuel12pls73y9hnqdqthvduy2x44x48zt8s50pkerf32kq26f2afeqdwq6rj9ar? [y/N]: y + + +Contract impl-contract Deployed! + +Network: https://testnet.fuel.network +Contract ID: 0x94b712901f04332682d14c998a5fc5a078ed15321438f46d58d0383200cde43d +Deployed in block 00114d4d +``` + +As it can be seen from the example, forc-client asks for your password to decrypt the forc-wallet vault, and list your accounts so that you can select the one you want to fund the transaction with. + +## Option 2: Using default signer + +If you are not interacting with a deployed network, such as testnets, your local fuel-core environment can be structured such that it funds an account by default. Using `--default-signer` flag with forc-client binaries (run, deploy) will instruct forc-client to sign transactions with this pre-funded account. Which makes it a useful command while working against a local node. + +Example: + +```console +> forc deploy --default-signer + + Building /Users/test/test-projects/test-contract + Finished release [optimized + fuel] target(s) in 11.40s + Deploying contract: impl-contract + + +Contract impl-contract Deployed! + +Network: http://127.0.0.1:4000 +Contract ID: 0xf9fb08ef18ce226954270d6d4f67677d484b8782a5892b3d436572b405407544 +Deployed in block 00000001 +``` + +## Option 3: Manually signing through forc-wallet (Deprecated) + +This option is for creating the transaction first, signing it manually and supplying the signed transaction back to forc-client. Since it requires multiple steps, it is more error-prone and not recommended for general use case. Also this will be deprecated soon. + +1. Construct the transaction by using either `forc deploy` or `forc run`. To do so simply run `forc deploy --manual-sign` or `forc run --manual-sign` with your desired parameters. For a list of parameters please refer to the [forc-deploy](./forc_deploy.md) or [forc-run](./forc_run.md) section of the book. Once you run either command you will be asked the address of the wallet you are going to be signing with. After the address is given the transaction will be generated and you will be given a transaction ID. At this point CLI will actively wait for you to insert the signature. 2. Take the transaction ID generated in the first step and sign it with `forc wallet sign --account tx-id `. This will generate a signature. 3. Take the signature generated in the second step and provide it to `forc-deploy` (or `forc-run`). Once the signature is provided, the signed transaction will be submitted. @@ -56,7 +106,7 @@ By default `--default-signer` flag would sign your transactions with the followi ## Interacting with the testnet -To interact with the latest testnet, use the `--testnet` flag. When this flag is passed, transactions created by `forc-deploy` will be sent to the `beta-4` testnet. +To interact with the latest testnet, use the `--testnet` flag. When this flag is passed, transactions created by `forc-deploy` will be sent to the latest `testnet`. ```sh forc-deploy --testnet @@ -68,10 +118,10 @@ It is also possible to pass the exact node URL while using `forc-deploy` or `for forc-deploy --node-url https://beta-3.fuel.network ``` -Another alternative is the `--target` option, which provides useful aliases to all targets. For example if you want to deploy to `beta-3` you can use: +Another alternative is the `--target` option, which provides useful aliases to all targets. For example if you want to deploy to `beta-5` you can use: ```sh -forc-deploy --target beta-3 +forc-deploy --target beta-5 ``` Since deploying and running projects on the testnet cost gas, you will need coins to pay for them. You can get some using the [testnet faucet](https://faucet-beta-4.fuel.network/). diff --git a/forc-pkg/src/manifest/mod.rs b/forc-pkg/src/manifest/mod.rs index e6d94f1079f..c319df6f313 100644 --- a/forc-pkg/src/manifest/mod.rs +++ b/forc-pkg/src/manifest/mod.rs @@ -184,6 +184,7 @@ pub struct PackageManifest { pub build_target: Option>, build_profile: Option>, pub contract_dependencies: Option>, + pub proxy: Option, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -242,6 +243,17 @@ pub struct DependencyDetails { pub(crate) ipfs: Option, } +/// Describes the details around proxy contract. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct Proxy { + pub enabled: bool, + /// Points to the proxy contract to be updated with the new contract id. + /// If there is a value for this field, forc will try to update the proxy contract's storage + /// field such that it points to current contract's deployed instance. + pub address: Option, +} + impl DependencyDetails { /// Checks if dependency details reserved for a specific dependency type used without the main /// detail for that type. @@ -619,6 +631,11 @@ impl PackageManifest { .and_then(|patches| patches.get(patch_name)) } + /// Retrieve the proxy table for the package. + pub fn proxy(&self) -> Option<&Proxy> { + self.proxy.as_ref() + } + /// Check for the `core` and `std` packages under `[dependencies]`. If both are missing, add /// `std` implicitly. /// diff --git a/forc-pkg/src/pkg.rs b/forc-pkg/src/pkg.rs index 2c24049acb5..7e14f20a322 100644 --- a/forc-pkg/src/pkg.rs +++ b/forc-pkg/src/pkg.rs @@ -282,7 +282,7 @@ pub struct MinifyOpts { type ContractIdConst = String; /// The set of options provided to the `build` functions. -#[derive(Default)] +#[derive(Default, Clone)] pub struct BuildOpts { pub pkg: PkgOpts, pub print: PrintOpts, @@ -315,6 +315,7 @@ pub struct BuildOpts { } /// The set of options to filter type of projects to build in a workspace. +#[derive(Clone)] pub struct MemberFilter { pub build_contracts: bool, pub build_scripts: bool, @@ -2126,6 +2127,9 @@ pub fn build_with_options(build_options: &BuildOpts) -> Result { .as_ref() .map_or_else(|| current_dir, PathBuf::from); + let building = ansi_term::Colour::Green.bold().paint("Building"); + info!(" {} {}", building, path.display()); + let build_plan = BuildPlan::from_build_opts(build_options)?; let graph = build_plan.graph(); let manifest_map = build_plan.manifest_map(); diff --git a/forc-plugins/forc-client/Cargo.toml b/forc-plugins/forc-client/Cargo.toml index 521c6e03f13..7046dfec8ee 100644 --- a/forc-plugins/forc-client/Cargo.toml +++ b/forc-plugins/forc-client/Cargo.toml @@ -13,6 +13,7 @@ anyhow = "1" async-trait = "0.1.58" chrono = { version = "0.4", default-features = false, features = ["std"] } clap = { version = "4.5.4", features = ["derive", "env"] } +colored = "2.0.0" devault = "0.1" forc = { version = "0.60.0", path = "../../forc" } forc-pkg = { version = "0.60.0", path = "../../forc-pkg" } @@ -26,6 +27,7 @@ fuel-core-types = { workspace = true } fuel-crypto = { workspace = true } fuel-tx = { workspace = true, features = ["test-helpers"] } fuel-vm = { workspace = true } +fuels = { workspace = true } fuels-accounts = { workspace = true } fuels-core = { workspace = true } futures = "0.3" @@ -38,6 +40,7 @@ sway-core = { version = "0.60.0", path = "../../sway-core" } sway-types = { version = "0.60.0", path = "../../sway-types" } sway-utils = { version = "0.60.0", path = "../../sway-utils" } tokio = { version = "1.8", features = ["macros", "rt-multi-thread", "process"] } +toml_edit = "0.21.1" tracing = "0.1" [[bin]] diff --git a/forc-plugins/forc-client/src/cmd/deploy.rs b/forc-plugins/forc-client/src/cmd/deploy.rs index cf1f26e2a2e..abec6c3ed76 100644 --- a/forc-plugins/forc-client/src/cmd/deploy.rs +++ b/forc-plugins/forc-client/src/cmd/deploy.rs @@ -57,9 +57,6 @@ pub struct Command { pub unsigned: bool, /// Set the key to be used for signing. pub signing_key: Option, - /// Sign the deployment transaction manually. - #[clap(long)] - pub manual_signing: bool, /// Override storage slot initialization. /// /// By default, storage slots are initialized with the values defined in the storage block in diff --git a/forc-plugins/forc-client/src/cmd/run.rs b/forc-plugins/forc-client/src/cmd/run.rs index e23d9f6cb43..5875b270adc 100644 --- a/forc-plugins/forc-client/src/cmd/run.rs +++ b/forc-plugins/forc-client/src/cmd/run.rs @@ -52,9 +52,6 @@ pub struct Command { pub unsigned: bool, /// Set the key to be used for signing. pub signing_key: Option, - /// Sign the deployment transaction manually. - #[clap(long)] - pub manual_signing: bool, /// Arguments to pass into main function with forc run. #[clap(long)] pub args: Option>, diff --git a/forc-plugins/forc-client/src/op/deploy.rs b/forc-plugins/forc-client/src/op/deploy.rs index e0270fa5245..bb610285565 100644 --- a/forc-plugins/forc-client/src/op/deploy.rs +++ b/forc-plugins/forc-client/src/op/deploy.rs @@ -3,28 +3,37 @@ use crate::{ util::{ gas::get_estimated_max_fee, node_url::get_node_url, - pkg::built_pkgs, - tx::{TransactionBuilderExt, WalletSelectionMode, TX_SUBMIT_TIMEOUT_MS}, + pkg::{build_proxy_contract, built_pkgs, update_proxy_address_in_manifest}, + tx::{ + bech32_from_secret, check_and_create_wallet_at_default_path, first_user_account, + prompt_forc_wallet_password, select_manual_secret_key, select_secret_key, + update_proxy_contract_target, TransactionBuilderExt, WalletSelectionMode, + TX_SUBMIT_TIMEOUT_MS, + }, }, }; use anyhow::{bail, Context, Result}; +use colored::Colorize; use forc_pkg::manifest::GenericManifestFile; use forc_pkg::{self as pkg, PackageManifestFile}; use forc_tracing::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::{Output, Salt, TransactionBuilder}; use fuel_vm::prelude::*; use fuels_accounts::provider::Provider; +use fuels_core::types::bech32::Bech32Address; use futures::FutureExt; -use pkg::{manifest::build_profile::ExperimentalFlags, BuildProfile, BuiltPackage}; +use pkg::{manifest::build_profile::ExperimentalFlags, BuildOpts, BuildProfile, BuiltPackage}; use serde::{Deserialize, Serialize}; use std::time::Duration; use std::{ collections::BTreeMap, path::{Path, PathBuf}, + str::FromStr, }; use sway_core::language::parsed::TreeType; use sway_core::BuildTarget; @@ -113,6 +122,54 @@ fn validate_and_parse_salts<'a>( Ok(contract_salt_map) } +async fn deploy_new_proxy( + pkg: &BuiltPackage, + owner_account_address: &mut Bech32Address, + impl_contract: &DeployedContract, + build_opts: &BuildOpts, + command: &cmd::Deploy, + salt: Salt, + wallet_mode: &WalletSelectionMode, +) -> Result { + println!(" {} proxy contract", "Creating".bold().green()); + let user_addr = if *owner_account_address != Bech32Address::default() { + anyhow::Ok(owner_account_address.clone()) + } else { + // Check if the wallet exists and if not create it at the default path. + match wallet_mode { + WalletSelectionMode::ForcWallet(password) => { + let default_path = default_wallet_path(); + check_and_create_wallet_at_default_path(&default_path)?; + let account = first_user_account(&default_wallet_path(), password)?; + *owner_account_address = account.clone(); + Ok(account) + } + WalletSelectionMode::Manual => { + let secret_key = + select_manual_secret_key(command.default_signer, command.signing_key) + .ok_or_else(|| { + anyhow::anyhow!("couldn't resolve the secret key for manual signing") + })?; + bech32_from_secret(&secret_key) + } + } + }?; + let user_addr_hex: fuels_core::types::Address = user_addr.into(); + let user_addr = format!("0x{}", user_addr_hex); + let pkg_name = pkg.descriptor.manifest_file.project_name(); + let contract_addr = format!("0x{}", impl_contract.id); + let proxy_contract = build_proxy_contract(&user_addr, &contract_addr, pkg_name, build_opts)?; + println!(" {} proxy contract", "Deploying".bold().green()); + let proxy = deploy_pkg( + command, + &pkg.descriptor.manifest_file, + &proxy_contract, + salt, + wallet_mode, + ) + .await?; + Ok(proxy) +} /// Builds and deploys contract(s). If the given path corresponds to a workspace, all deployable members /// will be built and deployed. /// @@ -124,7 +181,7 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { println_warning("--unsigned flag is deprecated, please prefer using --default-signer. Assuming `--default-signer` is passed. This means your transaction will be signed by an account that is funded by fuel-core by default for testing purposes."); } - let mut contract_ids = Vec::new(); + let mut deployed_contracts = Vec::new(); let curr_dir = if let Some(ref path) = command.pkg.path { PathBuf::from(path) } else { @@ -136,7 +193,7 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { if built_pkgs.is_empty() { println_warning("No deployable contracts found in the current directory."); - return Ok(contract_ids); + return Ok(deployed_contracts); } let contract_salt_map = if let Some(salt_input) = &command.salt { @@ -156,7 +213,6 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { // OK to index into salt_input and built_pkgs_with_manifest here, // since both are known to be len 1. - let salt = salt_input[0] .parse::() .map_err(|e| anyhow::anyhow!(e)) @@ -176,6 +232,14 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { None }; + let wallet_mode = if command.default_signer || command.signing_key.is_some() { + WalletSelectionMode::Manual + } else { + let password = prompt_forc_wallet_password(&default_wallet_path())?; + WalletSelectionMode::ForcWallet(password) + }; + + let mut owner_account_address = Bech32Address::default(); for pkg in built_pkgs { if pkg .descriptor @@ -197,12 +261,79 @@ pub async fn deploy(command: cmd::Deploy) -> Result> { bail!("Both `--salt` and `--default-salt` were specified: must choose one") } }; - let contract_id = - deploy_pkg(&command, &pkg.descriptor.manifest_file, &pkg, salt).await?; - contract_ids.push(contract_id); + let node_url = get_node_url(&command.node, &pkg.descriptor.manifest_file.network)?; + println!( + " {} contract: {}", + "Deploying".bold().green(), + &pkg.descriptor.name + ); + let deployed_contract = deploy_pkg( + &command, + &pkg.descriptor.manifest_file, + &pkg, + salt, + &wallet_mode, + ) + .await?; + let proxy = &pkg.descriptor.manifest_file.proxy(); + if let Some(proxy) = proxy { + if proxy.enabled { + if let Some(proxy_addr) = &proxy.address { + // Make a call into the contract to update impl contract address to 'deployed_contract'. + + // Create a contract instance for the proxy contract using default proxy contract abi and + // specified address. + let provider = Provider::connect(node_url.clone()).await?; + // TODO: once https://github.com/FuelLabs/sway/issues/6071 is closed, this will return just a result + // and we won't need to handle the manual prompt based signature case. + let signing_key = select_secret_key( + &wallet_mode, + command.default_signer, + command.signing_key, + &provider, + ) + .await?; + + let signing_key = signing_key.ok_or_else( + + || anyhow::anyhow!("proxy contract deployments are not supported with manual prompt based signing") + )?; + let proxy_contract = + ContractId::from_str(proxy_addr).map_err(|e| anyhow::anyhow!(e))?; + + update_proxy_contract_target( + provider, + signing_key, + proxy_contract, + deployed_contract.id, + ) + .await?; + } else { + // Deploy a new proxy contract. + let deployed_proxy_contract = deploy_new_proxy( + &pkg, + &mut owner_account_address, + &deployed_contract, + &build_opts, + &command, + salt, + &wallet_mode, + ) + .await?; + + // Update manifest file such that the proxy address field points to the new proxy contract. + update_proxy_address_in_manifest( + &format!("0x{}", deployed_proxy_contract.id), + &pkg.descriptor.manifest_file, + )?; + } + } + } + + deployed_contracts.push(deployed_contract); } } - Ok(contract_ids) + Ok(deployed_contracts) } /// Deploy a single pkg given deploy command and the manifest file @@ -211,10 +342,10 @@ pub async fn deploy_pkg( manifest: &PackageManifestFile, compiled: &BuiltPackage, salt: Salt, + wallet_mode: &WalletSelectionMode, ) -> Result { let node_url = get_node_url(&command.node, &manifest.network)?; let client = FuelClient::new(node_url.clone())?; - let bytecode = &compiled.bytecode.bytes; let mut storage_slots = @@ -232,12 +363,6 @@ pub async fn deploy_pkg( let state_root = Contract::initial_state_root(storage_slots.iter()); let contract_id = contract.id(&salt, &root, &state_root); - let wallet_mode = if command.manual_signing { - WalletSelectionMode::Manual - } else { - WalletSelectionMode::ForcWallet - }; - let provider = Provider::connect(node_url.clone()).await?; // We need a tx for estimation without the signature. diff --git a/forc-plugins/forc-client/src/op/run/mod.rs b/forc-plugins/forc-client/src/op/run/mod.rs index 0ea4f073b2a..b7e9356bce4 100644 --- a/forc-plugins/forc-client/src/op/run/mod.rs +++ b/forc-plugins/forc-client/src/op/run/mod.rs @@ -5,13 +5,17 @@ use crate::{ gas::get_script_gas_used, node_url::get_node_url, pkg::built_pkgs, - tx::{TransactionBuilderExt, WalletSelectionMode, TX_SUBMIT_TIMEOUT_MS}, + tx::{ + prompt_forc_wallet_password, TransactionBuilderExt, WalletSelectionMode, + TX_SUBMIT_TIMEOUT_MS, + }, }, }; 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; @@ -49,6 +53,12 @@ pub async fn run(command: cmd::Run) -> Result> { }; let build_opts = build_opts_from_cmd(&command); let built_pkgs_with_manifest = built_pkgs(&curr_dir, &build_opts)?; + let wallet_mode = if command.default_signer || command.signing_key.is_some() { + WalletSelectionMode::Manual + } else { + let password = prompt_forc_wallet_password(&default_wallet_path())?; + WalletSelectionMode::ForcWallet(password) + }; for built in built_pkgs_with_manifest { if built .descriptor @@ -56,7 +66,13 @@ pub async fn run(command: cmd::Run) -> Result> { .check_program_type(&[TreeType::Script]) .is_ok() { - let pkg_receipts = run_pkg(&command, &built.descriptor.manifest_file, &built).await?; + let pkg_receipts = run_pkg( + &command, + &built.descriptor.manifest_file, + &built, + &wallet_mode, + ) + .await?; receipts.push(pkg_receipts); } } @@ -68,6 +84,7 @@ pub async fn run_pkg( command: &cmd::Run, manifest: &PackageManifestFile, compiled: &BuiltPackage, + wallet_mode: &WalletSelectionMode, ) -> Result { let node_url = get_node_url(&command.node, &manifest.network)?; @@ -101,11 +118,6 @@ pub async fn run_pkg( .map_err(|e| anyhow!("Failed to parse contract id: {}", e)) }) .collect::>>()?; - let wallet_mode = if command.manual_signing { - WalletSelectionMode::Manual - } else { - WalletSelectionMode::ForcWallet - }; let mut tb = TransactionBuilder::script(compiled.bytecode.bytes.clone(), script_data); tb.maturity(command.maturity.maturity.into()) diff --git a/forc-plugins/forc-client/src/util/pkg.rs b/forc-plugins/forc-client/src/util/pkg.rs index f7b6384cb74..f0ecd6605f3 100644 --- a/forc-plugins/forc-client/src/util/pkg.rs +++ b/forc-plugins/forc-client/src/util/pkg.rs @@ -1,8 +1,142 @@ use anyhow::Result; use forc_pkg::manifest::GenericManifestFile; use forc_pkg::{self as pkg, manifest::ManifestFile, BuildOpts, BuildPlan}; -use pkg::{build_with_options, BuiltPackage}; +use forc_util::user_forc_directory; +use pkg::{build_with_options, BuiltPackage, PackageManifestFile}; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::PathBuf; use std::{collections::HashMap, path::Path, sync::Arc}; +use sway_utils::{MAIN_ENTRY, MANIFEST_FILE_NAME, SRC_DIR}; + +/// The name of the folder that forc generated proxy contract project will reside at. +pub const PROXY_CONTRACT_FOLDER_NAME: &str = ".generated_proxy_contracts"; +/// Forc.toml for the default proxy contract that 'generate_proxy_contract_src()' returns. +pub const PROXY_CONTRACT_FORC_TOML: &str = r#" +[project] +authors = ["Fuel Labs "] +entry = "main.sw" +license = "Apache-2.0" +name = "proxy_contract" + +[dependencies] +standards = { git = "https://github.com/FuelLabs/sway-standards/" } +"#; + +/// Generates source code for proxy contract owner set to the given 'addr'. +pub(crate) fn generate_proxy_contract_src(addr: &str, impl_contract_id: &str) -> String { + format!( + r#" +contract; + +use std::execution::run_external; +use standards::src5::{{AccessError, SRC5, State}}; +use standards::src14::SRC14; + +/// The owner of this contract at deployment. +const INITIAL_OWNER: Identity = Identity::Address(Address::from({addr})); + +// use sha256("storage_SRC14") as base to avoid collisions +#[namespace(SRC14)] +storage {{ + // target is at sha256("storage_SRC14_0") + target: ContractId = ContractId::from({impl_contract_id}), + owner: State = State::Initialized(INITIAL_OWNER), +}} + +impl SRC5 for Contract {{ + #[storage(read)] + fn owner() -> State {{ + storage.owner.read() + }} +}} + +impl SRC14 for Contract {{ + #[storage(write)] + fn set_proxy_target(new_target: ContractId) {{ + only_owner(); + storage.target.write(new_target); + }} +}} + +#[fallback] +#[storage(read)] +fn fallback() {{ + // pass through any other method call to the target + run_external(storage.target.read()) +}} + +#[storage(read)] +fn only_owner() {{ + require( + storage + .owner + .read() == State::Initialized(msg_sender().unwrap()), + AccessError::NotOwner, + ); +}} +"# + ) +} + +/// Updates the given package manifest file such that the address field under the proxy table updated to the given value. +/// Updated manifest file is written back to the same location, without thouching anything else such as comments etc. +/// A safety check is done to ensure the proxy table exists before attempting to udpdate the value. +pub(crate) fn update_proxy_address_in_manifest( + address: &str, + manifest: &PackageManifestFile, +) -> Result<()> { + let mut toml = String::new(); + let mut file = File::open(manifest.path())?; + file.read_to_string(&mut toml)?; + let mut manifest_toml = toml.parse::()?; + if manifest.proxy().is_some() { + manifest_toml["proxy"]["address"] = toml_edit::value(address); + let mut file = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(manifest.path())?; + file.write_all(manifest_toml.to_string().as_bytes())?; + } + Ok(()) +} + +/// Creates a proxy contract project at the given path, adds a forc.toml and source file. +pub(crate) fn create_proxy_contract( + addr: &str, + impl_contract_id: &str, + pkg_name: &str, +) -> Result { + // Create the proxy contract folder. + let proxy_contract_dir = user_forc_directory() + .join(PROXY_CONTRACT_FOLDER_NAME) + .join(pkg_name); + std::fs::create_dir_all(&proxy_contract_dir)?; + + // Create the Forc.toml + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(proxy_contract_dir.join(MANIFEST_FILE_NAME))?; + write!(f, "{}", PROXY_CONTRACT_FORC_TOML)?; + + // Create the src folder + std::fs::create_dir_all(proxy_contract_dir.join(SRC_DIR))?; + + // Create main.sw + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(proxy_contract_dir.join(SRC_DIR).join(MAIN_ENTRY))?; + + let contract_str = generate_proxy_contract_src(addr, impl_contract_id); + write!(f, "{}", contract_str)?; + Ok(proxy_contract_dir) +} pub(crate) fn built_pkgs(path: &Path, build_opts: &BuildOpts) -> Result>> { let manifest_file = ManifestFile::from_dir(path)?; @@ -32,3 +166,60 @@ pub(crate) fn built_pkgs(path: &Path, build_opts: &BuildOpts) -> Result Result> { + let proxy_contract_dir = create_proxy_contract(owner_addr, impl_contract_id, pkg_name)?; + let mut build_opts = build_opts.clone(); + let proxy_contract_dir_str = format!("{}", proxy_contract_dir.clone().display()); + build_opts.pkg.path = Some(proxy_contract_dir_str); + let built_pkgs = built_pkgs(&proxy_contract_dir, &build_opts)?; + let built_pkg = built_pkgs + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("could not get proxy contract"))?; + Ok(built_pkg) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use forc_pkg::BuildOpts; + use forc_util::user_forc_directory; + + use super::{build_proxy_contract, PROXY_CONTRACT_FOLDER_NAME}; + + #[test] + fn test_build_proxy_contract() { + let owner_address = "0x0000000000000000000000000000000000000000000000000000000000000000"; + let impl_contract_address = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + let target_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("test") + .join("data") + .join("standalone_contract"); + let mut build_opts = BuildOpts::default(); + let target_path = format!("{}", target_path.display()); + build_opts.pkg.path = Some(target_path); + let pkg_name = "standalone_contract"; + + let proxy_contract = + build_proxy_contract(owner_address, impl_contract_address, pkg_name, &build_opts); + // We want to make sure proxy_contract is building + proxy_contract.unwrap(); + + let proxy_contract_dir = user_forc_directory() + .join(PROXY_CONTRACT_FOLDER_NAME) + .join(pkg_name); + // Cleanup the test artifacts + std::fs::remove_dir_all(proxy_contract_dir).expect("failed to clean test artifacts") + } +} diff --git a/forc-plugins/forc-client/src/util/proxy_contract-abi.json b/forc-plugins/forc-client/src/util/proxy_contract-abi.json new file mode 100644 index 00000000000..be94c534664 --- /dev/null +++ b/forc-plugins/forc-client/src/util/proxy_contract-abi.json @@ -0,0 +1,146 @@ +{ + "encoding": "1", + "types": [ + { + "typeId": 0, + "type": "()", + "components": [], + "typeParameters": null + }, + { + "typeId": 1, + "type": "b256", + "components": null, + "typeParameters": null + }, + { + "typeId": 2, + "type": "enum AccessError", + "components": [ + { + "name": "NotOwner", + "type": 0, + "typeArguments": null + } + ], + "typeParameters": null + }, + { + "typeId": 3, + "type": "enum Identity", + "components": [ + { + "name": "Address", + "type": 5, + "typeArguments": null + }, + { + "name": "ContractId", + "type": 6, + "typeArguments": null + } + ], + "typeParameters": null + }, + { + "typeId": 4, + "type": "enum State", + "components": [ + { + "name": "Uninitialized", + "type": 0, + "typeArguments": null + }, + { + "name": "Initialized", + "type": 3, + "typeArguments": null + }, + { + "name": "Revoked", + "type": 0, + "typeArguments": null + } + ], + "typeParameters": null + }, + { + "typeId": 5, + "type": "struct Address", + "components": [ + { + "name": "bits", + "type": 1, + "typeArguments": null + } + ], + "typeParameters": null + }, + { + "typeId": 6, + "type": "struct ContractId", + "components": [ + { + "name": "bits", + "type": 1, + "typeArguments": null + } + ], + "typeParameters": null + } + ], + "functions": [ + { + "inputs": [], + "name": "owner", + "output": { + "name": "", + "type": 4, + "typeArguments": null + }, + "attributes": [ + { + "name": "storage", + "arguments": [ + "read" + ] + } + ] + }, + { + "inputs": [ + { + "name": "new_target", + "type": 6, + "typeArguments": null + } + ], + "name": "set_proxy_target", + "output": { + "name": "", + "type": 0, + "typeArguments": null + }, + "attributes": [ + { + "name": "storage", + "arguments": [ + "write" + ] + } + ] + } + ], + "loggedTypes": [ + { + "logId": "4571204900286667806", + "loggedType": { + "name": "", + "type": 2, + "typeArguments": [] + } + } + ], + "messagesTypes": [], + "configurables": [] +} \ No newline at end of file diff --git a/forc-plugins/forc-client/src/util/tx.rs b/forc-plugins/forc-client/src/util/tx.rs index 84441b79933..33b65c6f246 100644 --- a/forc-plugins/forc-client/src/util/tx.rs +++ b/forc-plugins/forc-client/src/util/tx.rs @@ -1,4 +1,4 @@ -use std::{io::Write, str::FromStr}; +use std::{collections::BTreeMap, io::Write, path::Path, str::FromStr}; use anyhow::{Error, Result}; use async_trait::async_trait; @@ -6,7 +6,12 @@ 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::macros::abigen; +use fuels_accounts::{ + provider::Provider, + wallet::{Wallet, WalletUnlocked}, + ViewOnlyAccount, +}; use fuels_core::types::{ bech32::{Bech32Address, FUEL_BECH32_HRP}, coin_type::CoinType, @@ -34,7 +39,8 @@ pub const DEFAULT_PRIVATE_KEY: &str = #[derive(PartialEq, Eq)] pub enum WalletSelectionMode { - ForcWallet, + /// Holds the password of forc-wallet instance. + ForcWallet(String), Manual, } @@ -66,6 +72,98 @@ fn ask_user_yes_no_question(question: &str) -> Result { let ans = ans.trim(); Ok(ans == "y" || ans == "Y") } + +fn collect_user_accounts( + wallet_path: &Path, + password: &str, +) -> Result> { + let verification = AccountVerification::Yes(password.to_string()); + let accounts = collect_accounts_with_verification(wallet_path, verification).map_err(|e| { + if e.to_string().contains("Mac Mismatch") { + anyhow::anyhow!("Failed to access forc-wallet vault. Please check your password") + } else { + e + } + })?; + 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)?; + Ok(password) +} + +pub(crate) fn first_user_account(wallet_path: &Path, password: &str) -> Result { + let accounts = collect_user_accounts(wallet_path, password)?; + + let account = accounts + .get(&0) + .ok_or_else(|| anyhow::anyhow!("No account derived for this wallet"))? + .clone(); + Ok(account) +} +pub(crate) fn check_and_create_wallet_at_default_path(wallet_path: &Path) -> Result<()> { + if !wallet_path.exists() { + let question = format!("Could not find a wallet at {wallet_path:?}, would you like to create a new one? [y/N]: "); + let accepted = ask_user_yes_no_question(&question)?; + let new_options = New { + force: false, + cache_accounts: None, + }; + if accepted { + new_wallet_cli(wallet_path, new_options)?; + println!("Wallet created successfully."); + // Derive first account for the fresh wallet we created. + new_at_index_cli(wallet_path, 0)?; + println!("Account derived successfully."); + } else { + anyhow::bail!("Refused to create a new wallet. If you don't want to use forc-wallet, you can sign this transaction manually with --manual-signing flag.") + } + } + Ok(()) +} + +pub(crate) fn secret_key_from_forc_wallet( + wallet_path: &Path, + account_index: usize, + password: &str, +) -> Result { + let secret_key = derive_secret_key(wallet_path, account_index, password).map_err(|e| { + if e.to_string().contains("Mac Mismatch") { + anyhow::anyhow!("Failed to access forc-wallet vault. Please check your password") + } else { + e + } + })?; + 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, +) -> Option { + match (default_signer, signing_key) { + // Note: unwrap is safe here as we already know that 'DEFAULT_PRIVATE_KEY' is a valid private key. + (true, None) => Some(SecretKey::from_str(DEFAULT_PRIVATE_KEY).unwrap()), + (true, Some(signing_key)) => { + println_warning("Signing key is provided while requesting to sign with a default signer. Using signing key"); + Some(signing_key) + } + (false, None) => None, + (false, Some(signing_key)) => Some(signing_key), + } +} + /// Collect and return balances of each account in the accounts map. async fn collect_account_balances( accounts_map: &AccountsMap, @@ -81,6 +179,102 @@ async fn collect_account_balances( .map_err(|e| anyhow::anyhow!("{e}")) } +// 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, +) -> Result> { + let chain_info = provider.chain_info().await?; + let signing_key = match wallet_mode { + WalletSelectionMode::ForcWallet(password) => { + let wallet_path = default_wallet_path(); + check_and_create_wallet_at_default_path(&wallet_path)?; + // TODO: This is a very simple TUI, we should consider adding a nice TUI + // capabilities for selections and answer collection. + let accounts = collect_user_accounts(&wallet_path, password)?; + let account_balances = collect_account_balances(&accounts, provider).await?; + + let total_balance = account_balances + .iter() + .flat_map(|account| account.values()) + .sum::(); + if total_balance == 0 { + let first_account = accounts + .get(&0) + .ok_or_else(|| anyhow::anyhow!("No account derived for this wallet"))?; + let target = Target::from_str(&chain_info.name).unwrap_or(Target::testnet()); + let faucet_link = format!("{}/?address={first_account}", target.faucet_url()); + anyhow::bail!("Your wallet does not have any funds to pay for the transaction.\ + \n\nIf you are interacting with a testnet consider using the faucet.\ + \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 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::()?; + if accounts.contains_key(&account_index) { + break; + } + let options: Vec = accounts.keys().map(|key| key.to_string()).collect(); + println_warning(&format!( + "\"{}\" is not a valid account.\nPlease choose a valid option from {}", + account_index, + options.join(","), + )); + } + + 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. + let question = format!( + "Do you agree to sign this transaction with {}? [y/N]: ", + bech32 + ); + let accepted = ask_user_yes_no_question(&question)?; + if !accepted { + anyhow::bail!("User refused to sign"); + } + + Some(secret_key) + } + WalletSelectionMode::Manual => select_manual_secret_key(default_sign, signing_key), + }; + Ok(signing_key) +} + +pub(crate) async fn update_proxy_contract_target( + provider: Provider, + secret_key: SecretKey, + proxy_contract_id: ContractId, + new_target: ContractId, +) -> Result<()> { + abigen!(Contract( + name = "ProxyContract", + abi = "forc-plugins/forc-client/src/util/proxy_contract-abi.json" + )); + + let wallet = WalletUnlocked::new_from_private_key(secret_key, Some(provider)); + + let proxy_contract = ProxyContract::new(proxy_contract_id, wallet); + + // TODO: what happens if the call fails? Does 'FuelCallResponse' is returned as Err() in that case? + proxy_contract + .methods() + .set_proxy_target(new_target) + .call() + .await?; + Ok(()) +} + #[async_trait] pub trait TransactionBuilderExt { fn add_contract(&mut self, contract_id: ContractId) -> &mut Self; @@ -95,9 +289,9 @@ pub trait TransactionBuilderExt { async fn finalize_signed( &mut self, client: Provider, - unsigned: bool, + default_signature: bool, signing_key: Option, - wallet_mode: WalletSelectionMode, + wallet_mode: &WalletSelectionMode, ) -> Result; } @@ -168,131 +362,17 @@ impl TransactionBuilderExt for Tran provider: Provider, default_sign: bool, signing_key: Option, - wallet_mode: WalletSelectionMode, + wallet_mode: &WalletSelectionMode, ) -> Result { let chain_info = provider.chain_info().await?; let params = chain_info.consensus_parameters; - let signing_key = match (wallet_mode, signing_key, default_sign) { - (WalletSelectionMode::ForcWallet, None, false) => { - // TODO: This is a very simple TUI, we should consider adding a nice TUI - // capabilities for selections and answer collection. - let wallet_path = default_wallet_path(); - if !wallet_path.exists() { - let question = format!("Could not find a wallet at {wallet_path:?}, would you like to create a new one? [y/N]: "); - let accepted = ask_user_yes_no_question(&question)?; - let new_options = New { - force: false, - cache_accounts: None, - }; - if accepted { - new_wallet_cli(&wallet_path, new_options)?; - println!("Wallet created successfully."); - // Derive first account for the fresh wallet we created. - new_at_index_cli(&wallet_path, 0)?; - println!("Account derived successfully."); - } else { - anyhow::bail!("Refused to create a new wallet. If you don't want to use forc-wallet, you can sign this transaction manually with --manual-signing flag.") - } - } - let prompt = format!( - "\nPlease provide the password of your encrypted wallet vault at {wallet_path:?}: " - ); - let password = rpassword::prompt_password(prompt)?; - let verification = AccountVerification::Yes(password.clone()); - let accounts = collect_accounts_with_verification(&wallet_path, verification) - .map_err(|e| { - if e.to_string().contains("Mac Mismatch") { - anyhow::anyhow!( - "Failed to access forc-wallet vault. Please check your password" - ) - } else { - e - } - })?; - let account_balances = collect_account_balances(&accounts, &provider).await?; - - let total_balance = account_balances - .iter() - .flat_map(|account| account.values()) - .sum::(); - if total_balance == 0 { - let first_account = accounts - .get(&0) - .ok_or_else(|| anyhow::anyhow!("No account derived for this wallet"))?; - let target = Target::from_str(&chain_info.name).unwrap_or(Target::testnet()); - let faucet_link = format!("{}/?address={first_account}", target.faucet_url()); - anyhow::bail!("Your wallet does not have any funds to pay for the transaction.\ - \n\nIf you are interacting with a testnet consider using the faucet.\ - \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 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::()?; - if accounts.contains_key(&account_index) { - break; - } - let options: Vec = accounts.keys().map(|key| key.to_string()).collect(); - println_warning(&format!( - "\"{}\" is not a valid account.\nPlease choose a valid option from {}", - account_index, - options.join(","), - )); - } - - let secret_key = derive_secret_key(&wallet_path, account_index, &password) - .map_err(|e| { - if e.to_string().contains("Mac Mismatch") { - anyhow::anyhow!( - "Failed to access forc-wallet vault. Please check your password" - ) - } else { - e - } - })?; - - // TODO: Do this via forc-wallet once the functionality is exposed. - let public_key = PublicKey::from(&secret_key); - let hashed = public_key.hash(); - let bech32 = Bech32Address::new(FUEL_BECH32_HRP, hashed); - let question = format!( - "Do you agree to sign this transaction with {}? [y/N]: ", - bech32 - ); - let accepted = ask_user_yes_no_question(&question)?; - if !accepted { - anyhow::bail!("User refused to sign"); - } - - Some(secret_key) - } - (WalletSelectionMode::ForcWallet, Some(key), _) => { - println_warning("Signing key is provided while requesting to sign with forc-wallet or with default signer. Using signing key"); - Some(key) - } - (WalletSelectionMode::Manual, None, false) => None, - (WalletSelectionMode::Manual, Some(key), false) => Some(key), - (_, None, true) => { - // Generate a `SecretKey` to sign this transaction from a default private key used - // by fuel-core. - let secret_key = SecretKey::from_str(DEFAULT_PRIVATE_KEY)?; - Some(secret_key) - } - (WalletSelectionMode::Manual, Some(key), true) => { - println_warning("Signing key is provided while requesting to sign with a default signer. Using signing key"); - Some(key) - } - }; + let signing_key = + select_secret_key(wallet_mode, default_sign, signing_key, &provider).await?; // Get the address let address = if let Some(key) = signing_key { Address::from(*key.public_key().hash()) } else { + // TODO: Remove this path https://github.com/FuelLabs/sway/issues/6071 Address::from(prompt_address()?) };