use super::utils::{check_deployed, core_felt_to_rs, rs_felt_to_core, StarknetRsFelt};
use super::WalletExecutor;
use crate::tx::encode::encode_execute_calldata;
use crate::tx::hash::{
compute_deploy_account_v3_hash, compute_invoke_v3_hash, DaMode, ResourceBounds,
};
use crate::tx::Tx;
use krusty_kms::account_class::OpenZeppelinEthAccount;
use krusty_kms::AccountClass;
use krusty_kms::EthSigner;
use krusty_kms_common::address::Address;
use krusty_kms_common::chain::ChainId;
use krusty_kms_common::network::NetworkPreset;
use krusty_kms_common::{KmsError, Result};
use std::sync::Arc;
use std::time::Instant;
use starknet_rust::core::types::{
BlockId, BlockTag, BroadcastedDeployAccountTransactionV3,
BroadcastedInvokeTransactionV3, BroadcastedTransaction, Call, DataAvailabilityMode,
FeeEstimate, ResourceBoundsMapping, SimulationFlagForEstimateFee,
};
use starknet_rust::providers::jsonrpc::{HttpTransport, JsonRpcClient};
use starknet_rust::providers::Provider;
use starknet_types_core::felt::Felt as CoreFelt;
use tokio::sync::RwLock;
const DEPLOYED_CACHE_TTL_SECS: u64 = 3;
const FEE_MARGIN_NUM: u64 = 3;
const FEE_MARGIN_DEN: u64 = 2;
pub struct EthWallet {
signer: EthSigner,
provider: Arc<JsonRpcClient<HttpTransport>>,
address: Address,
chain_id: ChainId,
network: NetworkPreset,
class_hash: CoreFelt,
constructor_calldata: Vec<CoreFelt>,
salt: CoreFelt,
deployed_cache: RwLock<Option<(bool, Instant)>>,
}
impl EthWallet {
pub fn new(
provider: Arc<JsonRpcClient<HttpTransport>>,
private_key: &[u8; 32],
chain_id: ChainId,
network: NetworkPreset,
) -> Result<Self> {
let account_class = OpenZeppelinEthAccount::new();
Self::with_class(provider, private_key, &account_class, chain_id, network)
}
pub fn from_hex(
provider: Arc<JsonRpcClient<HttpTransport>>,
hex_key: &str,
chain_id: ChainId,
network: NetworkPreset,
) -> Result<Self> {
let signer = EthSigner::from_hex(hex_key)?;
let account_class = OpenZeppelinEthAccount::new();
Self::from_signer(provider, signer, &account_class, chain_id, network)
}
pub fn with_class(
provider: Arc<JsonRpcClient<HttpTransport>>,
private_key: &[u8; 32],
account_class: &OpenZeppelinEthAccount,
chain_id: ChainId,
network: NetworkPreset,
) -> Result<Self> {
let signer = EthSigner::from_private_key(private_key)?;
Self::from_signer(provider, signer, account_class, chain_id, network)
}
fn from_signer(
provider: Arc<JsonRpcClient<HttpTransport>>,
signer: EthSigner,
account_class: &OpenZeppelinEthAccount,
chain_id: ChainId,
network: NetworkPreset,
) -> Result<Self> {
let (x, y) = signer.public_key_xy();
let class_hash = account_class.class_hash();
let constructor_calldata = account_class.build_constructor_calldata_eth(&x, &y);
let salt = account_class.get_salt_eth(&x, &y);
let address_felt = account_class.calculate_address_eth(&x, &y)?;
let address = Address::from(address_felt);
Ok(Self {
signer,
provider,
address,
chain_id,
network,
class_hash,
constructor_calldata,
salt,
deployed_cache: RwLock::new(None),
})
}
pub async fn deploy(&self) -> Result<Tx> {
let deployed = self.is_deployed().await?;
if deployed {
return Err(KmsError::TransactionError(
"Account is already deployed".into(),
));
}
let nonce = CoreFelt::ZERO; let chain_id_core = self.chain_id.as_felt();
let class_hash_rs = core_felt_to_rs(self.class_hash);
let salt_rs = core_felt_to_rs(self.salt);
let constructor_calldata_rs: Vec<StarknetRsFelt> = self
.constructor_calldata
.iter()
.map(|f| core_felt_to_rs(*f))
.collect();
let dummy_sig = vec![StarknetRsFelt::ZERO; 5];
let dummy_deploy = BroadcastedDeployAccountTransactionV3 {
signature: dummy_sig,
nonce: StarknetRsFelt::ZERO,
contract_address_salt: salt_rs,
constructor_calldata: constructor_calldata_rs.clone(),
class_hash: class_hash_rs,
resource_bounds: zero_resource_bounds(),
tip: 0,
paymaster_data: vec![],
nonce_data_availability_mode: DataAvailabilityMode::L1,
fee_data_availability_mode: DataAvailabilityMode::L1,
is_query: true,
};
let estimates = self
.provider
.estimate_fee(
&[BroadcastedTransaction::DeployAccount(dummy_deploy)],
vec![SimulationFlagForEstimateFee::SkipValidate],
BlockId::Tag(BlockTag::Latest),
)
.await
.map_err(|e| KmsError::FeeEstimationFailed(e.to_string()))?;
let estimate = estimates
.first()
.ok_or_else(|| KmsError::FeeEstimationFailed("empty estimate".into()))?;
let resource_bounds = estimate_to_resource_bounds(estimate);
let (l1_gas, l2_gas) = mapping_to_hash_bounds(&resource_bounds);
let address_core = self.address.as_felt();
let tx_hash = compute_deploy_account_v3_hash(
&address_core,
&self.class_hash,
&self.constructor_calldata,
&self.salt,
&chain_id_core,
&nonce,
0,
&l1_gas,
&l2_gas,
&[], DaMode::L1,
DaMode::L1,
);
let sig_felts = self.signer.sign_hash(&tx_hash)?;
let signature: Vec<StarknetRsFelt> =
sig_felts.iter().map(|f| core_felt_to_rs(*f)).collect();
let deploy_tx = BroadcastedDeployAccountTransactionV3 {
signature,
nonce: StarknetRsFelt::ZERO,
contract_address_salt: salt_rs,
constructor_calldata: constructor_calldata_rs,
class_hash: class_hash_rs,
resource_bounds,
tip: 0,
paymaster_data: vec![],
nonce_data_availability_mode: DataAvailabilityMode::L1,
fee_data_availability_mode: DataAvailabilityMode::L1,
is_query: false,
};
let result = self
.provider
.add_deploy_account_transaction(&deploy_tx)
.await
.map_err(|e| KmsError::TransactionError(e.to_string()))?;
{
let mut cache = self.deployed_cache.write().await;
*cache = None;
}
Ok(Tx::new(
result.transaction_hash,
self.provider.clone(),
self.network.clone(),
))
}
pub async fn ensure_deployed(&self) -> Result<Option<Tx>> {
if self.is_deployed().await? {
Ok(None)
} else {
Ok(Some(self.deploy().await?))
}
}
pub fn signer(&self) -> &EthSigner {
&self.signer
}
pub fn class_hash(&self) -> CoreFelt {
self.class_hash
}
pub fn provider(&self) -> &Arc<JsonRpcClient<HttpTransport>> {
&self.provider
}
pub fn tx(&self) -> crate::tx::TxBuilder<'_> {
crate::tx::TxBuilder::new(self)
}
async fn check_deployed(&self) -> Result<bool> {
{
let cache = self.deployed_cache.read().await;
if let Some((deployed, ts)) = *cache {
if deployed || ts.elapsed().as_secs() < DEPLOYED_CACHE_TTL_SECS {
return Ok(deployed);
}
}
}
let address_rs = core_felt_to_rs(self.address.as_felt());
let deployed = check_deployed(&self.provider, address_rs).await?;
{
let mut cache = self.deployed_cache.write().await;
*cache = Some((deployed, Instant::now()));
}
Ok(deployed)
}
async fn execute_inner(&self, calls: Vec<Call>) -> Result<Tx> {
let calldata = encode_execute_calldata(&calls);
let address_rs = core_felt_to_rs(self.address.as_felt());
let chain_id_core = self.chain_id.as_felt();
let nonce_rs = self
.provider
.get_nonce(BlockId::Tag(BlockTag::Latest), address_rs)
.await
.map_err(|e| KmsError::RpcError(e.to_string()))?;
let nonce_core = rs_felt_to_core(nonce_rs);
let dummy_sig = vec![StarknetRsFelt::ZERO; 5];
let dummy_tx = BroadcastedInvokeTransactionV3 {
sender_address: address_rs,
calldata: calldata.clone(),
signature: dummy_sig,
nonce: nonce_rs,
resource_bounds: zero_resource_bounds(),
tip: 0,
paymaster_data: vec![],
account_deployment_data: vec![],
nonce_data_availability_mode: DataAvailabilityMode::L1,
fee_data_availability_mode: DataAvailabilityMode::L1,
is_query: true,
};
let estimates = self
.provider
.estimate_fee(
&[BroadcastedTransaction::Invoke(dummy_tx)],
vec![SimulationFlagForEstimateFee::SkipValidate],
BlockId::Tag(BlockTag::Latest),
)
.await
.map_err(|e| KmsError::FeeEstimationFailed(e.to_string()))?;
let estimate = estimates
.first()
.ok_or_else(|| KmsError::FeeEstimationFailed("empty estimate".into()))?;
let resource_bounds = estimate_to_resource_bounds(estimate);
let calldata_core: Vec<CoreFelt> =
calldata.iter().map(|f| rs_felt_to_core(*f)).collect();
let (l1_gas, l2_gas) = mapping_to_hash_bounds(&resource_bounds);
let address_core = self.address.as_felt();
let tx_hash = compute_invoke_v3_hash(
&address_core,
&calldata_core,
&chain_id_core,
&nonce_core,
0,
&l1_gas,
&l2_gas,
&[], &[], DaMode::L1,
DaMode::L1,
);
let sig_felts = self.signer.sign_hash(&tx_hash)?;
let signature: Vec<StarknetRsFelt> =
sig_felts.iter().map(|f| core_felt_to_rs(*f)).collect();
let invoke_tx = BroadcastedInvokeTransactionV3 {
sender_address: address_rs,
calldata,
signature,
nonce: nonce_rs,
resource_bounds,
tip: 0,
paymaster_data: vec![],
account_deployment_data: vec![],
nonce_data_availability_mode: DataAvailabilityMode::L1,
fee_data_availability_mode: DataAvailabilityMode::L1,
is_query: false,
};
let result = self
.provider
.add_invoke_transaction(&invoke_tx)
.await
.map_err(|e| KmsError::TransactionError(e.to_string()))?;
Ok(Tx::new(
result.transaction_hash,
self.provider.clone(),
self.network.clone(),
))
}
async fn estimate_fee_inner(&self, calls: Vec<Call>) -> Result<FeeEstimate> {
let calldata = encode_execute_calldata(&calls);
let address_rs = core_felt_to_rs(self.address.as_felt());
let nonce_rs = self
.provider
.get_nonce(BlockId::Tag(BlockTag::Latest), address_rs)
.await
.map_err(|e| KmsError::RpcError(e.to_string()))?;
let dummy_sig = vec![StarknetRsFelt::ZERO; 5];
let dummy_tx = BroadcastedInvokeTransactionV3 {
sender_address: address_rs,
calldata,
signature: dummy_sig,
nonce: nonce_rs,
resource_bounds: zero_resource_bounds(),
tip: 0,
paymaster_data: vec![],
account_deployment_data: vec![],
nonce_data_availability_mode: DataAvailabilityMode::L1,
fee_data_availability_mode: DataAvailabilityMode::L1,
is_query: true,
};
let estimates = self
.provider
.estimate_fee(
&[BroadcastedTransaction::Invoke(dummy_tx)],
vec![SimulationFlagForEstimateFee::SkipValidate],
BlockId::Tag(BlockTag::Latest),
)
.await
.map_err(|e| KmsError::FeeEstimationFailed(e.to_string()))?;
estimates
.into_iter()
.next()
.ok_or_else(|| KmsError::FeeEstimationFailed("empty estimate".into()))
}
}
#[async_trait::async_trait]
impl WalletExecutor for EthWallet {
async fn execute(&self, calls: Vec<Call>) -> Result<Tx> {
self.execute_inner(calls).await
}
async fn estimate_fee(&self, calls: Vec<Call>) -> Result<FeeEstimate> {
self.estimate_fee_inner(calls).await
}
fn address(&self) -> &Address {
&self.address
}
fn chain_id(&self) -> ChainId {
self.chain_id
}
fn network(&self) -> &NetworkPreset {
&self.network
}
async fn is_deployed(&self) -> Result<bool> {
self.check_deployed().await
}
}
fn zero_resource_bounds() -> ResourceBoundsMapping {
ResourceBoundsMapping {
l1_gas: starknet_rust::core::types::ResourceBounds {
max_amount: 0,
max_price_per_unit: 0,
},
l1_data_gas: starknet_rust::core::types::ResourceBounds {
max_amount: 0,
max_price_per_unit: 0,
},
l2_gas: starknet_rust::core::types::ResourceBounds {
max_amount: 0,
max_price_per_unit: 0,
},
}
}
fn estimate_to_resource_bounds(estimate: &FeeEstimate) -> ResourceBoundsMapping {
ResourceBoundsMapping {
l1_gas: starknet_rust::core::types::ResourceBounds {
max_amount: estimate
.l1_gas_consumed
.saturating_mul(FEE_MARGIN_NUM)
/ FEE_MARGIN_DEN,
max_price_per_unit: estimate
.l1_gas_price
.saturating_mul(FEE_MARGIN_NUM as u128)
/ FEE_MARGIN_DEN as u128,
},
l1_data_gas: starknet_rust::core::types::ResourceBounds {
max_amount: estimate
.l1_data_gas_consumed
.saturating_mul(FEE_MARGIN_NUM)
/ FEE_MARGIN_DEN,
max_price_per_unit: estimate
.l1_data_gas_price
.saturating_mul(FEE_MARGIN_NUM as u128)
/ FEE_MARGIN_DEN as u128,
},
l2_gas: starknet_rust::core::types::ResourceBounds {
max_amount: estimate
.l2_gas_consumed
.saturating_mul(FEE_MARGIN_NUM)
/ FEE_MARGIN_DEN,
max_price_per_unit: estimate
.l2_gas_price
.saturating_mul(FEE_MARGIN_NUM as u128)
/ FEE_MARGIN_DEN as u128,
},
}
}
fn mapping_to_hash_bounds(
mapping: &ResourceBoundsMapping,
) -> (ResourceBounds, ResourceBounds) {
(
ResourceBounds {
max_amount: mapping.l1_gas.max_amount,
max_price_per_unit: mapping.l1_gas.max_price_per_unit,
},
ResourceBounds {
max_amount: mapping.l2_gas.max_amount,
max_price_per_unit: mapping.l2_gas.max_price_per_unit,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY_HEX: &str =
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
fn make_provider() -> Arc<JsonRpcClient<HttpTransport>> {
Arc::new(JsonRpcClient::new(
starknet_rust::providers::jsonrpc::HttpTransport::new(
url::Url::parse("http://localhost:5050").unwrap(),
),
))
}
#[test]
fn test_factory_computes_address() {
let provider = make_provider();
let wallet = EthWallet::from_hex(
provider,
TEST_KEY_HEX,
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
assert_ne!(wallet.address().as_felt(), CoreFelt::ZERO);
}
#[test]
fn test_factory_deterministic() {
let provider1 = make_provider();
let wallet1 = EthWallet::from_hex(
provider1,
TEST_KEY_HEX,
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
let provider2 = make_provider();
let wallet2 = EthWallet::from_hex(
provider2,
TEST_KEY_HEX,
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
assert_eq!(wallet1.address(), wallet2.address());
}
#[test]
fn test_different_keys_different_addresses() {
let provider1 = make_provider();
let wallet1 = EthWallet::from_hex(
provider1,
TEST_KEY_HEX,
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
let provider2 = make_provider();
let wallet2 = EthWallet::from_hex(
provider2,
"59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
assert_ne!(wallet1.address(), wallet2.address());
}
#[test]
fn test_class_hash_matches_oz_eth() {
let provider = make_provider();
let wallet = EthWallet::from_hex(
provider,
TEST_KEY_HEX,
ChainId::Sepolia,
NetworkPreset::sepolia(),
)
.unwrap();
let expected = CoreFelt::from_hex(OpenZeppelinEthAccount::CLASS_HASH).unwrap();
assert_eq!(wallet.class_hash(), expected);
}
#[test]
fn test_zero_resource_bounds() {
let bounds = zero_resource_bounds();
assert_eq!(bounds.l1_gas.max_amount, 0);
assert_eq!(bounds.l2_gas.max_amount, 0);
}
#[test]
fn test_estimate_to_resource_bounds_applies_margin() {
let estimate = FeeEstimate {
l1_gas_consumed: 100,
l1_gas_price: 1000,
l2_gas_consumed: 200,
l2_gas_price: 2000,
l1_data_gas_consumed: 50,
l1_data_gas_price: 500,
overall_fee: 999999,
};
let bounds = estimate_to_resource_bounds(&estimate);
assert_eq!(bounds.l1_gas.max_amount, 150);
assert_eq!(bounds.l1_gas.max_price_per_unit, 1500);
assert_eq!(bounds.l2_gas.max_amount, 300);
assert_eq!(bounds.l2_gas.max_price_per_unit, 3000);
assert_eq!(bounds.l1_data_gas.max_amount, 75);
assert_eq!(bounds.l1_data_gas.max_price_per_unit, 750);
}
}