From 44b6bb0e7c81bf58228a2129d428179ac6ddf16a Mon Sep 17 00:00:00 2001 From: JimmyFate Date: Thu, 21 Mar 2024 01:43:12 +0100 Subject: [PATCH] sozo keystore, sozo account --- Cargo.lock | 55 ++- bin/sozo/Cargo.toml | 5 +- bin/sozo/src/args.rs | 6 + bin/sozo/src/commands/account.rs | 124 +++++++ bin/sozo/src/commands/keystore.rs | 104 ++++++ bin/sozo/src/commands/mod.rs | 4 + bin/sozo/src/commands/options/account.rs | 226 +----------- bin/sozo/src/commands/options/fee.rs | 101 ++++++ bin/sozo/src/commands/options/mod.rs | 2 + bin/sozo/src/commands/options/signer.rs | 237 +++++++++++++ crates/sozo/ops/Cargo.toml | 6 +- crates/sozo/ops/src/account.rs | 424 +++++++++++++++++++++++ crates/sozo/ops/src/keystore.rs | 140 ++++++++ crates/sozo/ops/src/lib.rs | 2 + 14 files changed, 1215 insertions(+), 221 deletions(-) create mode 100644 bin/sozo/src/commands/account.rs create mode 100644 bin/sozo/src/commands/keystore.rs create mode 100644 bin/sozo/src/commands/options/fee.rs create mode 100644 bin/sozo/src/commands/options/signer.rs create mode 100644 crates/sozo/ops/src/account.rs create mode 100644 crates/sozo/ops/src/keystore.rs diff --git a/Cargo.lock b/Cargo.lock index ba897b4e69..34d1ad10cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,6 +992,19 @@ dependencies = [ "serde", ] +[[package]] +name = "bigdecimal" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9324c8014cd04590682b34f1e9448d38f0674d0f7b2dc553331016ef0e4e9ebc" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits 0.2.17", +] + [[package]] name = "bincode" version = "1.3.3" @@ -2485,6 +2498,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "colored_json" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cb9ce6b86f6e54bfa9518df2eeeef65d424ec7244d083ed97229185e366a91" +dependencies = [ + "is-terminal", + "serde", + "serde_json", + "yansi", +] + [[package]] name = "combine" version = "4.6.6" @@ -9892,6 +9917,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rpassword" +version = "7.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.48.0", +] + [[package]] name = "rsa" version = "0.9.6" @@ -9979,6 +10015,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "rtoolbox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "rtp" version = "0.9.0" @@ -11078,6 +11124,7 @@ dependencies = [ "anyhow", "assert_fs", "async-trait", + "bigdecimal 0.4.3", "cainome 0.1.5", "cairo-lang-compiler", "cairo-lang-defs", @@ -11104,6 +11151,8 @@ dependencies = [ "katana-runner", "notify", "notify-debouncer-mini", + "num-bigint", + "num-integer", "scarb", "scarb-ui", "semver 1.0.21", @@ -11144,6 +11193,8 @@ dependencies = [ "clap", "clap-verbosity-flag", "clap_complete", + "colored", + "colored_json", "console", "dojo-bindgen", "dojo-lang", @@ -11154,11 +11205,13 @@ dependencies = [ "katana-runner", "notify", "notify-debouncer-mini", + "rpassword", "scarb", "scarb-ui", "semver 1.0.21", "serde", "serde_json", + "serde_with", "smol_str", "snapbox", "starknet 0.9.0", @@ -11636,7 +11689,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "067419451efdea1ee968df8438369960c167e0e905c05b84afd074f50e1d6f3d" dependencies = [ "ark-ff 0.4.2", - "bigdecimal", + "bigdecimal 0.3.1", "crypto-bigint", "getrandom", "hex", diff --git a/bin/sozo/Cargo.toml b/bin/sozo/Cargo.toml index fecb6807cc..cea62c5119 100644 --- a/bin/sozo/Cargo.toml +++ b/bin/sozo/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true +bigdecimal = "0.4.1" cairo-lang-compiler.workspace = true cairo-lang-defs.workspace = true cairo-lang-filesystem.workspace = true @@ -31,12 +32,15 @@ dojo-world = { workspace = true, features = [ "contracts", "metadata", "migratio futures.workspace = true notify = "6.0.1" notify-debouncer-mini = "0.3.0" +num-bigint = "0.4.3" +num-integer = "0.1.45" scarb-ui.workspace = true scarb.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true smol_str.workspace = true +sozo-ops.workspace = true starknet-crypto.workspace = true starknet.workspace = true thiserror.workspace = true @@ -44,7 +48,6 @@ tokio.workspace = true tracing-log = "0.1.3" tracing.workspace = true url.workspace = true -sozo-ops.workspace = true cainome = { git = "https://github.com/cartridge-gg/cainome", tag = "v0.2.2" } diff --git a/bin/sozo/src/args.rs b/bin/sozo/src/args.rs index fc3c7c5f5b..7ba907fe40 100644 --- a/bin/sozo/src/args.rs +++ b/bin/sozo/src/args.rs @@ -7,6 +7,7 @@ use smol_str::SmolStr; use tracing::level_filters::LevelFilter; use tracing_log::AsTrace; +use crate::commands::account::AccountArgs; use crate::commands::auth::AuthArgs; use crate::commands::build::BuildArgs; use crate::commands::clean::CleanArgs; @@ -15,6 +16,7 @@ use crate::commands::dev::DevArgs; use crate::commands::events::EventsArgs; use crate::commands::execute::ExecuteArgs; use crate::commands::init::InitArgs; +use crate::commands::keystore::KeystoreArgs; use crate::commands::migrate::MigrateArgs; use crate::commands::model::ModelArgs; use crate::commands::register::RegisterArgs; @@ -51,6 +53,8 @@ pub struct SozoArgs { #[derive(Subcommand)] pub enum Commands { + #[command(about = "Manage accounts")] + Account(AccountArgs), #[command(about = "Build the world, generating the necessary artifacts for deployment")] Build(BuildArgs), #[command(about = "Initialize a new project")] @@ -74,6 +78,8 @@ pub enum Commands { Events(EventsArgs), #[command(about = "Manage world authorization")] Auth(AuthArgs), + #[clap(about = "Manage keystore files")] + Keystore(KeystoreArgs), #[command(about = "Generate shell completion file for specified shell")] Completions(CompletionsArgs), } diff --git a/bin/sozo/src/commands/account.rs b/bin/sozo/src/commands/account.rs new file mode 100644 index 0000000000..e2c23e7401 --- /dev/null +++ b/bin/sozo/src/commands/account.rs @@ -0,0 +1,124 @@ +// MIT License + +// Copyright (c) 2022 Jonathan LEI + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Args, Subcommand}; +use scarb::core::Config; +use sozo_ops::account; +use starknet::signers::LocalWallet; +use starknet_crypto::FieldElement; + +use super::options::fee::FeeOptions; +use super::options::signer::SignerOptions; +use super::options::starknet::StarknetOptions; +use crate::utils; + +#[derive(Debug, Args)] +pub struct AccountArgs { + #[clap(subcommand)] + command: AccountCommand, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Subcommand)] +pub enum AccountCommand { + #[clap(about = "Create a new account configuration without actually deploying.")] + New { + #[clap(flatten)] + signer: SignerOptions, + + #[clap(long, short, help = "Overwrite the account config file if it already exists")] + force: bool, + + #[clap(help = "Path to save the account config file")] + output: PathBuf, + }, + + #[clap(about = "Deploy account contract with a DeployAccount transaction.")] + Deploy { + #[clap(flatten)] + starknet: StarknetOptions, + + #[clap(flatten)] + signer: SignerOptions, + + #[clap(flatten)] + fee: FeeOptions, + + #[clap(long, help = "Simulate the transaction only")] + simulate: bool, + + #[clap(long, help = "Provide transaction nonce manually")] + nonce: Option, + + #[clap( + long, + env = "STARKNET_POLL_INTERVAL", + default_value = "5000", + help = "Transaction result poll interval in milliseconds" + )] + poll_interval: u64, + + #[clap(help = "Path to the account config file")] + file: PathBuf, + }, +} + +impl AccountArgs { + pub fn run(self, config: &Config) -> Result<()> { + let env_metadata = utils::load_metadata_from_config(config)?; + + config.tokio_handle().block_on(async { + match self.command { + AccountCommand::New { signer, force, output } => { + let signer: LocalWallet = signer.signer(env_metadata.as_ref()).unwrap(); + account::new(signer, force, output).await + } + AccountCommand::Deploy { + starknet, + signer, + fee, + simulate, + nonce, + poll_interval, + file, + } => { + let provider = starknet.provider(env_metadata.as_ref()).unwrap(); + let signer = signer.signer(env_metadata.as_ref()).unwrap(); + let fee_setting = fee.into_setting()?; + account::deploy( + provider, + signer, + fee_setting, + simulate, + nonce, + poll_interval, + file, + ) + .await + } + } + }) + } +} diff --git a/bin/sozo/src/commands/keystore.rs b/bin/sozo/src/commands/keystore.rs new file mode 100644 index 0000000000..a04be6f5c7 --- /dev/null +++ b/bin/sozo/src/commands/keystore.rs @@ -0,0 +1,104 @@ +// MIT License + +// Copyright (c) 2022 Jonathan LEI + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Args, Subcommand}; +use sozo_ops::keystore; + +#[derive(Debug, Args)] +pub struct KeystoreArgs { + #[clap(subcommand)] + command: KeystoreCommand, +} + +#[derive(Debug, Subcommand)] +pub enum KeystoreCommand { + #[clap(about = "Randomly generate a new keystore.")] + New { + #[clap(long, help = "Supply password from command line option instead of prompt")] + password: Option, + + #[clap(long, help = "Overwrite the file if it already exists")] + force: bool, + + #[clap(help = "Path to save the JSON keystore")] + file: PathBuf, + }, + + #[clap(about = "Create a keystore file from an existing private key.")] + FromKey { + #[clap(long, help = "Overwrite the file if it already exists")] + force: bool, + + #[clap(long, help = "Take the private key from stdin instead of prompt")] + private_key_stdin: bool, + + #[clap(long, help = "Supply password from command line option instead of prompt")] + password: Option, + + #[clap(help = "Path to save the JSON keystore")] + file: PathBuf, + }, + + #[clap(about = "Check the public key of an existing keystore file.")] + Inspect { + #[clap(long, help = "Supply password from command line option instead of prompt")] + password: Option, + + #[clap(long, help = "Print the public key only")] + raw: bool, + + #[clap(help = "Path to the JSON keystore")] + file: PathBuf, + }, + + #[clap(about = "Check the private key of an existing keystore file.")] + InspectPrivate { + #[clap(long, help = "Supply password from command line option instead of prompt")] + password: Option, + + #[clap(long, help = "Print the private key only")] + raw: bool, + + #[clap(help = "Path to the JSON keystore")] + file: PathBuf, + }, +} + +impl KeystoreArgs { + pub fn run(self) -> Result<()> { + match self.command { + KeystoreCommand::New { password, force, file } => keystore::new(password, force, file), + KeystoreCommand::FromKey { force, private_key_stdin, password, file } => { + keystore::from_key(force, private_key_stdin, password, file) + } + KeystoreCommand::Inspect { password, raw, file } => { + keystore::inspect(password, raw, file) + } + KeystoreCommand::InspectPrivate { password, raw, file } => { + keystore::inspect_private(password, raw, file) + } + } + } +} diff --git a/bin/sozo/src/commands/mod.rs b/bin/sozo/src/commands/mod.rs index fce588da4f..e1a4e2753b 100644 --- a/bin/sozo/src/commands/mod.rs +++ b/bin/sozo/src/commands/mod.rs @@ -3,6 +3,7 @@ use scarb::core::Config; use crate::args::Commands; +pub(crate) mod account; pub(crate) mod auth; pub(crate) mod build; pub(crate) mod clean; @@ -11,6 +12,7 @@ pub(crate) mod dev; pub(crate) mod events; pub(crate) mod execute; pub(crate) mod init; +pub(crate) mod keystore; pub(crate) mod migrate; pub(crate) mod model; pub(crate) mod options; @@ -19,6 +21,7 @@ pub(crate) mod test; pub fn run(command: Commands, config: &Config) -> Result<()> { match command { + Commands::Account(args) => args.run(config), Commands::Init(args) => args.run(config), Commands::Clean(args) => args.run(config), Commands::Test(args) => args.run(config), @@ -30,6 +33,7 @@ pub fn run(command: Commands, config: &Config) -> Result<()> { Commands::Model(args) => args.run(config), Commands::Register(args) => args.run(config), Commands::Events(args) => args.run(config), + Commands::Keystore(args) => args.run(), Commands::Completions(args) => args.run(), } } diff --git a/bin/sozo/src/commands/options/account.rs b/bin/sozo/src/commands/options/account.rs index 1538e8f106..71ae8a3fd0 100644 --- a/bin/sozo/src/commands/options/account.rs +++ b/bin/sozo/src/commands/options/account.rs @@ -6,41 +6,20 @@ use dojo_world::metadata::Environment; use starknet::accounts::{ExecutionEncoding, SingleOwnerAccount}; use starknet::core::types::FieldElement; use starknet::providers::Provider; -use starknet::signers::{LocalWallet, SigningKey}; +use starknet::signers::LocalWallet; -use super::{ - DOJO_ACCOUNT_ADDRESS_ENV_VAR, DOJO_KEYSTORE_PASSWORD_ENV_VAR, DOJO_KEYSTORE_PATH_ENV_VAR, - DOJO_PRIVATE_KEY_ENV_VAR, -}; +use super::signer::SignerOptions; +use super::DOJO_ACCOUNT_ADDRESS_ENV_VAR; #[derive(Debug, Args)] #[command(next_help_heading = "Account options")] -// INVARIANT: -// - For commandline: we can either specify `private_key` or `keystore_path` along with -// `keystore_password`. This is enforced by Clap. -// - For `Scarb.toml`: if both private_key and keystore are specified in `Scarb.toml` private_key -// will take priority pub struct AccountOptions { #[arg(long, env = DOJO_ACCOUNT_ADDRESS_ENV_VAR)] pub account_address: Option, - #[arg(long, env = DOJO_PRIVATE_KEY_ENV_VAR)] - #[arg(conflicts_with = "keystore_path")] - #[arg(help_heading = "Signer options - RAW")] - #[arg(help = "The raw private key associated with the account contract.")] - pub private_key: Option, - - #[arg(long = "keystore", env = DOJO_KEYSTORE_PATH_ENV_VAR)] - #[arg(value_name = "PATH")] - #[arg(help_heading = "Signer options - KEYSTORE")] - #[arg(help = "Use the keystore in the given folder or file.")] - pub keystore_path: Option, - - #[arg(long = "password", env = DOJO_KEYSTORE_PASSWORD_ENV_VAR)] - #[arg(value_name = "PASSWORD")] - #[arg(help_heading = "Signer options - KEYSTORE")] - #[arg(help = "The keystore password. Used with --keystore.")] - pub keystore_password: Option, + #[command(flatten)] + #[command(next_help_heading = "Signer options")] + pub signer: SignerOptions, #[arg(long)] #[arg(help = "Use legacy account (cairo0 account)")] @@ -57,7 +36,7 @@ impl AccountOptions { P: Provider + Send + Sync, { let account_address = self.account_address(env_metadata)?; - let signer = self.signer(env_metadata)?; + let signer = self.signer.signer(env_metadata)?; let chain_id = provider.chain_id().await.with_context(|| "Failed to retrieve network chain id.")?; @@ -67,39 +46,6 @@ impl AccountOptions { Ok(SingleOwnerAccount::new(provider, signer, account_address, chain_id, encoding)) } - fn signer(&self, env_metadata: Option<&Environment>) -> Result { - if let Some(private_key) = - self.private_key.as_deref().or_else(|| env_metadata.and_then(|env| env.private_key())) - { - return Ok(LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key)?, - ))); - } - - if let Some(path) = &self - .keystore_path - .as_deref() - .or_else(|| env_metadata.and_then(|env| env.keystore_path())) - { - if let Some(password) = self - .keystore_password - .as_deref() - .or_else(|| env_metadata.and_then(|env| env.keystore_password())) - { - return Ok(LocalWallet::from_signing_key(SigningKey::from_keystore( - path, password, - )?)); - } else { - return Err(anyhow!("Keystore path is specified but password is not.")); - } - } - - Err(anyhow!( - "Could not find private key. Please specify the private key or path to the keystore \ - file." - )) - } - fn account_address(&self, env_metadata: Option<&Environment>) -> Result { if let Some(address) = self.account_address { Ok(address) @@ -116,17 +62,11 @@ impl AccountOptions { #[cfg(test)] mod tests { - use std::str::FromStr; - use clap::Parser; use starknet::accounts::{Call, ExecutionEncoder}; - use starknet::signers::{LocalWallet, Signer, SigningKey}; use starknet_crypto::FieldElement; - use super::{ - AccountOptions, DOJO_ACCOUNT_ADDRESS_ENV_VAR, DOJO_KEYSTORE_PASSWORD_ENV_VAR, - DOJO_PRIVATE_KEY_ENV_VAR, - }; + use super::{AccountOptions, DOJO_ACCOUNT_ADDRESS_ENV_VAR}; #[derive(clap::Parser, Debug)] struct Command { @@ -142,22 +82,6 @@ mod tests { assert_eq!(cmd.account.account_address, Some(FieldElement::from_hex_be("0x0").unwrap())); } - #[test] - fn private_key_read_from_env_variable() { - std::env::set_var(DOJO_PRIVATE_KEY_ENV_VAR, "private_key"); - - let cmd = Command::parse_from(["sozo", "--account-address", "0x0"]); - assert_eq!(cmd.account.private_key, Some("private_key".to_owned())); - } - - #[test] - fn keystore_path_read_from_env_variable() { - std::env::set_var(DOJO_KEYSTORE_PASSWORD_ENV_VAR, "keystore_password"); - - let cmd = Command::parse_from(["sozo", "--keystore", "./some/path"]); - assert_eq!(cmd.account.keystore_password, Some("keystore_password".to_owned())); - } - #[test] fn account_address_from_args() { let cmd = Command::parse_from(["sozo", "--account-address", "0x0"]); @@ -201,122 +125,6 @@ mod tests { assert!(cmd.account.account_address(None).is_err()); } - #[tokio::test] - async fn private_key_from_args() { - let private_key = "0x1"; - - let cmd = - Command::parse_from(["sozo", "--account-address", "0x0", "--private-key", private_key]); - let result_wallet = cmd.account.signer(None).unwrap(); - let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key).unwrap(), - )); - - let result_public_key = result_wallet.get_public_key().await.unwrap(); - let expected_public_key = expected_wallet.get_public_key().await.unwrap(); - assert!(result_public_key.scalar() == expected_public_key.scalar()); - } - - #[tokio::test] - async fn private_key_from_env_metadata() { - let private_key = "0x1"; - let env_metadata = dojo_world::metadata::Environment { - private_key: Some(private_key.to_owned()), - ..Default::default() - }; - - let cmd = Command::parse_from(["sozo", "--account-address", "0x0"]); - let result_wallet = cmd.account.signer(Some(&env_metadata)).unwrap(); - let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key).unwrap(), - )); - - let result_public_key = result_wallet.get_public_key().await.unwrap(); - let expected_public_key = expected_wallet.get_public_key().await.unwrap(); - assert!(result_public_key.scalar() == expected_public_key.scalar()); - } - - #[tokio::test] - async fn keystore_path_and_keystore_password_from_args() { - let keystore_path = "./tests/test_data/keystore/test.json"; - let keystore_password = "dojoftw"; - let private_key = "0x1"; - - let cmd = Command::parse_from([ - "sozo", - "--keystore", - keystore_path, - "--password", - keystore_password, - ]); - let result_wallet = cmd.account.signer(None).unwrap(); - let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key).unwrap(), - )); - - let result_public_key = result_wallet.get_public_key().await.unwrap(); - let expected_public_key = expected_wallet.get_public_key().await.unwrap(); - assert!(result_public_key.scalar() == expected_public_key.scalar()); - } - - #[tokio::test] - async fn keystore_path_from_env_metadata() { - let keystore_path = "./tests/test_data/keystore/test.json"; - let keystore_password = "dojoftw"; - - let private_key = "0x1"; - let env_metadata = dojo_world::metadata::Environment { - keystore_path: Some(keystore_path.to_owned()), - ..Default::default() - }; - - let cmd = Command::parse_from(["sozo", "--password", keystore_password]); - let result_wallet = cmd.account.signer(Some(&env_metadata)).unwrap(); - let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key).unwrap(), - )); - - let result_public_key = result_wallet.get_public_key().await.unwrap(); - let expected_public_key = expected_wallet.get_public_key().await.unwrap(); - assert!(result_public_key.scalar() == expected_public_key.scalar()); - } - - #[tokio::test] - async fn keystore_password_from_env_metadata() { - let keystore_path = "./tests/test_data/keystore/test.json"; - let keystore_password = "dojoftw"; - let private_key = "0x1"; - - let env_metadata = dojo_world::metadata::Environment { - keystore_password: Some(keystore_password.to_owned()), - ..Default::default() - }; - - let cmd = Command::parse_from(["sozo", "--keystore", keystore_path]); - let result_wallet = cmd.account.signer(Some(&env_metadata)).unwrap(); - let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( - FieldElement::from_str(private_key).unwrap(), - )); - - let result_public_key = result_wallet.get_public_key().await.unwrap(); - let expected_public_key = expected_wallet.get_public_key().await.unwrap(); - assert!(result_public_key.scalar() == expected_public_key.scalar()); - } - - #[test] - fn dont_allow_both_private_key_and_keystore() { - let keystore_path = "./tests/test_data/keystore/test.json"; - let private_key = "0x1"; - let parse_result = Command::try_parse_from([ - "sozo", - "--keystore", - keystore_path, - "--private_key", - private_key, - ]); - assert!(parse_result.is_err()); - } - #[katana_runner::katana_test(2, true, "katana", "")] async fn legacy_flag_works_as_expected() { let cmd = Command::parse_from([ @@ -363,22 +171,4 @@ mod tests { // 0x2 is the Calldata len. assert!(*result.get(3).unwrap() == FieldElement::from_hex_be("0x2").unwrap()); } - - #[test] - fn keystore_path_without_keystore_password() { - let keystore_path = "./tests/test_data/keystore/test.json"; - - let cmd = Command::parse_from(["sozo", "--keystore", keystore_path]); - let result = cmd.account.signer(None); - - assert!(result.is_err()); - } - - #[test] - fn signer_without_pk_or_keystore() { - let cmd = Command::parse_from(["sozo"]); - let result = cmd.account.signer(None); - - assert!(result.is_err()); - } } diff --git a/bin/sozo/src/commands/options/fee.rs b/bin/sozo/src/commands/options/fee.rs new file mode 100644 index 0000000000..7fa13e0826 --- /dev/null +++ b/bin/sozo/src/commands/options/fee.rs @@ -0,0 +1,101 @@ +// MIT License + +// Copyright (c) 2022 Jonathan LEI + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use anyhow::Result; +use bigdecimal::{BigDecimal, Zero}; +use clap::Args; +use num_integer::Integer; +use sozo_ops::account::FeeSetting; +use starknet::macros::felt; +use starknet_crypto::FieldElement; + +#[derive(Debug, Args, Clone)] +#[command(next_help_heading = "Fee options")] +pub struct FeeOptions { + #[clap(long, help = "Maximum transaction fee in Ether (18 decimals)")] + max_fee: Option, + + #[clap(long, help = "Maximum transaction fee in Wei")] + max_fee_raw: Option, + + #[clap(long, help = "Only estimate transaction fee without sending transaction")] + estimate_only: bool, +} + +impl FeeOptions { + pub fn into_setting(self) -> Result { + match (self.max_fee, self.max_fee_raw, self.estimate_only) { + (Some(max_fee), None, false) => { + let max_fee_felt = bigdecimal_to_felt(&max_fee, 18)?; + + // The user is most likely making a mistake for using a max fee higher than 1 ETH + if max_fee_felt > felt!("1000000000000000000") { + anyhow::bail!( + "the --max-fee value is too large. --max-fee expects a value in Ether (18 \ + decimals). Use --max-fee-raw instead to use a raw max_fee amount in Wei." + ) + } + + Ok(FeeSetting::Manual(max_fee_felt)) + } + (None, Some(max_fee_raw), false) => Ok(FeeSetting::Manual(max_fee_raw)), + (None, None, true) => Ok(FeeSetting::EstimateOnly), + (None, None, false) => Ok(FeeSetting::None), + _ => Err(anyhow::anyhow!( + "invalid fee option. At most one of --max-fee, --max-fee-raw, and --estimate-only \ + can be used." + )), + } + } +} + +#[allow(clippy::comparison_chain)] +fn bigdecimal_to_felt(dec: &BigDecimal, decimals: D) -> Result +where + D: Into, +{ + let decimals: i64 = decimals.into(); + + // Scale the bigint part up or down + let (bigint, exponent) = dec.as_bigint_and_exponent(); + + let mut biguint = match bigint.to_biguint() { + Some(value) => value, + None => anyhow::bail!("too many decimal places"), + }; + + if exponent < decimals { + for _ in 0..(decimals - exponent) { + biguint *= 10u32; + } + } else if exponent > decimals { + for _ in 0..(exponent - decimals) { + let (quotient, remainder) = biguint.div_rem(&10u32.into()); + if !remainder.is_zero() { + anyhow::bail!("too many decimal places") + } + biguint = quotient; + } + } + + Ok(FieldElement::from_byte_slice_be(&biguint.to_bytes_be())?) +} diff --git a/bin/sozo/src/commands/options/mod.rs b/bin/sozo/src/commands/options/mod.rs index 0bd599bcc1..9f817439fd 100644 --- a/bin/sozo/src/commands/options/mod.rs +++ b/bin/sozo/src/commands/options/mod.rs @@ -1,4 +1,6 @@ pub mod account; +pub mod fee; +pub mod signer; pub mod starknet; pub mod transaction; pub mod world; diff --git a/bin/sozo/src/commands/options/signer.rs b/bin/sozo/src/commands/options/signer.rs new file mode 100644 index 0000000000..eae8668759 --- /dev/null +++ b/bin/sozo/src/commands/options/signer.rs @@ -0,0 +1,237 @@ +use std::str::FromStr; + +use anyhow::{anyhow, Result}; +use clap::Args; +use dojo_world::metadata::Environment; +use starknet::core::types::FieldElement; +use starknet::signers::{LocalWallet, SigningKey}; + +use super::{DOJO_KEYSTORE_PASSWORD_ENV_VAR, DOJO_KEYSTORE_PATH_ENV_VAR, DOJO_PRIVATE_KEY_ENV_VAR}; + +#[derive(Debug, Args)] +#[command(next_help_heading = "Signer options")] +// INVARIANT: +// - For commandline: we can either specify `private_key` or `keystore_path` along with +// `keystore_password`. This is enforced by Clap. +// - For `Scarb.toml`: if both private_key and keystore are specified in `Scarb.toml` private_key +// will take priority +pub struct SignerOptions { + #[arg(long, env = DOJO_PRIVATE_KEY_ENV_VAR)] + #[arg(conflicts_with = "keystore_path")] + #[arg(help_heading = "Signer options - RAW")] + #[arg(help = "The raw private key associated with the account contract.")] + pub private_key: Option, + + #[arg(long = "keystore", env = DOJO_KEYSTORE_PATH_ENV_VAR)] + #[arg(value_name = "PATH")] + #[arg(help_heading = "Signer options - KEYSTORE")] + #[arg(help = "Use the keystore in the given folder or file.")] + pub keystore_path: Option, + + #[arg(long = "password", env = DOJO_KEYSTORE_PASSWORD_ENV_VAR)] + #[arg(value_name = "PASSWORD")] + #[arg(help_heading = "Signer options - KEYSTORE")] + #[arg(help = "The keystore password. Used with --keystore.")] + pub keystore_password: Option, +} + +impl SignerOptions { + pub fn signer(&self, env_metadata: Option<&Environment>) -> Result { + if let Some(private_key) = + self.private_key.as_deref().or_else(|| env_metadata.and_then(|env| env.private_key())) + { + return Ok(LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key)?, + ))); + } + + if let Some(path) = &self + .keystore_path + .as_deref() + .or_else(|| env_metadata.and_then(|env| env.keystore_path())) + { + if let Some(password) = self + .keystore_password + .as_deref() + .or_else(|| env_metadata.and_then(|env| env.keystore_password())) + { + return Ok(LocalWallet::from_signing_key(SigningKey::from_keystore( + path, password, + )?)); + } else { + return Err(anyhow!("Keystore path is specified but password is not.")); + } + } + + Err(anyhow!( + "Could not find private key. Please specify the private key or path to the keystore \ + file." + )) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use clap::Parser; + use starknet::signers::{LocalWallet, Signer, SigningKey}; + use starknet_crypto::FieldElement; + + use super::{SignerOptions, DOJO_KEYSTORE_PASSWORD_ENV_VAR, DOJO_PRIVATE_KEY_ENV_VAR}; + + #[derive(clap::Parser, Debug)] + struct Command { + #[clap(flatten)] + pub signer: SignerOptions, + } + + #[test] + fn private_key_read_from_env_variable() { + std::env::set_var(DOJO_PRIVATE_KEY_ENV_VAR, "private_key"); + + let cmd = Command::parse_from(["sozo"]); + assert_eq!(cmd.signer.private_key, Some("private_key".to_owned())); + } + + #[test] + fn keystore_path_read_from_env_variable() { + std::env::set_var(DOJO_KEYSTORE_PASSWORD_ENV_VAR, "keystore_password"); + + let cmd = Command::parse_from(["sozo", "--keystore", "./some/path"]); + assert_eq!(cmd.signer.keystore_password, Some("keystore_password".to_owned())); + } + + #[tokio::test] + async fn private_key_from_args() { + let private_key = "0x1"; + + let cmd = Command::parse_from(["sozo", "--private-key", private_key]); + let result_wallet = cmd.signer.signer(None).unwrap(); + let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key).unwrap(), + )); + + let result_public_key = result_wallet.get_public_key().await.unwrap(); + let expected_public_key = expected_wallet.get_public_key().await.unwrap(); + assert!(result_public_key.scalar() == expected_public_key.scalar()); + } + + #[tokio::test] + async fn private_key_from_env_metadata() { + let private_key = "0x1"; + let env_metadata = dojo_world::metadata::Environment { + private_key: Some(private_key.to_owned()), + ..Default::default() + }; + + let cmd = Command::parse_from(["sozo"]); + let result_wallet = cmd.signer.signer(Some(&env_metadata)).unwrap(); + let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key).unwrap(), + )); + + let result_public_key = result_wallet.get_public_key().await.unwrap(); + let expected_public_key = expected_wallet.get_public_key().await.unwrap(); + assert!(result_public_key.scalar() == expected_public_key.scalar()); + } + + #[tokio::test] + async fn keystore_path_and_keystore_password_from_args() { + let keystore_path = "./tests/test_data/keystore/test.json"; + let keystore_password = "dojoftw"; + let private_key = "0x1"; + + let cmd = Command::parse_from([ + "sozo", + "--keystore", + keystore_path, + "--password", + keystore_password, + ]); + let result_wallet = cmd.signer.signer(None).unwrap(); + let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key).unwrap(), + )); + + let result_public_key = result_wallet.get_public_key().await.unwrap(); + let expected_public_key = expected_wallet.get_public_key().await.unwrap(); + assert!(result_public_key.scalar() == expected_public_key.scalar()); + } + + #[tokio::test] + async fn keystore_path_from_env_metadata() { + let keystore_path = "./tests/test_data/keystore/test.json"; + let keystore_password = "dojoftw"; + + let private_key = "0x1"; + let env_metadata = dojo_world::metadata::Environment { + keystore_path: Some(keystore_path.to_owned()), + ..Default::default() + }; + + let cmd = Command::parse_from(["sozo", "--password", keystore_password]); + let result_wallet = cmd.signer.signer(Some(&env_metadata)).unwrap(); + let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key).unwrap(), + )); + + let result_public_key = result_wallet.get_public_key().await.unwrap(); + let expected_public_key = expected_wallet.get_public_key().await.unwrap(); + assert!(result_public_key.scalar() == expected_public_key.scalar()); + } + + #[tokio::test] + async fn keystore_password_from_env_metadata() { + let keystore_path = "./tests/test_data/keystore/test.json"; + let keystore_password = "dojoftw"; + let private_key = "0x1"; + + let env_metadata = dojo_world::metadata::Environment { + keystore_password: Some(keystore_password.to_owned()), + ..Default::default() + }; + + let cmd = Command::parse_from(["sozo", "--keystore", keystore_path]); + let result_wallet = cmd.signer.signer(Some(&env_metadata)).unwrap(); + let expected_wallet = LocalWallet::from_signing_key(SigningKey::from_secret_scalar( + FieldElement::from_str(private_key).unwrap(), + )); + + let result_public_key = result_wallet.get_public_key().await.unwrap(); + let expected_public_key = expected_wallet.get_public_key().await.unwrap(); + assert!(result_public_key.scalar() == expected_public_key.scalar()); + } + + #[test] + fn dont_allow_both_private_key_and_keystore() { + let keystore_path = "./tests/test_data/keystore/test.json"; + let private_key = "0x1"; + let parse_result = Command::try_parse_from([ + "sozo", + "--keystore", + keystore_path, + "--private_key", + private_key, + ]); + assert!(parse_result.is_err()); + } + + #[test] + fn keystore_path_without_keystore_password() { + let keystore_path = "./tests/test_data/keystore/test.json"; + + let cmd = Command::parse_from(["sozo", "--keystore", keystore_path]); + let result = cmd.signer.signer(None); + + assert!(result.is_err()); + } + + #[test] + fn signer_without_pk_or_keystore() { + let cmd = Command::parse_from(["sozo"]); + let result = cmd.signer.signer(None); + + assert!(result.is_err()); + } +} diff --git a/crates/sozo/ops/Cargo.toml b/crates/sozo/ops/Cargo.toml index 2472bb87fe..4ce5d81198 100644 --- a/crates/sozo/ops/Cargo.toml +++ b/crates/sozo/ops/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true [dependencies] anyhow.workspace = true async-trait.workspace = true +cainome = { git = "https://github.com/cartridge-gg/cainome", tag = "v0.2.2" } cairo-lang-compiler.workspace = true cairo-lang-defs.workspace = true cairo-lang-filesystem.workspace = true @@ -23,6 +24,8 @@ camino.workspace = true clap-verbosity-flag = "2.0.1" clap.workspace = true clap_complete.workspace = true +colored = "2.0.0" +colored_json = "3.2.0" console.workspace = true dojo-bindgen.workspace = true dojo-lang.workspace = true @@ -31,11 +34,13 @@ dojo-world = { workspace = true, features = [ "contracts", "metadata", "migratio futures.workspace = true notify = "6.0.1" notify-debouncer-mini = "0.3.0" +rpassword = "7.2.0" scarb-ui.workspace = true scarb.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true +serde_with.workspace = true smol_str.workspace = true starknet-crypto.workspace = true starknet.workspace = true @@ -44,7 +49,6 @@ tokio.workspace = true tracing-log = "0.1.3" tracing.workspace = true url.workspace = true -cainome = { git = "https://github.com/cartridge-gg/cainome", tag = "v0.2.2" } [dev-dependencies] assert_fs = "1.0.10" diff --git a/crates/sozo/ops/src/account.rs b/crates/sozo/ops/src/account.rs new file mode 100644 index 0000000000..205f14ded0 --- /dev/null +++ b/crates/sozo/ops/src/account.rs @@ -0,0 +1,424 @@ +// MIT License + +// Copyright (c) 2022 Jonathan LEI + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use std::io::Write; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use colored::Colorize; +use colored_json::{ColorMode, Output}; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use starknet::accounts::{AccountFactory, AccountFactoryError, OpenZeppelinAccountFactory}; +use starknet::core::serde::unsigned_field_element::UfeHex; +use starknet::core::types::{BlockId, BlockTag, ExecutionResult, StarknetError}; +use starknet::core::utils::get_contract_address; +use starknet::macros::felt; +use starknet::providers::jsonrpc::HttpTransport; +use starknet::providers::{JsonRpcClient, Provider, ProviderError}; +use starknet::signers::{LocalWallet, Signer, SigningKey}; +use starknet_crypto::FieldElement; + +// use crate::commands::account::AccountCommand; +// use crate::commands::options::fee::FeeSetting; + +/// The canonical hash of a contract class. This is the class hash value of a contract instance. +pub type ClassHash = FieldElement; + +/// The class hash of DEFAULT_OZ_ACCOUNT_CONTRACT. +/// Corresponds to 0x05400e90f7e0ae78bd02c77cd75527280470e2fe19c54970dd79dc37a9d3645c +pub const DEFAULT_OZ_ACCOUNT_CONTRACT_CLASS_HASH: ClassHash = FieldElement::from_mont([ + 8460675502047588988, + 17729791148444280953, + 7171298771336181387, + 292243705759714441, +]); + +#[derive(Serialize, Deserialize)] +pub struct AccountConfig { + pub version: u64, + pub variant: AccountVariant, + pub deployment: DeploymentStatus, +} + +impl AccountConfig { + pub fn deploy_account_address(&self) -> Result { + let undeployed_status = match &self.deployment { + DeploymentStatus::Undeployed(value) => value, + DeploymentStatus::Deployed(_) => { + anyhow::bail!("account already deployed"); + } + }; + + match &self.variant { + AccountVariant::OpenZeppelin(oz) => Ok(get_contract_address( + undeployed_status.salt, + undeployed_status.class_hash, + &[oz.public_key], + FieldElement::ZERO, + )), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AccountVariant { + OpenZeppelin(OzAccountConfig), +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct OzAccountConfig { + pub version: u64, + #[serde_as(as = "UfeHex")] + pub public_key: FieldElement, + #[serde(default = "true_as_default")] + pub legacy: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum DeploymentStatus { + Undeployed(UndeployedStatus), + Deployed(DeployedStatus), +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct UndeployedStatus { + #[serde_as(as = "UfeHex")] + pub class_hash: FieldElement, + #[serde_as(as = "UfeHex")] + pub salt: FieldElement, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct DeployedStatus { + #[serde_as(as = "UfeHex")] + pub class_hash: FieldElement, + #[serde_as(as = "UfeHex")] + pub address: FieldElement, +} + +enum MaxFeeType { + Manual { max_fee: FieldElement }, + Estimated { estimate: FieldElement, estimate_with_buffer: FieldElement }, +} + +impl MaxFeeType { + pub fn max_fee(&self) -> FieldElement { + match self { + Self::Manual { max_fee } => *max_fee, + Self::Estimated { estimate_with_buffer, .. } => *estimate_with_buffer, + } + } +} + +pub async fn new(signer: LocalWallet, force: bool, output: PathBuf) -> Result<()> { + if output.exists() && !force { + anyhow::bail!("account config file already exists"); + } + + let salt = SigningKey::from_random().secret_scalar(); + + let account_config = AccountConfig { + version: 1, + variant: AccountVariant::OpenZeppelin(OzAccountConfig { + version: 1, + public_key: signer.get_public_key().await?.scalar(), + legacy: false, + }), + deployment: DeploymentStatus::Undeployed(UndeployedStatus { + class_hash: DEFAULT_OZ_ACCOUNT_CONTRACT_CLASS_HASH, + salt, + }), + }; + + let deployed_address = account_config.deploy_account_address()?; + + let mut file = std::fs::File::create(&output)?; + serde_json::to_writer_pretty(&mut file, &account_config)?; + file.write_all(b"\n")?; + + eprintln!("Created new account config file: {}", std::fs::canonicalize(&output)?.display()); + eprintln!(); + eprintln!( + "Once deployed, this account will be available at:\n {}", + format!("{:#064x}", deployed_address).bright_yellow() + ); + eprintln!(); + eprintln!( + "Deploy this account by running:\n {}", + format!("sozo account deploy {}", output.display()).bright_yellow() + ); + + Ok(()) +} + +pub async fn deploy( + provider: JsonRpcClient, + signer: LocalWallet, + fee_setting: FeeSetting, + simulate: bool, + nonce: Option, + poll_interval: u64, + file: PathBuf, +) -> Result<()> { + if simulate && fee_setting.is_estimate_only() { + anyhow::bail!("--simulate cannot be used with --estimate-only"); + } + + if !file.exists() { + anyhow::bail!("account config file not found"); + } + + let mut account: AccountConfig = serde_json::from_reader(&mut std::fs::File::open(&file)?)?; + + let signer_public_key = signer.get_public_key().await?.scalar(); + + let undeployed_status = match &account.deployment { + DeploymentStatus::Undeployed(inner) => inner, + DeploymentStatus::Deployed(_) => { + anyhow::bail!("account already deployed"); + } + }; + + let chain_id = provider.chain_id().await?; + + let factory = match &account.variant { + AccountVariant::OpenZeppelin(oz_config) => { + // Makes sure we're using the right key + if signer_public_key != oz_config.public_key { + anyhow::bail!( + "public key mismatch. Expected: {:#064x}; actual: {:#064x}.", + oz_config.public_key, + signer_public_key + ); + } + + let mut factory = OpenZeppelinAccountFactory::new( + undeployed_status.class_hash, + chain_id, + signer, + &provider, + ) + .await?; + factory.set_block_id(BlockId::Tag(BlockTag::Pending)); + + factory + } + }; + + let account_deployment = factory.deploy(undeployed_status.salt); + + let target_deployment_address = account.deploy_account_address()?; + + // Sanity check. We don't really need to check again here actually + if account_deployment.address() != target_deployment_address { + panic!("Unexpected account deployment address mismatch"); + } + + let max_fee = match fee_setting { + FeeSetting::Manual(fee) => MaxFeeType::Manual { max_fee: fee }, + FeeSetting::EstimateOnly | FeeSetting::None => { + let estimated_fee = account_deployment + .estimate_fee() + .await + .map_err(|err| match err { + AccountFactoryError::Provider(ProviderError::StarknetError(err)) => { + map_starknet_error(err) + } + err => anyhow::anyhow!("{}", err), + })? + .overall_fee; + + let estimated_fee_with_buffer = (estimated_fee * felt!("3")).floor_div(felt!("2")); + + if fee_setting.is_estimate_only() { + println!("{} ETH", format!("{}", estimated_fee.to_big_decimal(18)).bright_yellow(),); + return Ok(()); + } + + MaxFeeType::Estimated { + estimate: estimated_fee, + estimate_with_buffer: estimated_fee_with_buffer, + } + } + }; + + if !simulate { + match max_fee { + MaxFeeType::Manual { max_fee } => { + eprintln!( + "You've manually specified the account deployment fee to be {}. Therefore, \ + fund at least:\n {}", + format!("{} ETH", max_fee.to_big_decimal(18)).bright_yellow(), + format!("{} ETH", max_fee.to_big_decimal(18)).bright_yellow(), + ); + } + MaxFeeType::Estimated { estimate, estimate_with_buffer } => { + eprintln!( + "The estimated account deployment fee is {}. However, to avoid failure, fund \ + at least:\n {}", + format!("{} ETH", estimate.to_big_decimal(18)).bright_yellow(), + format!("{} ETH", estimate_with_buffer.to_big_decimal(18)).bright_yellow() + ); + } + } + + eprintln!( + "to the following address:\n {}", + format!("{:#064x}", target_deployment_address).bright_yellow() + ); + + eprint!("Press [ENTER] once you've funded the address."); + std::io::stdin().read_line(&mut String::new())?; + } + + let account_deployment = match nonce { + Some(nonce) => account_deployment.nonce(nonce), + None => account_deployment, + }; + let account_deployment = account_deployment.max_fee(max_fee.max_fee()); + + if simulate { + let simulation = account_deployment.simulate(false, false).await?; + let simulation_json = serde_json::to_value(simulation)?; + + let simulation_json = + colored_json::to_colored_json(&simulation_json, ColorMode::Auto(Output::StdOut))?; + println!("{simulation_json}"); + return Ok(()); + } + + let account_deployment_tx = account_deployment.send().await?.transaction_hash; + eprintln!( + "Account deployment transaction: {}", + format!("{:#064x}", account_deployment_tx).bright_yellow() + ); + + // By default we wait for the tx to confirm so that we don't incorrectly mark the + // account as deployed + eprintln!( + "Waiting for transaction {} to confirm. If this process is interrupted, you will need to \ + run `{}` to update the account file.", + format!("{:#064x}", account_deployment_tx).bright_yellow(), + "sozo account fetch".bright_yellow(), + ); + watch_tx(&provider, account_deployment_tx, Duration::from_millis(poll_interval)).await?; + + account.deployment = DeploymentStatus::Deployed(DeployedStatus { + class_hash: undeployed_status.class_hash, + address: target_deployment_address, + }); + + // Never write directly to the original file to avoid data loss + let mut temp_file_name = file + .file_name() + .ok_or_else(|| anyhow::anyhow!("unable to determine file name"))? + .to_owned(); + temp_file_name.push(".tmp"); + let mut temp_path = file.clone(); + temp_path.set_file_name(temp_file_name); + + let mut temp_file = std::fs::File::create(&temp_path)?; + serde_json::to_writer_pretty(&mut temp_file, &account)?; + temp_file.write_all(b"\n")?; + std::fs::rename(temp_path, file)?; + + Ok(()) +} + +fn true_as_default() -> bool { + true +} + +fn map_starknet_error(err: StarknetError) -> anyhow::Error { + match err { + StarknetError::ContractError(err) => { + anyhow::anyhow!("ContractError: {}", err.revert_error.trim()) + } + StarknetError::TransactionExecutionError(err) => { + anyhow::anyhow!( + "TransactionExecutionError (tx index {}): {}", + err.transaction_index, + err.execution_error.trim() + ) + } + StarknetError::ValidationFailure(err) => { + anyhow::anyhow!("ValidationFailure: {}", err.trim()) + } + StarknetError::UnexpectedError(err) => { + anyhow::anyhow!("UnexpectedError: {}", err.trim()) + } + err => anyhow::anyhow!("{}", err), + } +} + +pub async fn watch_tx

( + provider: P, + transaction_hash: FieldElement, + poll_interval: Duration, +) -> Result<()> +where + P: Provider, +{ + loop { + match provider.get_transaction_receipt(transaction_hash).await { + Ok(receipt) => match receipt.execution_result() { + ExecutionResult::Succeeded => { + eprintln!( + "Transaction {} confirmed", + format!("{:#064x}", transaction_hash).bright_yellow() + ); + + return Ok(()); + } + ExecutionResult::Reverted { reason } => { + return Err(anyhow::anyhow!("transaction reverted: {}", reason)); + } + }, + Err(ProviderError::StarknetError(StarknetError::TransactionHashNotFound)) => { + eprintln!("Transaction not confirmed yet..."); + } + Err(err) => return Err(err.into()), + } + + tokio::time::sleep(poll_interval).await; + } +} + +#[derive(Debug)] +pub enum FeeSetting { + Manual(FieldElement), + EstimateOnly, + None, +} + +impl FeeSetting { + pub fn is_estimate_only(&self) -> bool { + matches!(self, FeeSetting::EstimateOnly) + } +} diff --git a/crates/sozo/ops/src/keystore.rs b/crates/sozo/ops/src/keystore.rs new file mode 100644 index 0000000000..3ff7d6df57 --- /dev/null +++ b/crates/sozo/ops/src/keystore.rs @@ -0,0 +1,140 @@ +// MIT License + +// Copyright (c) 2022 Jonathan LEI + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +use std::io::Read; +use std::path::PathBuf; + +use anyhow::Result; +use colored::Colorize; +use starknet::signers::SigningKey; +use starknet_crypto::FieldElement; + +const RAW_PASSWORD_WARNING: &str = "WARNING: setting passwords via --password is generally \ + considered insecure, as they will be stored in your shell \ + history or other log files."; + +pub fn new(password: Option, force: bool, file: PathBuf) -> Result<()> { + if password.is_some() { + eprintln!("{}", RAW_PASSWORD_WARNING.bright_magenta()); + } + + if file.exists() && !force { + anyhow::bail!("keystore file already exists"); + } + + let password = get_password(password)?; + + let key = SigningKey::from_random(); + key.save_as_keystore(&file, &password)?; + + println!("Created new encrypted keystore file: {}", std::fs::canonicalize(file)?.display()); + println!("Public key: {}", format!("{:#064x}", key.verifying_key().scalar()).bright_yellow()); + + Ok(()) +} + +pub fn from_key( + force: bool, + private_key_stdin: bool, + password: Option, + file: PathBuf, +) -> Result<()> { + if password.is_some() { + eprintln!("{}", RAW_PASSWORD_WARNING.bright_magenta()); + } + + if file.exists() && !force { + anyhow::bail!("keystore file already exists"); + } + + let private_key = if private_key_stdin { + let mut buffer = String::new(); + std::io::stdin().read_to_string(&mut buffer)?; + + buffer + } else { + rpassword::prompt_password("Enter private key: ")? + }; + let private_key = FieldElement::from_hex_be(private_key.trim())?; + + let password = get_password(password)?; + + let key = SigningKey::from_secret_scalar(private_key); + key.save_as_keystore(&file, &password)?; + + println!("Created new encrypted keystore file: {}", std::fs::canonicalize(file)?.display()); + println!("Public key: {:#064x}", key.verifying_key().scalar()); + + Ok(()) +} + +pub fn inspect(password: Option, raw: bool, file: PathBuf) -> Result<()> { + if password.is_some() { + eprintln!("{}", RAW_PASSWORD_WARNING.bright_magenta()); + } + + if !file.exists() { + anyhow::bail!("keystore file not found"); + } + + let password = get_password(password)?; + + let key = SigningKey::from_keystore(file, &password)?; + + if raw { + println!("{:#064x}", key.verifying_key().scalar()); + } else { + println!("Public key: {:#064x}", key.verifying_key().scalar()); + } + + Ok(()) +} + +pub fn inspect_private(password: Option, raw: bool, file: PathBuf) -> Result<()> { + if password.is_some() { + eprintln!("{}", RAW_PASSWORD_WARNING.bright_magenta()); + } + + if !file.exists() { + anyhow::bail!("keystore file not found"); + } + + let password = get_password(password)?; + + let key = SigningKey::from_keystore(file, &password)?; + + if raw { + println!("{:#064x}", key.secret_scalar()); + } else { + println!("Private key: {:#064x}", key.secret_scalar()); + } + + Ok(()) +} + +fn get_password(password: Option) -> std::io::Result { + if let Some(password) = password { + Ok(password) + } else { + rpassword::prompt_password("Enter password: ") + } +} diff --git a/crates/sozo/ops/src/lib.rs b/crates/sozo/ops/src/lib.rs index 676e8e86b8..acab8a6808 100644 --- a/crates/sozo/ops/src/lib.rs +++ b/crates/sozo/ops/src/lib.rs @@ -4,9 +4,11 @@ use dojo_world::migration::strategy::generate_salt; use starknet::accounts::ConnectedAccount; use starknet::core::types::FieldElement; +pub mod account; pub mod auth; pub mod events; pub mod execute; +pub mod keystore; pub mod migration; pub mod model; pub mod register;