diff --git a/bin/sozo/src/commands/execute.rs b/bin/sozo/src/commands/execute.rs index e9698ddc0d..701113eacc 100644 --- a/bin/sozo/src/commands/execute.rs +++ b/bin/sozo/src/commands/execute.rs @@ -68,7 +68,7 @@ impl ExecuteArgs { self.starknet.url(profile_config.env.as_ref())?, ); - let txn_config: TxnConfig = self.transaction.into(); + let txn_config: TxnConfig = self.transaction.try_into()?; config.tokio_handle().block_on(async { // We could save the world diff computation extracting the account directly from the diff --git a/bin/sozo/src/commands/migrate.rs b/bin/sozo/src/commands/migrate.rs index 30e32c4408..2769ac057d 100644 --- a/bin/sozo/src/commands/migrate.rs +++ b/bin/sozo/src/commands/migrate.rs @@ -61,7 +61,7 @@ impl MigrateArgs { let world_address = world_diff.world_info.address; - let mut txn_config: TxnConfig = self.transaction.into(); + let mut txn_config: TxnConfig = self.transaction.try_into()?; txn_config.wait = true; let migration = Migration::new( diff --git a/bin/sozo/src/commands/options/transaction.rs b/bin/sozo/src/commands/options/transaction.rs index 46920c4066..6e956902c3 100644 --- a/bin/sozo/src/commands/options/transaction.rs +++ b/bin/sozo/src/commands/options/transaction.rs @@ -1,26 +1,49 @@ +use std::fmt::{Display, Formatter}; + use anyhow::{bail, Result}; -use clap::Args; -use dojo_utils::{TxnAction, TxnConfig}; +use clap::builder::PossibleValue; +use clap::{Args, ValueEnum}; +use dojo_utils::{EthFeeConfig, FeeConfig, StrkFeeConfig, TxnAction, TxnConfig}; use starknet::core::types::Felt; #[derive(Debug, Args, Default)] #[command(next_help_heading = "Transaction options")] pub struct TransactionOptions { + #[arg(long)] + #[arg(help = "Fee token to use.")] + #[arg(default_value_t = FeeToken::Strk)] + #[arg(global = true)] + pub fee: FeeToken, + + #[arg(help_heading = "Transaction options - ETH")] #[arg(long, value_name = "MULTIPLIER")] #[arg(help = "The multiplier to use for the fee estimate.")] #[arg(long_help = "The multiplier to use for the fee estimate. This value will be used on \ the estimated fee which will be used as the max fee for the transaction. \ (max_fee = estimated_fee * multiplier)")] - #[arg(conflicts_with = "max_fee_raw")] + #[arg(conflicts_with_all = ["max_fee_raw", "gas", "gas_price"])] #[arg(global = true)] pub fee_estimate_multiplier: Option, + #[arg(help_heading = "Transaction options - ETH")] #[arg(long)] #[arg(help = "Maximum raw value to be used for fees, in Wei.")] - #[arg(conflicts_with = "fee_estimate_multiplier")] + #[arg(conflicts_with_all = ["fee_estimate_multiplier", "gas", "gas_price"])] #[arg(global = true)] pub max_fee_raw: Option, + #[arg(help_heading = "Transaction options - STRK")] + #[arg(long, help = "Maximum L1 gas amount.")] + #[arg(conflicts_with_all = ["max_fee_raw", "fee_estimate_multiplier"])] + #[arg(global = true)] + pub gas: Option, + + #[arg(help_heading = "Transaction options - STRK")] + #[arg(long, help = "Maximum L1 gas price in STRK.")] + #[arg(conflicts_with_all = ["max_fee_raw", "fee_estimate_multiplier"])] + #[arg(global = true)] + pub gas_price: Option, + #[arg(long)] #[arg(help = "Wait until the transaction is accepted by the sequencer, returning the status \ and hash.")] @@ -58,22 +81,184 @@ impl TransactionOptions { (false, false) => Ok(TxnAction::Send { wait: self.wait || self.walnut, receipt: self.receipt, - max_fee_raw: self.max_fee_raw, - fee_estimate_multiplier: self.fee_estimate_multiplier, + fee_config: match self.fee { + FeeToken::Strk => { + FeeConfig::Strk(StrkFeeConfig { gas: self.gas, gas_price: self.gas_price }) + } + FeeToken::Eth => FeeConfig::Eth(EthFeeConfig { + max_fee_raw: self.max_fee_raw, + fee_estimate_multiplier: self.fee_estimate_multiplier, + }), + }, walnut: self.walnut, }), } } } -impl From for TxnConfig { - fn from(value: TransactionOptions) -> Self { - Self { - fee_estimate_multiplier: value.fee_estimate_multiplier, +impl TryFrom for TxnConfig { + type Error = anyhow::Error; + + fn try_from(value: TransactionOptions) -> Result { + match value.fee { + FeeToken::Eth => { + if value.gas.is_some() || value.gas_price.is_some() { + bail!( + "Gas and gas price are not supported for ETH transactions. Use `--fee \ + strk` instead." + ); + } + } + FeeToken::Strk => { + if value.max_fee_raw.is_some() || value.fee_estimate_multiplier.is_some() { + bail!( + "Max fee raw and fee estimate multiplier are not supported for STRK \ + transactions. Use `--fee eth` instead." + ); + } + } + }; + + Ok(Self { wait: value.wait || value.walnut, receipt: value.receipt, - max_fee_raw: value.max_fee_raw, + fee_config: match value.fee { + FeeToken::Strk => { + FeeConfig::Strk(StrkFeeConfig { gas: value.gas, gas_price: value.gas_price }) + } + FeeToken::Eth => FeeConfig::Eth(EthFeeConfig { + max_fee_raw: value.max_fee_raw, + fee_estimate_multiplier: value.fee_estimate_multiplier, + }), + }, walnut: value.walnut, + }) + } +} + +#[derive(Debug, Default, Clone)] +pub enum FeeToken { + #[default] + Strk, + Eth, +} + +impl ValueEnum for FeeToken { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Eth, Self::Strk] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::Eth => Some(PossibleValue::new("ETH").alias("eth")), + Self::Strk => Some(PossibleValue::new("STRK").alias("strk")), } } } + +impl Display for FeeToken { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Eth => write!(f, "ETH"), + Self::Strk => write!(f, "STRK"), + } + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + + use super::*; + + #[test] + fn test_strk_conversion() -> Result<()> { + let opts = TransactionOptions { + wait: true, + receipt: true, + fee: FeeToken::Strk, + gas: Some(1000), + gas_price: Some(100), + max_fee_raw: None, + fee_estimate_multiplier: None, + walnut: false, + }; + + let config: TxnConfig = opts.try_into()?; + + assert!(config.wait); + assert!(config.receipt); + assert!(!config.walnut); + + match config.fee_config { + FeeConfig::Strk(strk_config) => { + assert_eq!(strk_config.gas, Some(1000)); + assert_eq!(strk_config.gas_price, Some(100)); + } + _ => panic!("Expected STRK fee config"), + } + + Ok(()) + } + + #[test] + fn test_eth_conversion() -> Result<()> { + let opts = TransactionOptions { + wait: false, + receipt: true, + fee: FeeToken::Eth, + gas: None, + gas_price: None, + max_fee_raw: Some(Felt::from(1000)), + fee_estimate_multiplier: Some(1.5), + walnut: true, + }; + + let config: TxnConfig = opts.try_into()?; + + assert!(config.wait); + assert!(config.receipt); + assert!(config.walnut); + + match config.fee_config { + FeeConfig::Eth(eth_config) => { + assert_eq!(eth_config.max_fee_raw, Some(Felt::from(1000))); + assert_eq!(eth_config.fee_estimate_multiplier, Some(1.5)); + } + _ => panic!("Expected ETH fee config"), + } + + Ok(()) + } + + #[test] + fn test_invalid_strk_config() { + let opts = TransactionOptions { + fee: FeeToken::Strk, + max_fee_raw: Some(Felt::from(1000)), + fee_estimate_multiplier: Some(1.5), + ..Default::default() + }; + + let result: Result = opts.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_eth_config() { + let opts = TransactionOptions { + fee: FeeToken::Eth, + gas: Some(1000), + gas_price: Some(100), + ..Default::default() + }; + let result: Result = opts.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_fee_token_display() { + assert_eq!(FeeToken::Eth.to_string(), "ETH"); + assert_eq!(FeeToken::Strk.to_string(), "STRK"); + } +} diff --git a/crates/dojo/utils/src/lib.rs b/crates/dojo/utils/src/lib.rs index 380dd650cb..96524d1805 100644 --- a/crates/dojo/utils/src/lib.rs +++ b/crates/dojo/utils/src/lib.rs @@ -8,10 +8,7 @@ pub use tx::deployer::*; pub use tx::error::TransactionError; pub use tx::invoker::*; pub use tx::waiter::*; -pub use tx::{ - get_predeployed_accounts, parse_block_id, TransactionExt, TransactionResult, TxnAction, - TxnConfig, -}; +pub use tx::*; pub mod env; pub mod keystore; diff --git a/crates/dojo/utils/src/tx/declarer.rs b/crates/dojo/utils/src/tx/declarer.rs index c82e94604b..cd08d8f1ca 100644 --- a/crates/dojo/utils/src/tx/declarer.rs +++ b/crates/dojo/utils/src/tx/declarer.rs @@ -15,7 +15,9 @@ use starknet::core::types::{ }; use starknet::providers::{Provider, ProviderError}; -use crate::{TransactionError, TransactionExt, TransactionResult, TransactionWaiter, TxnConfig}; +use crate::{ + FeeConfig, TransactionError, TransactionExt, TransactionResult, TransactionWaiter, TxnConfig, +}; /// A declarer is in charge of declaring contracts. #[derive(Debug)] @@ -92,8 +94,21 @@ where Err(e) => return Err(TransactionError::Provider(e)), } - let DeclareTransactionResult { transaction_hash, class_hash } = - account.declare_v2(Arc::new(class), casm_class_hash).send_with_cfg(txn_config).await?; + let DeclareTransactionResult { transaction_hash, class_hash } = match txn_config.fee_config + { + FeeConfig::Strk(_) => { + account + .declare_v3(Arc::new(class), casm_class_hash) + .send_with_cfg(txn_config) + .await? + } + FeeConfig::Eth(_) => { + account + .declare_v2(Arc::new(class), casm_class_hash) + .send_with_cfg(txn_config) + .await? + } + }; tracing::trace!( transaction_hash = format!("{:#066x}", transaction_hash), diff --git a/crates/dojo/utils/src/tx/deployer.rs b/crates/dojo/utils/src/tx/deployer.rs index 947733f642..f449a17fc8 100644 --- a/crates/dojo/utils/src/tx/deployer.rs +++ b/crates/dojo/utils/src/tx/deployer.rs @@ -9,7 +9,9 @@ use starknet::macros::{felt, selector}; use starknet::providers::{Provider, ProviderError}; use tracing::trace; -use crate::{TransactionError, TransactionExt, TransactionResult, TransactionWaiter, TxnConfig}; +use crate::{ + FeeConfig, TransactionError, TransactionExt, TransactionResult, TransactionWaiter, TxnConfig, +}; const UDC_DEPLOY_SELECTOR: Felt = selector!("deployContract"); const UDC_ADDRESS: Felt = @@ -56,14 +58,18 @@ where return Ok(TransactionResult::Noop); } - let txn = self.account.execute_v1(vec![Call { - calldata: udc_calldata, - selector: UDC_DEPLOY_SELECTOR, - to: UDC_ADDRESS, - }]); + let call = Call { calldata: udc_calldata, selector: UDC_DEPLOY_SELECTOR, to: UDC_ADDRESS }; - let InvokeTransactionResult { transaction_hash } = - txn.send_with_cfg(&self.txn_config).await?; + let InvokeTransactionResult { transaction_hash } = match self.txn_config.fee_config { + FeeConfig::Strk(_) => { + trace!("Deploying with STRK."); + self.account.execute_v3(vec![call]).send_with_cfg(&self.txn_config).await? + } + FeeConfig::Eth(_) => { + trace!("Deploying with ETH."); + self.account.execute_v1(vec![call]).send_with_cfg(&self.txn_config).await? + } + }; trace!( transaction_hash = format!("{:#066x}", transaction_hash), diff --git a/crates/dojo/utils/src/tx/error.rs b/crates/dojo/utils/src/tx/error.rs index 4a80eacb63..9a5d92b550 100644 --- a/crates/dojo/utils/src/tx/error.rs +++ b/crates/dojo/utils/src/tx/error.rs @@ -17,6 +17,8 @@ where Provider(ProviderError), #[error("{0}")] TransactionExecution(String), + #[error("{0}")] + TransactionValidation(String), #[error(transparent)] TransactionWaiting(#[from] TransactionWaitingError), #[error(transparent)] @@ -51,6 +53,9 @@ where ProviderError::StarknetError(StarknetError::TransactionExecutionError(te)) => { TransactionError::TransactionExecution(te.execution_error.clone()) } + ProviderError::StarknetError(StarknetError::ValidationFailure(ve)) => { + TransactionError::TransactionExecution(ve.to_string()) + } _ => TransactionError::Provider(value), } } diff --git a/crates/dojo/utils/src/tx/invoker.rs b/crates/dojo/utils/src/tx/invoker.rs index 796b051b68..9178bfaf80 100644 --- a/crates/dojo/utils/src/tx/invoker.rs +++ b/crates/dojo/utils/src/tx/invoker.rs @@ -5,6 +5,7 @@ use starknet::core::types::Call; use tracing::trace; use super::TransactionResult; +use crate::tx::FeeConfig; use crate::{TransactionError, TransactionExt, TransactionWaiter, TxnConfig}; #[derive(Debug)] @@ -54,7 +55,16 @@ where ) -> Result> { trace!(?call, "Invoke contract."); - let tx = self.account.execute_v1(vec![call]).send_with_cfg(&self.txn_config).await?; + let tx = match self.txn_config.fee_config { + FeeConfig::Strk(config) => { + trace!(?config, "Invoking with STRK."); + self.account.execute_v3(vec![call]).send_with_cfg(&self.txn_config).await? + } + FeeConfig::Eth(config) => { + trace!(?config, "Invoking with ETH."); + self.account.execute_v1(vec![call]).send_with_cfg(&self.txn_config).await? + } + }; trace!(transaction_hash = format!("{:#066x}", tx.transaction_hash), "Invoke contract."); @@ -78,8 +88,16 @@ where trace!(?self.calls, "Invoke contract multicall."); - let tx = - self.account.execute_v1(self.calls.clone()).send_with_cfg(&self.txn_config).await?; + let tx = match self.txn_config.fee_config { + FeeConfig::Strk(config) => { + trace!(?config, "Invoking with STRK."); + self.account.execute_v3(self.calls.clone()).send_with_cfg(&self.txn_config).await? + } + FeeConfig::Eth(config) => { + trace!(?config, "Invoking with ETH."); + self.account.execute_v1(self.calls.clone()).send_with_cfg(&self.txn_config).await? + } + }; trace!( transaction_hash = format!("{:#066x}", tx.transaction_hash), diff --git a/crates/dojo/utils/src/tx/mod.rs b/crates/dojo/utils/src/tx/mod.rs index df6455048e..9bcf74b6d9 100644 --- a/crates/dojo/utils/src/tx/mod.rs +++ b/crates/dojo/utils/src/tx/mod.rs @@ -10,8 +10,9 @@ use anyhow::{anyhow, Result}; use colored_json::ToColoredJson; use reqwest::Url; use starknet::accounts::{ - AccountDeploymentV1, AccountError, AccountFactory, AccountFactoryError, ConnectedAccount, - DeclarationV2, ExecutionEncoding, ExecutionV1, SingleOwnerAccount, + AccountDeploymentV1, AccountDeploymentV3, AccountError, AccountFactory, AccountFactoryError, + ConnectedAccount, DeclarationV2, DeclarationV3, ExecutionEncoding, ExecutionV1, ExecutionV3, + SingleOwnerAccount, }; use starknet::core::types::{ BlockId, BlockTag, DeclareTransactionResult, DeployAccountTransactionResult, Felt, @@ -21,31 +22,53 @@ use starknet::providers::jsonrpc::HttpTransport; use starknet::providers::{AnyProvider, JsonRpcClient, Provider}; use starknet::signers::{LocalWallet, SigningKey}; -/// The transaction configuration to use when sending a transaction. #[derive(Debug, Copy, Clone, Default)] -pub struct TxnConfig { +pub struct StrkFeeConfig { + /// The maximum L1 gas amount. + pub gas: Option, + /// The maximum L1 gas price in STRK. + pub gas_price: Option, +} + +#[derive(Debug, Copy, Clone, Default)] +pub struct EthFeeConfig { /// The multiplier for how much the actual transaction max fee should be relative to the - /// estimated fee. If `None` is provided, the multiplier is set to `1.1`. + /// estimated fee. pub fee_estimate_multiplier: Option, + /// The maximum fee to pay for the transaction. + pub max_fee_raw: Option, +} + +#[derive(Debug, Copy, Clone)] +pub enum FeeConfig { + /// The STRK fee configuration. + Strk(StrkFeeConfig), + /// The ETH fee configuration. + Eth(EthFeeConfig), +} + +impl Default for FeeConfig { + fn default() -> Self { + Self::Strk(StrkFeeConfig::default()) + } +} + +/// The transaction configuration to use when sending a transaction. +#[derive(Debug, Copy, Clone, Default)] +pub struct TxnConfig { /// Whether to wait for the transaction to be accepted or reverted on L2. pub wait: bool, /// Whether to display the transaction receipt. pub receipt: bool, - /// The maximum fee to pay for the transaction. - pub max_fee_raw: Option, /// Whether to use the `walnut` fee estimation strategy. pub walnut: bool, + /// The fee configuration to use for the transaction. + pub fee_config: FeeConfig, } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Clone)] pub enum TxnAction { - Send { - wait: bool, - receipt: bool, - max_fee_raw: Option, - fee_estimate_multiplier: Option, - walnut: bool, - }, + Send { wait: bool, receipt: bool, fee_config: FeeConfig, walnut: bool }, Estimate, Simulate, } @@ -111,17 +134,16 @@ where mut self, txn_config: &TxnConfig, ) -> Result> { - if let TxnConfig { fee_estimate_multiplier: Some(fee_est_mul), .. } = txn_config { - self = self.fee_estimate_multiplier(*fee_est_mul); - } - - if let TxnConfig { max_fee_raw: Some(max_fee_r), .. } = txn_config { - self = self.max_fee(*max_fee_r); + if let FeeConfig::Eth(c) = txn_config.fee_config { + if let Some(fee_est_mul) = c.fee_estimate_multiplier { + self = self.fee_estimate_multiplier(fee_est_mul); + } + + if let Some(max_raw_f) = c.max_fee_raw { + self = self.max_fee(max_raw_f); + } } - // TODO: need to fix the wait that is not usable, since we don't have access to the - // account/provider. Or execution could expose it, or we need it to be stored in the - // configuration... self.send().await } } @@ -137,12 +159,14 @@ where mut self, txn_config: &TxnConfig, ) -> Result> { - if let TxnConfig { fee_estimate_multiplier: Some(fee_est_mul), .. } = txn_config { - self = self.fee_estimate_multiplier(*fee_est_mul); - } - - if let TxnConfig { max_fee_raw: Some(max_raw_f), .. } = txn_config { - self = self.max_fee(*max_raw_f); + if let FeeConfig::Eth(c) = txn_config.fee_config { + if let Some(fee_est_mul) = c.fee_estimate_multiplier { + self = self.fee_estimate_multiplier(fee_est_mul); + } + + if let Some(max_raw_f) = c.max_fee_raw { + self = self.max_fee(max_raw_f); + } } self.send().await @@ -160,12 +184,89 @@ where mut self, txn_config: &TxnConfig, ) -> Result::SignError>> { - if let TxnConfig { fee_estimate_multiplier: Some(fee_est_mul), .. } = txn_config { - self = self.fee_estimate_multiplier(*fee_est_mul); + if let FeeConfig::Eth(c) = txn_config.fee_config { + if let Some(fee_est_mul) = c.fee_estimate_multiplier { + self = self.fee_estimate_multiplier(fee_est_mul); + } + + if let Some(max_raw_f) = c.max_fee_raw { + self = self.max_fee(max_raw_f); + } } - if let TxnConfig { max_fee_raw: Some(max_raw_f), .. } = txn_config { - self = self.max_fee(*max_raw_f); + self.send().await + } +} + +impl TransactionExt for ExecutionV3<'_, T> +where + T: ConnectedAccount + Sync, +{ + type R = InvokeTransactionResult; + type U = AccountError; + + async fn send_with_cfg( + mut self, + txn_config: &TxnConfig, + ) -> Result> { + if let FeeConfig::Strk(c) = txn_config.fee_config { + if let Some(g) = c.gas { + self = self.gas(g); + } + + if let Some(gp) = c.gas_price { + self = self.gas_price(gp); + } + } + + self.send().await + } +} + +impl TransactionExt for DeclarationV3<'_, T> +where + T: ConnectedAccount + Sync, +{ + type R = DeclareTransactionResult; + type U = AccountError; + + async fn send_with_cfg( + mut self, + txn_config: &TxnConfig, + ) -> Result> { + if let FeeConfig::Strk(c) = txn_config.fee_config { + if let Some(g) = c.gas { + self = self.gas(g); + } + + if let Some(gp) = c.gas_price { + self = self.gas_price(gp); + } + } + + self.send().await + } +} + +impl TransactionExt for AccountDeploymentV3<'_, T> +where + T: AccountFactory + Sync, +{ + type R = DeployAccountTransactionResult; + type U = AccountFactoryError; + + async fn send_with_cfg( + mut self, + txn_config: &TxnConfig, + ) -> Result::SignError>> { + if let FeeConfig::Strk(c) = txn_config.fee_config { + if let Some(g) = c.gas { + self = self.gas(g); + } + + if let Some(gp) = c.gas_price { + self = self.gas_price(gp); + } } self.send().await diff --git a/crates/sozo/ops/src/account.rs b/crates/sozo/ops/src/account.rs deleted file mode 100644 index f8febe249a..0000000000 --- a/crates/sozo/ops/src/account.rs +++ /dev/null @@ -1,502 +0,0 @@ -use core::panic; -use std::io::Write; -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use bigdecimal::BigDecimal; -use colored::Colorize; -use colored_json::{ColorMode, Output}; -use dojo_utils::{TransactionExt, TransactionWaiter, TxnAction, TxnConfig}; -use num_traits::ToPrimitive; -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, FunctionCall, StarknetError, TransactionFinalityStatus, -}; -use starknet::core::utils::get_contract_address; -use starknet::macros::{felt, selector}; -use starknet::providers::jsonrpc::HttpTransport; -use starknet::providers::{JsonRpcClient, Provider, ProviderError}; -use starknet::signers::{LocalWallet, Signer, SigningKey}; -use starknet_crypto::Felt; - -/// Convert a [`Felt`] into a [`BigDecimal`] with a given number of decimals. -pub fn felt_to_bigdecimal(felt: F, decimals: D) -> BigDecimal -where - F: AsRef, - D: Into, -{ - BigDecimal::from((felt.as_ref().to_bigint(), decimals.into())) -} - -/// The canonical hash of a contract class. This is the class hash value of a contract instance. -pub type ClassHash = Felt; - -/// The class hash of DEFAULT_OZ_ACCOUNT_CONTRACT. -pub const DEFAULT_OZ_ACCOUNT_CONTRACT_CLASS_HASH: ClassHash = - felt!("0x05400e90f7e0ae78bd02c77cd75527280470e2fe19c54970dd79dc37a9d3645c"); - -#[derive(Serialize, Deserialize, Debug)] -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], - Felt::ZERO, - )), - } - } -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum AccountVariant { - OpenZeppelin(OzAccountConfig), -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug)] -pub struct OzAccountConfig { - pub version: u64, - #[serde_as(as = "UfeHex")] - pub public_key: Felt, - #[serde(default = "true_as_default")] - pub legacy: bool, -} - -#[derive(Serialize, Deserialize, Debug)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum DeploymentStatus { - Undeployed(UndeployedStatus), - Deployed(DeployedStatus), -} - -impl DeploymentStatus { - pub fn to_deployed(&mut self, address: Felt) { - match self { - DeploymentStatus::Undeployed(status) => { - *self = DeploymentStatus::Deployed(DeployedStatus { - class_hash: status.class_hash, - address, - }); - } - DeploymentStatus::Deployed(_) => { - panic!("Already deployed!") - } - } - } -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug)] -pub struct UndeployedStatus { - #[serde_as(as = "UfeHex")] - pub class_hash: Felt, - #[serde_as(as = "UfeHex")] - pub salt: Felt, -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug)] -pub struct DeployedStatus { - #[serde_as(as = "UfeHex")] - pub class_hash: Felt, - #[serde_as(as = "UfeHex")] - pub address: Felt, -} - -enum MaxFeeType { - Manual { max_fee: Felt }, - Estimated { estimate: Felt, estimate_with_buffer: Felt }, -} - -impl MaxFeeType { - pub fn max_fee(&self) -> Felt { - match self { - Self::Manual { max_fee } => *max_fee, - Self::Estimated { estimate_with_buffer, .. } => *estimate_with_buffer, - } - } -} - -#[derive(Debug)] -pub enum FeeSetting { - Manual(Felt), - EstimateOnly, - None, -} - -impl FeeSetting { - pub fn is_estimate_only(&self) -> bool { - matches!(self, FeeSetting::EstimateOnly) - } -} - -pub async fn new(signer: LocalWallet, force: bool, file: PathBuf) -> Result<()> { - if file.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 file_path = file; - let mut file = std::fs::File::create(&file_path)?; - serde_json::to_writer_pretty(&mut file, &account_config)?; - file.write_all(b"\n")?; - - eprintln!("Created new account config file: {}", std::fs::canonicalize(&file_path)?.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 {}", file_path.display()).bright_yellow() - ); - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -pub async fn deploy( - provider: JsonRpcClient, - signer: LocalWallet, - txn_action: TxnAction, - nonce: Option, - poll_interval: u64, - file: PathBuf, - no_confirmation: bool, -) -> Result<()> { - 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 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 - let signer_public_key = signer.get_public_key().await?.scalar(); - 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_v1(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 account_deployment = match nonce { - Some(nonce) => account_deployment.nonce(nonce), - None => account_deployment, - }; - - match txn_action { - TxnAction::Send { wait, receipt, max_fee_raw, fee_estimate_multiplier, walnut } => { - let max_fee = if let Some(max_fee_raw) = max_fee_raw { - MaxFeeType::Manual { max_fee: max_fee_raw } - } else { - 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 fee_estimate_multiplier = fee_estimate_multiplier.unwrap_or(1.1); - - let estimated_fee_with_buffer = - (((estimated_fee.to_u64().context("Invalid u64")? as f64) - * fee_estimate_multiplier) as u64) - .into(); - - MaxFeeType::Estimated { - estimate: estimated_fee, - estimate_with_buffer: estimated_fee_with_buffer, - } - }; - - let account_deployment = account_deployment.max_fee(max_fee.max_fee()); - let txn_config = - TxnConfig { fee_estimate_multiplier, wait, receipt, max_fee_raw, walnut }; - do_account_deploy( - max_fee, - txn_config, - target_deployment_address, - no_confirmation, - account_deployment, - &provider, - poll_interval, - &mut account, - ) - .await?; - - write_account_to_file(file, account)?; - Ok(()) - } - TxnAction::Estimate => { - 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 decimal = felt_to_bigdecimal(estimated_fee, 18); - println!("{} ETH", format!("{decimal}").bright_yellow()); - - Ok(()) - } - TxnAction::Simulate => { - simulate_account_deploy(&account_deployment).await?; - Ok(()) - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn do_account_deploy( - max_fee: MaxFeeType, - txn_config: TxnConfig, - target_deployment_address: Felt, - no_confirmation: bool, - account_deployment: starknet::accounts::AccountDeploymentV1< - '_, - OpenZeppelinAccountFactory>, - >, - provider: &JsonRpcClient, - poll_interval: u64, - account: &mut AccountConfig, -) -> Result<(), anyhow::Error> { - 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", felt_to_bigdecimal(max_fee, 18)).bright_yellow(), - format!("{} ETH", felt_to_bigdecimal(max_fee, 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", felt_to_bigdecimal(estimate, 18)).bright_yellow(), - format!("{} ETH", felt_to_bigdecimal(estimate_with_buffer, 18)).bright_yellow() - ); - } - } - eprintln!( - "to the following address:\n {}", - format!("{:#064x}", target_deployment_address).bright_yellow() - ); - if !no_confirmation { - eprint!("Press [ENTER] once you've funded the address."); - std::io::stdin().read_line(&mut String::new())?; - } - - let account_deployment_tx = - account_deployment.send_with_cfg(&txn_config).await?.transaction_hash; - - eprintln!( - "Account deployment transaction: {}", - format!("{:#064x}", account_deployment_tx).bright_yellow() - ); - 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(), - ); - let receipt = TransactionWaiter::new(account_deployment_tx, &provider) - .with_tx_status(TransactionFinalityStatus::AcceptedOnL2) - .with_interval(poll_interval) - .await?; - - eprintln!( - "Transaction {} confirmed", - format!("{:#064x}", account_deployment_tx).bright_yellow() - ); - - if txn_config.receipt { - println!("Receipt:\n{}", serde_json::to_string_pretty(&receipt)?); - } - - account.deployment.to_deployed(target_deployment_address); - - Ok(()) -} - -fn write_account_to_file(file: PathBuf, account: AccountConfig) -> Result<(), anyhow::Error> { - let mut temp_file_name = file - .file_name() - .ok_or_else(|| anyhow::anyhow!("unable to determine file name"))? - .to_owned(); - - // Never write directly to the original file to avoid data loss - 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(()) -} - -async fn simulate_account_deploy( - account_deployment: &starknet::accounts::AccountDeploymentV1< - '_, - OpenZeppelinAccountFactory>, - >, -) -> Result<(), anyhow::Error> { - 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}"); - Ok(()) -} - -pub async fn fetch( - provider: JsonRpcClient, - force: bool, - output: PathBuf, - address: Felt, -) -> Result<()> { - if output.exists() && !force { - anyhow::bail!("account config file already exists"); - } - - let class_hash = provider.get_class_hash_at(BlockId::Tag(BlockTag::Pending), address).await?; - - let public_key = provider - .call( - FunctionCall { - contract_address: address, - entry_point_selector: selector!("get_public_key"), - calldata: vec![], - }, - BlockId::Tag(BlockTag::Pending), - ) - .await?[0]; - - let variant = - AccountVariant::OpenZeppelin(OzAccountConfig { version: 1, public_key, legacy: false }); - - let account = AccountConfig { - version: 1, - variant, - deployment: DeploymentStatus::Deployed(DeployedStatus { class_hash, address }), - }; - - let mut file = std::fs::File::create(&output)?; - serde_json::to_writer_pretty(&mut file, &account)?; - file.write_all(b"\n")?; - - eprintln!("Downloaded new account config file: {}", std::fs::canonicalize(&output)?.display()); - - 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), - } -} diff --git a/crates/sozo/ops/src/lib.rs b/crates/sozo/ops/src/lib.rs index 91b88c10cf..656fad9c22 100644 --- a/crates/sozo/ops/src/lib.rs +++ b/crates/sozo/ops/src/lib.rs @@ -1,6 +1,5 @@ // #![cfg_attr(not(test), warn(unused_crate_dependencies))] -pub mod account; pub mod migrate; pub mod migration_ui; pub mod model; diff --git a/examples/simple/manifest_sepolia.json b/examples/simple/manifest_sepolia.json index 141e76e1b6..846a3c8290 100644 --- a/examples/simple/manifest_sepolia.json +++ b/examples/simple/manifest_sepolia.json @@ -1,9 +1,32 @@ { "world": { - "class_hash": "0x139239a99d627697b19b9856beaef7896fc75375caf3d750dd76982a7afeb78", - "address": "0x1f21e5883353346629ec313c950e998982c12411c1d86e12b97bf26540760c1", - "seed": "simple2", + "class_hash": "0x2f92b70bd2b5a40ddef12c55257f245176870b25c7eb0bd7a60cf1f1f2fbf0e", + "address": "0x222b086c792ac5d3ea8c11c13ab227967a07b35febc2c9ea822c2ea722a3cf2", + "seed": "simple3", "name": "simple", + "entrypoints": [ + "uuid", + "set_metadata", + "register_namespace", + "register_event", + "register_model", + "register_contract", + "init_contract", + "upgrade_event", + "upgrade_model", + "upgrade_contract", + "emit_event", + "emit_events", + "set_entity", + "set_entities", + "delete_entity", + "delete_entities", + "grant_owner", + "revoke_owner", + "grant_writer", + "revoke_writer", + "upgrade" + ], "abi": [ { "type": "impl", @@ -83,16 +106,12 @@ ] }, { - "type": "enum", - "name": "core::bool", - "variants": [ - { - "name": "False", - "type": "()" - }, + "type": "struct", + "name": "core::array::Span::>", + "members": [ { - "name": "True", - "type": "()" + "name": "snapshot", + "type": "@core::array::Array::>" } ] }, @@ -188,6 +207,30 @@ } ] }, + { + "type": "struct", + "name": "core::array::Span::", + "members": [ + { + "name": "snapshot", + "type": "@core::array::Array::" + } + ] + }, + { + "type": "enum", + "name": "core::bool", + "variants": [ + { + "name": "False", + "type": "()" + }, + { + "name": "True", + "type": "()" + } + ] + }, { "type": "interface", "name": "dojo::world::iworld::IWorld", @@ -398,10 +441,26 @@ { "name": "values", "type": "core::array::Span::" + } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "type": "function", + "name": "emit_events", + "inputs": [ + { + "name": "event_selector", + "type": "core::felt252" }, { - "name": "historical", - "type": "core::bool" + "name": "keys", + "type": "core::array::Span::>" + }, + { + "name": "values", + "type": "core::array::Span::>" } ], "outputs": [], @@ -431,6 +490,30 @@ ], "state_mutability": "view" }, + { + "type": "function", + "name": "entities", + "inputs": [ + { + "name": "model_selector", + "type": "core::felt252" + }, + { + "name": "indexes", + "type": "core::array::Span::" + }, + { + "name": "layout", + "type": "dojo::meta::layout::Layout" + } + ], + "outputs": [ + { + "type": "core::array::Span::>" + } + ], + "state_mutability": "view" + }, { "type": "function", "name": "set_entity", @@ -455,6 +538,30 @@ "outputs": [], "state_mutability": "external" }, + { + "type": "function", + "name": "set_entities", + "inputs": [ + { + "name": "model_selector", + "type": "core::felt252" + }, + { + "name": "indexes", + "type": "core::array::Span::" + }, + { + "name": "values", + "type": "core::array::Span::>" + }, + { + "name": "layout", + "type": "dojo::meta::layout::Layout" + } + ], + "outputs": [], + "state_mutability": "external" + }, { "type": "function", "name": "delete_entity", @@ -475,6 +582,26 @@ "outputs": [], "state_mutability": "external" }, + { + "type": "function", + "name": "delete_entities", + "inputs": [ + { + "name": "model_selector", + "type": "core::felt252" + }, + { + "name": "indexes", + "type": "core::array::Span::" + }, + { + "name": "layout", + "type": "dojo::meta::layout::Layout" + } + ], + "outputs": [], + "state_mutability": "external" + }, { "type": "function", "name": "is_owner", @@ -849,11 +976,6 @@ "type": "core::starknet::contract_address::ContractAddress", "kind": "key" }, - { - "name": "historical", - "type": "core::bool", - "kind": "key" - }, { "name": "keys", "type": "core::array::Span::", @@ -1121,14 +1243,24 @@ }, "contracts": [ { - "address": "0x79e0b2874810d3b146a6d992a77ce30637d9868b7399ef772d5c09323cf8a81", - "class_hash": "0x13767b87a8459556babbcf8cbdf2800181b462ef47f6fdafc14fc14fc1dae57", + "address": "0x5b713d5d994bf5b11795fe8463c0753c5158d1b1ebd956ab8d4863dcfe23af4", + "class_hash": "0x758181d4a28c2a580c7ef9e963d4bcd5b0712320dc68a174c4f3ff4ad3eaae0", "abi": [ { "type": "impl", "name": "c1__ContractImpl", "interface_name": "dojo::contract::interface::IContract" }, + { + "type": "interface", + "name": "dojo::contract::interface::IContract", + "items": [] + }, + { + "type": "impl", + "name": "c1__DeployedContractImpl", + "interface_name": "dojo::meta::interface::IDeployedResource" + }, { "type": "struct", "name": "core::byte_array::ByteArray", @@ -1149,7 +1281,7 @@ }, { "type": "interface", - "name": "dojo::contract::interface::IContract", + "name": "dojo::meta::interface::IDeployedResource", "items": [ { "type": "function", @@ -1359,17 +1491,34 @@ "0xfffe" ], "tag": "ns-c1", - "systems": [] + "selector": "0x344f082f6bed98b4d02d4a9d4db43590c268f4c23bf0a31bfebafacd1806177", + "systems": [ + "system_1", + "system_2", + "system_3", + "system_4", + "upgrade" + ] }, { - "address": "0x2794aa5eca46ac459d20105c8d33ec05ccdaa08d6b93a65fec58f2f3c25a3d0", - "class_hash": "0x1eef253239f61c49444c41990940fa8fee51b021d19e48c20d31f45bc465d46", + "address": "0x811f5003c889294792e04361b2108ee8a2321c0a05e58570a8d98656a273f0", + "class_hash": "0x2a400df88b0add6c980d281dc96354a5cfc2b886547e9ed621b097e21842ee6", "abi": [ { "type": "impl", "name": "c2__ContractImpl", "interface_name": "dojo::contract::interface::IContract" }, + { + "type": "interface", + "name": "dojo::contract::interface::IContract", + "items": [] + }, + { + "type": "impl", + "name": "c2__DeployedContractImpl", + "interface_name": "dojo::meta::interface::IDeployedResource" + }, { "type": "struct", "name": "core::byte_array::ByteArray", @@ -1390,7 +1539,7 @@ }, { "type": "interface", - "name": "dojo::contract::interface::IContract", + "name": "dojo::meta::interface::IDeployedResource", "items": [ { "type": "function", @@ -1522,17 +1671,30 @@ ], "init_calldata": [], "tag": "ns-c2", - "systems": [] + "selector": "0x7561c5d1d1d72352071ae8d14b5289444b3f2542b02daa2924c526fb0846e59", + "systems": [ + "upgrade" + ] }, { - "address": "0x689805c618f85331489e1799dd76d18d4998b8b05b3d595cfb9f3193593ee3b", - "class_hash": "0x4be29e651d49e58fba33f71ab6fe7fe101ee811842d07852b70d43a407fef2a", + "address": "0x2a27076c297cd13db40c114a44dee9a6fd4f3d85a886fdf64ded18a1e4a12df", + "class_hash": "0x7cc8d15e576873d544640f7fd124bd430bd19c0f31e203fb069b4fc2f5c0ab9", "abi": [ { "type": "impl", "name": "c3__ContractImpl", "interface_name": "dojo::contract::interface::IContract" }, + { + "type": "interface", + "name": "dojo::contract::interface::IContract", + "items": [] + }, + { + "type": "impl", + "name": "c3__DeployedContractImpl", + "interface_name": "dojo::meta::interface::IDeployedResource" + }, { "type": "struct", "name": "core::byte_array::ByteArray", @@ -1553,7 +1715,7 @@ }, { "type": "interface", - "name": "dojo::contract::interface::IContract", + "name": "dojo::meta::interface::IDeployedResource", "items": [ { "type": "function", @@ -1685,17 +1847,30 @@ ], "init_calldata": [], "tag": "ns-c3", - "systems": [] + "selector": "0x69bd1ce04aa3945bacae701a68e86c58b44d21c4debee8a65fa921b4a717c5f", + "systems": [ + "upgrade" + ] }, { - "address": "0x2f8b1d849ac131c974f35972c8c7d8ec7ce1b4f2d9e250cde0b246090ca45ff", - "class_hash": "0x13767b87a8459556babbcf8cbdf2800181b462ef47f6fdafc14fc14fc1dae57", + "address": "0x383866b1de57234f2b0e4409f1bc2863c0b94b08f0dcffc8fff0e37b1b56ebd", + "class_hash": "0x758181d4a28c2a580c7ef9e963d4bcd5b0712320dc68a174c4f3ff4ad3eaae0", "abi": [ { "type": "impl", "name": "c1__ContractImpl", "interface_name": "dojo::contract::interface::IContract" }, + { + "type": "interface", + "name": "dojo::contract::interface::IContract", + "items": [] + }, + { + "type": "impl", + "name": "c1__DeployedContractImpl", + "interface_name": "dojo::meta::interface::IDeployedResource" + }, { "type": "struct", "name": "core::byte_array::ByteArray", @@ -1716,7 +1891,7 @@ }, { "type": "interface", - "name": "dojo::contract::interface::IContract", + "name": "dojo::meta::interface::IDeployedResource", "items": [ { "type": "function", @@ -1926,31 +2101,42 @@ "0xfffe" ], "tag": "ns2-c1", - "systems": [] + "selector": "0x11652d70e4b9b3ec4ddfe9277bbf7e44db1ce5d5d9ba5ab84b96de5db614d0", + "systems": [ + "system_1", + "system_2", + "system_3", + "system_4", + "upgrade" + ] } ], "models": [ { "members": [], - "class_hash": "0xb35ce9998d1524acfc8b0318aed7375b0d977b6362a2f7af23be2950aa96fd", - "tag": "M" + "class_hash": "0x506d770b7301fc791cf3a19705848647524a0e2595a4d38365df917b4d1d246", + "tag": "M", + "selector": "0x3b26427a55dd1d51738b0e3e989fe6f25649e1311295f30f0a4fa2db439aa2c" }, { "members": [], - "class_hash": "0xb35ce9998d1524acfc8b0318aed7375b0d977b6362a2f7af23be2950aa96fd", - "tag": "M" + "class_hash": "0x506d770b7301fc791cf3a19705848647524a0e2595a4d38365df917b4d1d246", + "tag": "M", + "selector": "0x50aac05281bbfaa5393cacacc12e86f59ab7d5f3ee619427dd33a0756526f24" } ], "events": [ { "members": [], - "class_hash": "0x65aa33d998d733abc890ee36503fe1df8e7c01f2cf1a92b147bd424a1af56d7", - "tag": "E" + "class_hash": "0x5c72f78327d896e16f3c9fe7ee5e136ad9fc4cda89fba1ce7e665877d477cd5", + "tag": "E", + "selector": "0x260e0511a6fa454a7d4ed8bea5fa52fc80fc588e33ba4cb58c65bbeeadf7565" }, { "members": [], - "class_hash": "0x58568a90180a44515609dbaf69bb0c1aa56f29e93688f4bfdab10268fe68ce1", - "tag": "EH" + "class_hash": "0x408fc887363634ee0d261cc26606574ce2bc433bedbcef4bb88f8fbc69a1e43", + "tag": "EH", + "selector": "0x4c6c7772b19b700cf97d078d02a419670d11d2b689a7a3647eac311b2817ced" } ] } \ No newline at end of file