From 1f1756f35206d8d7266060558dd8aafad58fa0a9 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 18 Feb 2026 20:47:32 -0600 Subject: [PATCH 01/32] wip --- Cargo.lock | 19 +- crates/cartridge/Cargo.toml | 10 +- crates/cartridge/src/{client.rs => api.rs} | 4 +- crates/cartridge/src/lib.rs | 8 +- crates/cartridge/src/middlware/layer.rs | 234 +++++++ crates/cartridge/src/middlware/mod.rs | 634 ++++++++++++++++++ crates/cartridge/src/utils.rs | 19 + crates/cartridge/src/vrf/mod.rs | 183 +---- .../src/vrf/{ => server}/bootstrap.rs | 0 crates/cartridge/src/vrf/server/mod.rs | 174 +++++ crates/cli/src/sidecar.rs | 6 +- crates/node/full/src/lib.rs | 2 - crates/node/sequencer/Cargo.toml | 5 + crates/node/sequencer/src/exit.rs | 2 +- crates/node/sequencer/src/lib.rs | 112 +++- crates/rpc/rpc-server/src/cartridge/mod.rs | 12 +- crates/rpc/rpc-server/src/cartridge/vrf.rs | 8 +- crates/rpc/rpc-server/src/lib.rs | 59 +- .../rpc-server/src/middleware/cartridge.rs | 628 +++++++++++++++++ .../rpc-server/src/{ => middleware}/cors.rs | 0 .../rpc-server/src/{ => middleware}/logger.rs | 6 + .../src/{ => middleware}/metrics.rs | 7 +- crates/rpc/rpc-server/src/middleware/mod.rs | 6 + crates/rpc/rpc-server/src/starknet/config.rs | 17 - crates/rpc/rpc-server/src/starknet/mod.rs | 2 - crates/rpc/rpc-server/src/starknet/read.rs | 9 +- crates/rpc/rpc-types/src/outside_execution.rs | 11 +- crates/utils/src/node.rs | 5 +- 28 files changed, 1910 insertions(+), 272 deletions(-) rename crates/cartridge/src/{client.rs => api.rs} (98%) create mode 100644 crates/cartridge/src/middlware/layer.rs create mode 100644 crates/cartridge/src/middlware/mod.rs create mode 100644 crates/cartridge/src/utils.rs rename crates/cartridge/src/vrf/{ => server}/bootstrap.rs (100%) create mode 100644 crates/cartridge/src/vrf/server/mod.rs create mode 100644 crates/rpc/rpc-server/src/middleware/cartridge.rs rename crates/rpc/rpc-server/src/{ => middleware}/cors.rs (100%) rename crates/rpc/rpc-server/src/{ => middleware}/logger.rs (95%) rename crates/rpc/rpc-server/src/{ => middleware}/metrics.rs (96%) create mode 100644 crates/rpc/rpc-server/src/middleware/mod.rs mode change 100644 => 100755 crates/rpc/rpc-server/src/starknet/read.rs diff --git a/Cargo.lock b/Cargo.lock index 549efdd02..4e1b79b6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2879,9 +2879,15 @@ version = "1.7.0" dependencies = [ "anyhow", "ark-ff 0.4.2", + "cainome-cairo-serde", + "jsonrpsee 0.26.0", "katana-contracts", "katana-genesis", + "katana-paymaster", + "katana-pool", + "katana-pool-api", "katana-primitives", + "katana-provider", "katana-rpc-types", "lazy_static", "reqwest", @@ -2889,8 +2895,10 @@ dependencies = [ "serde_json", "stark-vrf", "starknet 0.17.0 (registry+https://github.com/rust-lang/crates.io-index)", + "starknet-types-core 0.2.3", "thiserror 1.0.69", "tokio", + "tower 0.5.2", "tracing", "url", ] @@ -6250,8 +6258,6 @@ dependencies = [ "parking_lot", "serde", "strum 0.25.0", - "strum_macros 0.25.3", - "thiserror 1.0.69", "tokio", "tracing", ] @@ -6790,6 +6796,7 @@ name = "katana-sequencer-node" version = "1.7.0" dependencies = [ "anyhow", + "cartridge", "futures", "http 1.3.1", "jsonrpsee 0.26.0", @@ -6804,7 +6811,6 @@ dependencies = [ "katana-metrics", "katana-node-config", "katana-pool", - "katana-pool-api", "katana-primitives", "katana-provider", "katana-rpc-api", @@ -6815,13 +6821,10 @@ dependencies = [ "katana-tasks", "katana-tee", "num-traits", - "parking_lot", "serde", - "strum 0.25.0", - "strum_macros 0.25.3", + "starknet 0.17.0 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile", - "thiserror 1.0.69", - "tokio", + "tower 0.5.2", "tracing", "url", ] diff --git a/crates/cartridge/Cargo.toml b/crates/cartridge/Cargo.toml index 846a12961..2c27a272b 100644 --- a/crates/cartridge/Cargo.toml +++ b/crates/cartridge/Cargo.toml @@ -9,18 +9,26 @@ build = "build.rs" [dependencies] katana-contracts.workspace = true katana-genesis.workspace = true +katana-pool = { workspace = true } +katana-pool-api = { workspace = true } katana-primitives.workspace = true -katana-rpc-types = { path = "../rpc/rpc-types" } +katana-provider = { workspace = true } +katana-rpc-types = { workspace = true } +katana-paymaster = { workspace = true } anyhow.workspace = true ark-ff = "0.4.2" +cainome-cairo-serde.workspace = true +jsonrpsee = { workspace = true, features = ["server"] } lazy_static.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true stark-vrf.workspace = true starknet.workspace = true +starknet-types-core.workspace = true thiserror.workspace = true tokio.workspace = true +tower.workspace = true tracing.workspace = true url.workspace = true diff --git a/crates/cartridge/src/client.rs b/crates/cartridge/src/api.rs similarity index 98% rename from crates/cartridge/src/client.rs rename to crates/cartridge/src/api.rs index ca1b8b474..231588137 100644 --- a/crates/cartridge/src/client.rs +++ b/crates/cartridge/src/api.rs @@ -17,12 +17,12 @@ pub enum Error { /// Client for interacting with the Cartridge service. #[derive(Debug, Clone)] -pub struct Client { +pub struct CartridgeApiClient { url: Url, client: reqwest::Client, } -impl Client { +impl CartridgeApiClient { /// Creates a new [`CartridgeApiClient`] with the given URL. pub fn new(url: Url) -> Self { Self { url, client: reqwest::Client::new() } diff --git a/crates/cartridge/src/lib.rs b/crates/cartridge/src/lib.rs index 2d2aca8d9..12e0994a9 100644 --- a/crates/cartridge/src/lib.rs +++ b/crates/cartridge/src/lib.rs @@ -1,14 +1,16 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] -pub mod client; +pub mod api; +pub mod middlware; +pub mod utils; pub mod vrf; -pub use client::Client; +pub use api::CartridgeApiClient; pub use vrf::{ bootstrap_vrf, get_vrf_account, resolve_executable, wait_for_http_ok, InfoResponse, RequestContext, SignedOutsideExecution, VrfAccountCredentials, VrfBootstrap, VrfBootstrapConfig, VrfBootstrapResult, VrfClient, VrfClientError, VrfOutsideExecution, - VrfService, VrfServiceConfig, VrfServiceProcess, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, + VrfServer, VrfServerConfig, VrfServiceProcess, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, VRF_HARDCODED_SECRET_KEY, VRF_SERVER_PORT, }; diff --git a/crates/cartridge/src/middlware/layer.rs b/crates/cartridge/src/middlware/layer.rs new file mode 100644 index 000000000..0e943364f --- /dev/null +++ b/crates/cartridge/src/middlware/layer.rs @@ -0,0 +1,234 @@ +use std::borrow::Cow; +use std::future::Future; + +use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; +use jsonrpsee::core::traits::ToRpcParams; +use jsonrpsee::types::Request; +use jsonrpsee::MethodResponse; +use katana_primitives::block::BlockIdOrTag; +use katana_provider::{ProviderFactory, ProviderRO}; +use katana_rpc_types::broadcasted::BroadcastedTx; +use katana_rpc_types::FeeEstimate; +use serde::Deserialize; +use starknet::core::types::SimulationFlagForEstimateFee; +use starknet::providers::jsonrpc::JsonRpcResponse; +use tracing::{debug, trace}; + +use super::ControllerDeployment; + +#[derive(Deserialize)] +struct EstimateFeeParams { + #[serde(alias = "request")] + txs: Vec, + #[serde(alias = "simulationFlags")] + simulation_flags: Vec, + #[serde(alias = "blockId")] + block_id: BlockIdOrTag, +} + +#[derive(Debug, Clone)] +pub struct PaymasterLayer { + pub(crate) paymaster: ControllerDeployment, +} + +impl tower::Layer for PaymasterLayer { + type Service = PaymasterService; + + fn layer(&self, service: S) -> Self::Service { + PaymasterService { service, paymaster: self.paymaster.clone() } + } +} + +#[derive(Debug)] +pub struct PaymasterService { + service: S, + paymaster: ControllerDeployment, +} + +impl PaymasterService +where + S: RpcServiceT + Send + Sync + Clone + 'static, +{ + /// Extract estimate_fee parameters from the request. + fn parse_estimate_fee_params(request: &Request<'_>) -> Option { + let params = request.params(); + + if params.is_object() { + match params.parse() { + Ok(p) => Some(p), + Err(..) => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } else { + let mut seq = params.sequence(); + + let txs_result: Result, _> = seq.next(); + let simulation_flags_result: Result, _> = seq.next(); + let block_id_result: Result = seq.next(); + + match (txs_result, simulation_flags_result, block_id_result) { + (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { + Some(EstimateFeeParams { txs, simulation_flags, block_id }) + } + _ => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } + } + + /// Build a new estimate fee request with the updated transactions. + fn build_new_estimate_fee_request<'a>( + request: &Request<'a>, + params: &EstimateFeeParams, + updated_txs: &Vec, + ) -> Request<'a> { + let mut new_request = request.clone(); + + let mut new_params = jsonrpsee::core::params::ArrayParams::new(); + new_params.insert(updated_txs).unwrap(); + new_params.insert(params.simulation_flags.clone()).unwrap(); + new_params.insert(params.block_id).unwrap(); + + let new_params = new_params.to_rpc_params().unwrap(); + new_request.params = new_params.map(Cow::Owned); + new_request + } + + // <--- TODO: this function should be removed once estimateFee will return 0 fees + // when --dev.no-fee is used. + fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { + let estimate_fees = vec![ + FeeEstimate { + l1_gas_consumed: 0, + l1_gas_price: 0, + l2_gas_consumed: 0, + l2_gas_price: 0, + l1_data_gas_consumed: 0, + l1_data_gas_price: 0, + overall_fee: 0 + }; + count + ]; + + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(estimate_fees), + usize::MAX, + ) + } + // end of the no-fee response + + async fn handle_estimate_fee<'a>( + service: S, + paymaster: ControllerDeployment, + request: Request<'a>, + ) -> S::MethodResponse { + if let Some(params) = Self::parse_estimate_fee_params(&request) { + let updated_txs = paymaster + .handle_estimate_fees(params.block_id, ¶ms.txs) + .await + .unwrap_or_default(); + + if let Some(updated_txs) = updated_txs { + let new_request = + Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); + + let response = service.call(new_request).await; + + // if `handle_estimate_fees` has added some new transactions at the + // beginning of updated_txs, we have to remove + // extras results from estimate_fees to be + // sure to return the same number of result than the number + // of transactions in the request. + let nb_of_txs = params.txs.len(); + let nb_of_extra_txs = updated_txs.len() - nb_of_txs; + + if response.is_success() && nb_of_extra_txs > 0 { + if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = + serde_json::from_str::>>( + response.to_json().get(), + ) + { + if estimate_fees.len() >= nb_of_extra_txs { + estimate_fees.drain(0..nb_of_extra_txs); + } + + trace!( + target: "cartridge", + nb_of_extra_txs = nb_of_extra_txs, + nb_of_estimate_fees = estimate_fees.len(), + "Removing extra transactions from estimate fees response", + ); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint original response returned"); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); + service.call(request).await + } +} + +impl RpcServiceT for PaymasterService +where + S: RpcServiceT< + MethodResponse = MethodResponse, + BatchResponse = MethodResponse, + NotificationResponse = MethodResponse, + > + Send + + Sync + + Clone + + 'static, +{ + type MethodResponse = S::MethodResponse; + type BatchResponse = S::BatchResponse; + type NotificationResponse = S::NotificationResponse; + + fn call<'a>( + &self, + request: Request<'a>, + ) -> impl Future + Send + 'a { + let service = self.service.clone(); + let paymaster = self.paymaster.clone(); + + async move { + if request.method_name() == "starknet_estimateFee" { + Self::handle_estimate_fee(service, paymaster, request).await + } else { + service.call(request).await + } + } + } + + fn batch<'a>( + &self, + requests: Batch<'a>, + ) -> impl Future + Send + 'a { + self.service.batch(requests) + } + + fn notification<'a>( + &self, + n: Notification<'a>, + ) -> impl Future + Send + 'a { + self.service.notification(n) + } +} + +impl Clone for PaymasterService { + fn clone(&self) -> Self { + Self { service: self.service.clone(), paymaster: self.paymaster.clone() } + } +} diff --git a/crates/cartridge/src/middlware/mod.rs b/crates/cartridge/src/middlware/mod.rs new file mode 100644 index 000000000..607638235 --- /dev/null +++ b/crates/cartridge/src/middlware/mod.rs @@ -0,0 +1,634 @@ +use std::borrow::Cow; +use std::collections::HashSet; +use std::future::Future; +use std::iter::once; + +use cainome_cairo_serde::CairoSerde; +use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; +use jsonrpsee::core::traits::ToRpcParams; +use jsonrpsee::http_client::HttpClient; +use jsonrpsee::types::Request; +use jsonrpsee::MethodResponse; +use katana_genesis::constant::DEFAULT_UDC_ADDRESS; +use katana_paymaster::api::PaymasterApiClient; +use katana_pool::{TransactionPool, TxPool}; +use katana_pool_api::PoolError; +use katana_primitives::block::BlockIdOrTag; +use katana_primitives::chain::ChainId; +use katana_primitives::contract::Nonce; +use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; +use katana_primitives::hash::{Poseidon, StarkHash}; +use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; +use katana_primitives::{ContractAddress, Felt}; +use katana_provider::api::state::StateFactoryProvider; +use katana_provider::{ProviderFactory, ProviderRO}; +use katana_rpc_types::broadcasted::BroadcastedTx; +use katana_rpc_types::outside_execution::{Call as OutsideExecutionCall, OutsideExecution}; +use katana_rpc_types::BroadcastedInvokeTx; +use katana_rpc_types::FeeEstimate; +use layer::PaymasterLayer; +use serde::Deserialize; +use starknet::core::types::SimulationFlagForEstimateFee; +use starknet::macros::selector; +use starknet::providers::jsonrpc::JsonRpcResponse; +use starknet::signers::{LocalWallet, Signer, SigningKey}; +use starknet_types_core::hash::Pedersen; +use tracing::{debug, trace}; +use url::Url; + +use crate::utils::{self, encode_calls}; +use crate::vrf::client::VrfClientError; +use crate::CartridgeApiClient; + +pub mod layer; + +pub type PaymasterResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("cartridge client error: {0}")] + Client(#[from] crate::client::Error), + + #[error("provider error: {0}")] + Provider(#[from] katana_provider::api::ProviderError), + + #[error("paymaster not found")] + PaymasterNotFound(ContractAddress), + + #[error("VRF error: {0}")] + Vrf(String), + + #[error("failed to sign with paymaster: {0}")] + SigningError(String), + + #[error("failed to add deploy controller transaction to the pool: {0}")] + FailedToAddTransaction(#[from] PoolError), +} + +impl From for Error { + fn from(e: VrfClientError) -> Self { + Error::Vrf(e.to_string()) + } +} + +#[derive(Debug)] +pub struct ControllerDeployment { + cartridge_api: CartridgeApiClient, + chain_id: ChainId, + paymaster_client: HttpClient, + paymaster_key: SigningKey, + paymaster_address: ContractAddress, + vrf_client: VrfClient, + vrf_account_address: ContractAddress, +} + +impl ControllerDeployment { + #[allow(clippy::too_many_arguments)] + pub fn new( + cartridge_api: CartridgeApiClient, + chain_id: ChainId, + paymaster_address: ContractAddress, + vrf_client: VrfClient, + vrf_account_address: ContractAddress, + ) -> Self { + // Self { + // cartridge_api, + // chain_id, + // paymaster_key, + // paymaster_address, + // vrf_client, + // vrf_account_address, + // } + + todo!() + } + + /// Handle the intercept of the 'starknet_estimateFee' end point. + pub async fn handle_estimate_fees( + &self, + _block_id: katana_rpc_types::BlockIdOrTag, + transactions: &Vec, + ) -> PaymasterResult>> { + let mut deployed_controllers: HashSet = HashSet::new(); + let mut new_transactions = Vec::new(); + let mut updated_transactions = Vec::new(); + let mut has_updated_transactions = false; + + let mut paymaster_nonce = self.get_paymaster_nonce()?; + + // Process the transactions to check if some controller needs to be deployed and + // if some VRF calls have to be inserted between the original calls. + for tx in transactions { + let address = match &tx { + BroadcastedTx::Invoke(invoke_tx) => { + // Try to inject VRF calls into invoke transactions. + let updated_tx = match self.decode_calls(&invoke_tx.calldata) { + Some(calls) => match self.get_vrf_calls(&calls).await? { + Some(vrf_calls) => { + // has_updated_transactions = true; + + // let [submit_call, assert_call] = vrf_calls; + // let calls = once(submit_call) + // .chain(calls.iter().cloned()) + // .chain(once(assert_call)) + // .collect::>(); + + // BroadcastedTx::Invoke(BroadcastedInvokeTx { + // sender_address: invoke_tx.sender_address, + // calldata: self.encode_calls(&calls), + // signature: invoke_tx.signature.clone(), + // nonce: invoke_tx.nonce, + // tip: invoke_tx.tip, + // paymaster_data: invoke_tx.paymaster_data.clone(), + // resource_bounds: invoke_tx.resource_bounds.clone(), + // nonce_data_availability_mode: invoke_tx + // .nonce_data_availability_mode, + // fee_data_availability_mode: invoke_tx + // .fee_data_availability_mode, + // account_deployment_data: invoke_tx + // .account_deployment_data + // .clone(), + // is_query: invoke_tx.is_query, + // }) + + todo!() + } + + None => tx.clone(), + }, + + None => tx.clone(), + }; + + updated_transactions.push(updated_tx); + invoke_tx.sender_address + } + BroadcastedTx::Declare(declare_tx) => { + updated_transactions.push(tx.clone()); + declare_tx.sender_address + } + _ => { + updated_transactions.push(tx.clone()); + continue; + } + }; + + // If the address has already been processed in this txs batch, just skip. + if deployed_controllers.contains(&address) { + continue; + } + + let tx_opt = self.craft_controller_deploy_tx(address, paymaster_nonce).await?; + if let Some(tx) = tx_opt { + deployed_controllers.insert(address); + + let tx_hash = self + .pool + .add_transaction(ExecutableTxWithHash::new(tx.clone())) + .await + .map_err(Error::FailedToAddTransaction)?; + + new_transactions.push(self.executable_tx_to_broadcasted(tx)); + + trace!( + target: "cartridge", + controller = %address, + tx_hash = format!("{tx_hash:#x}"), + "Estimate fee: Controller deploy transaction submitted"); + + paymaster_nonce += Nonce::ONE; + } + } + + if !new_transactions.is_empty() || has_updated_transactions { + new_transactions.extend(updated_transactions.iter().cloned()); + return Ok(Some(new_transactions)); + } + + Ok(None) + } + + /// Returns a [`Layer`](tower::Layer) implementation of [`Paymaster`]. + /// + /// This allows the paymaster to be used as a middleware in Katana RPC stack. + pub fn layer(self) -> PaymasterLayer { + PaymasterLayer { paymaster: self } + } + + /// Crafts a deploy controller transaction for a cartridge controller. + /// + /// Returns None if the provided `controller_address` is not registered in the Cartridge API, + /// or if it has already been deployed. + async fn craft_controller_deploy_tx( + &self, + address: ContractAddress, + paymaster_nonce: Felt, + ) -> PaymasterResult> { + // If the address is not a controller, just ignore the tx. + let controller_calldata = match self.get_controller_ctor_calldata(address).await? { + Some(calldata) => calldata, + None => return Ok(None), + }; + + // Check if the address has already been deployed using the provider directly. + let state = self.provider.provider().latest()?; + if state.class_hash_of_contract(address)?.is_some() { + return Ok(None); + } + + // Create a Controller deploy transaction against the latest state of the network. + debug!(target: "cartridge", controller = %address, "Crafting controller deploy transaction"); + + let call = OutsideExecutionCall { + to: DEFAULT_UDC_ADDRESS, + selector: selector!("deployContract"), + calldata: controller_calldata, + }; + + let mut tx = InvokeTxV3 { + nonce: paymaster_nonce, + chain_id: self.chain_id, + tip: 0_u64, + signature: Vec::new(), + sender_address: self.paymaster_address, + paymaster_data: Vec::new(), + calldata: encode_calls(vec![call]), + account_deployment_data: Vec::new(), + nonce_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, + fee_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, + resource_bounds: ResourceBoundsMapping::All(AllResourceBoundsMapping::default()), + }; + + let tx_hash = InvokeTx::V3(tx.clone()).calculate_hash(false); + + let signer = LocalWallet::from(self.paymaster_key.clone()); + let signature = signer.sign_hash(&tx_hash).await.unwrap(); + tx.signature = vec![signature.r, signature.s]; + + let tx = ExecutableTx::Invoke(InvokeTx::V3(tx)); + + Ok(Some(tx)) + } + + /// Get the constructor calldata for a controller account or None if the address is not a + /// controller. + async fn get_controller_ctor_calldata( + &self, + address: ContractAddress, + ) -> PaymasterResult>> { + let result = self.cartridge_api.get_account_calldata(address).await?; + Ok(result.map(|r| r.constructor_calldata)) + } + + fn decode_calls(&self, calldata: &[Felt]) -> Option> { + Vec::::cairo_deserialize(calldata, 0).ok() + } + + fn encode_calls(&self, calls: &Vec) -> Vec { + Vec::::cairo_serialize(calls) + } + + fn get_calls_from_outside_execution( + &self, + outside_execution: &OutsideExecution, + ) -> Vec { + match outside_execution { + OutsideExecution::V2(v2) => v2.calls.clone(), + OutsideExecution::V3(v3) => v3.calls.clone(), + } + } + + /// Get the VRF calls for a given set of decoded invoke transaction calls. + /// + /// Uses the external VRF server via [`VrfClient::proof`] to generate VRF proofs. + /// + /// Returns None if the calls do not contain any 'request_random' VRF call + /// targeting the VRF account. + async fn get_vrf_calls( + &self, + calls: &[OutsideExecutionCall], + ) -> PaymasterResult> { + if calls.is_empty() { + return Ok(None); + } + + if let Some((req_rand_call, position)) = utils::request_random_call(calls) { + if position + 1 >= calls_len { + return Err(Error::Vrf(format!( + "request_random call must be followed by another call", + ))); + } + + if req_rand_call.to != vrf_service.account_address() { + return Err(Error::Vrf( + format!("request_random call must target the vrf account",), + )); + } + + // Delegate VRF computation to the VRF server + let chain_id = this.backend.chain_spec.id(); + let result = vrf_service + .outside_execution(address, &outside_execution, &signature, chain_id) + .await?; + + user_address = result.address; + execute_from_outside_call = build_execute_from_outside_call_from_vrf_result(&result); + } + + // // If request_random targeting the VRF account is the only call, just ignore it + // // as the generated random value will not be consumed. + // if calls.len() == 1 { + // return Ok(None); + // } + + // let caller = first_call.calldata[0]; + // let salt_or_nonce_selector = first_call.calldata[1]; + // // Salt or nonce being the salt for the `Salt` variant, and the contract address for the + // // `Nonce` variant. + // let salt_or_nonce = first_call.calldata[2]; + + // let source = if salt_or_nonce_selector == Felt::ZERO { + // let contract_address = salt_or_nonce; + // let state = self.provider.provider().latest()?; + + // let key = Pedersen::hash(&selector!("VrfProvider_nonces"), &contract_address); + // state.storage(self.vrf_account_address, key)?.unwrap_or_default() + // } else if salt_or_nonce_selector == Felt::ONE { + // salt_or_nonce + // } else { + // return Err(Error::Vrf(format!( + // "Invalid salt or nonce for VRF request, expecting 0 or 1, got \ + // {salt_or_nonce_selector}" + // ))); + // }; + + // let seed = Poseidon::hash_array(&[source, caller, self.chain_id.id()]); + + // // Use external VRF server to generate the proof. + // let proof = self.vrf_client.proof(vec![seed.to_hex_string()]).await?; + + // let submit_random_call = OutsideExecutionCall { + // to: self.vrf_account_address, + // selector: selector!("submit_random"), + // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, proof.sqrt_ratio], + // }; + + // let assert_consumed_call = OutsideExecutionCall { + // selector: selector!("assert_consumed"), + // to: self.vrf_account_address, + // calldata: vec![seed], + // }; + + // Ok(Some([submit_random_call, assert_consumed_call])) + + todo!() + } + + /// Get the nonce of the paymaster account. + /// + /// Checks the pool nonce first (for pending state), then falls back to the provider. + fn get_paymaster_nonce(&self) -> PaymasterResult { + // Check pool nonce first for the most up-to-date value. + if let Some(nonce) = self.pool.get_nonce(self.paymaster_address) { + return Ok(nonce); + } + + // Fallback to state from provider. + let state = self.provider.provider().latest()?; + match state.nonce(self.paymaster_address)? { + Some(nonce) => Ok(nonce), + None => Err(Error::PaymasterNotFound(self.paymaster_address)), + } + } +} + +impl Clone for ControllerDeployment { + fn clone(&self) -> Self { + Self { + chain_id: self.chain_id, + cartridge_api: self.cartridge_api.clone(), + paymaster_key: self.paymaster_key.clone(), + vrf_client: self.vrf_client.clone(), + vrf_account_address: self.vrf_account_address, + paymaster_address: self.paymaster_address, + paymaster_client: self.paymaster_client.clone(), + } + } +} + +#[derive(Deserialize)] +struct EstimateFeeParams { + #[serde(alias = "request")] + txs: Vec, + #[serde(alias = "simulationFlags")] + simulation_flags: Vec, + #[serde(alias = "blockId")] + block_id: BlockIdOrTag, +} + +#[derive(Debug, Clone)] +pub struct PaymasterLayer { + pub(crate) paymaster: ControllerDeployment, +} + +impl tower::Layer for PaymasterLayer { + type Service = PaymasterService; + + fn layer(&self, service: S) -> Self::Service { + PaymasterService { service, paymaster: self.paymaster.clone() } + } +} + +#[derive(Debug)] +pub struct PaymasterService { + service: S, + paymaster: ControllerDeployment, +} + +impl PaymasterService +where + S: RpcServiceT + Send + Sync + Clone + 'static, +{ + /// Extract estimate_fee parameters from the request. + fn parse_estimate_fee_params(request: &Request<'_>) -> Option { + let params = request.params(); + + if params.is_object() { + match params.parse() { + Ok(p) => Some(p), + Err(..) => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } else { + let mut seq = params.sequence(); + + let txs_result: Result, _> = seq.next(); + let simulation_flags_result: Result, _> = seq.next(); + let block_id_result: Result = seq.next(); + + match (txs_result, simulation_flags_result, block_id_result) { + (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { + Some(EstimateFeeParams { txs, simulation_flags, block_id }) + } + _ => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } + } + + /// Build a new estimate fee request with the updated transactions. + fn build_new_estimate_fee_request<'a>( + request: &Request<'a>, + params: &EstimateFeeParams, + updated_txs: &Vec, + ) -> Request<'a> { + let mut new_request = request.clone(); + + let mut new_params = jsonrpsee::core::params::ArrayParams::new(); + new_params.insert(updated_txs).unwrap(); + new_params.insert(params.simulation_flags.clone()).unwrap(); + new_params.insert(params.block_id).unwrap(); + + let new_params = new_params.to_rpc_params().unwrap(); + new_request.params = new_params.map(Cow::Owned); + new_request + } + + // <--- TODO: this function should be removed once estimateFee will return 0 fees + // when --dev.no-fee is used. + fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { + let estimate_fees = vec![ + FeeEstimate { + l1_gas_consumed: 0, + l1_gas_price: 0, + l2_gas_consumed: 0, + l2_gas_price: 0, + l1_data_gas_consumed: 0, + l1_data_gas_price: 0, + overall_fee: 0 + }; + count + ]; + + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(estimate_fees), + usize::MAX, + ) + } + // end of the no-fee response + + async fn handle_estimate_fee<'a>( + service: S, + paymaster: ControllerDeployment, + request: Request<'a>, + ) -> S::MethodResponse { + if let Some(params) = Self::parse_estimate_fee_params(&request) { + let updated_txs = paymaster + .handle_estimate_fees(params.block_id, ¶ms.txs) + .await + .unwrap_or_default(); + + if let Some(updated_txs) = updated_txs { + let new_request = + Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); + + let response = service.call(new_request).await; + + // if `handle_estimate_fees` has added some new transactions at the + // beginning of updated_txs, we have to remove + // extras results from estimate_fees to be + // sure to return the same number of result than the number + // of transactions in the request. + let nb_of_txs = params.txs.len(); + let nb_of_extra_txs = updated_txs.len() - nb_of_txs; + + if response.is_success() && nb_of_extra_txs > 0 { + if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = + serde_json::from_str::>>( + response.to_json().get(), + ) + { + if estimate_fees.len() >= nb_of_extra_txs { + estimate_fees.drain(0..nb_of_extra_txs); + } + + trace!( + target: "cartridge", + nb_of_extra_txs = nb_of_extra_txs, + nb_of_estimate_fees = estimate_fees.len(), + "Removing extra transactions from estimate fees response", + ); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint original response returned"); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); + service.call(request).await + } +} + +impl RpcServiceT for PaymasterService +where + S: RpcServiceT< + MethodResponse = MethodResponse, + BatchResponse = MethodResponse, + NotificationResponse = MethodResponse, + > + Send + + Sync + + Clone + + 'static, +{ + type MethodResponse = S::MethodResponse; + type BatchResponse = S::BatchResponse; + type NotificationResponse = S::NotificationResponse; + + fn call<'a>( + &self, + request: Request<'a>, + ) -> impl Future + Send + 'a { + let service = self.service.clone(); + let paymaster = self.paymaster.clone(); + + async move { + if request.method_name() == "starknet_estimateFee" { + Self::handle_estimate_fee(service, paymaster, request).await + } else { + service.call(request).await + } + } + } + + fn batch<'a>( + &self, + requests: Batch<'a>, + ) -> impl Future + Send + 'a { + self.service.batch(requests) + } + + fn notification<'a>( + &self, + n: Notification<'a>, + ) -> impl Future + Send + 'a { + self.service.notification(n) + } +} + +impl Clone for PaymasterService { + fn clone(&self) -> Self { + Self { service: self.service.clone(), paymaster: self.paymaster.clone() } + } +} diff --git a/crates/cartridge/src/utils.rs b/crates/cartridge/src/utils.rs new file mode 100644 index 000000000..2c1f426d6 --- /dev/null +++ b/crates/cartridge/src/utils.rs @@ -0,0 +1,19 @@ +use cainome_cairo_serde::CairoSerde; +use katana_primitives::Felt; +use katana_rpc_types::outside_execution::Call; +use starknet::macros::selector; + +/// Encodes the given calls into a vector of Felt values (New encoding, cairo 1), +/// since controller accounts are Cairo 1 contracts. +pub fn encode_calls(calls: Vec) -> Vec { + Vec::::cairo_serialize(&calls) +} + +pub fn request_random_call( + calls: &[Call], +) -> Option<(katana_rpc_types::outside_execution::Call, usize)> { + calls + .iter() + .position(|call| call.selector == selector!("request_random")) + .map(|position| (calls[position].clone(), position)) +} diff --git a/crates/cartridge/src/vrf/mod.rs b/crates/cartridge/src/vrf/mod.rs index e7e2c0f41..a10e0bb24 100644 --- a/crates/cartridge/src/vrf/mod.rs +++ b/crates/cartridge/src/vrf/mod.rs @@ -1,181 +1,4 @@ -//! VRF (Verifiable Random Function) support for Cartridge. -//! -//! This module provides: -//! - VRF client for communicating with the VRF server -//! - Bootstrap logic for deploying VRF contracts -//! - Sidecar process management +//! Cartridge VRF (Verifiable Random Function) service. -mod bootstrap; -mod client; - -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::time::{Duration, Instant}; -use std::{env, io}; - -pub use bootstrap::{ - bootstrap_vrf, get_vrf_account, VrfAccountCredentials, VrfBootstrap, VrfBootstrapConfig, - VrfBootstrapResult, BOOTSTRAP_TIMEOUT, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, - VRF_HARDCODED_SECRET_KEY, -}; -pub use client::*; -use katana_primitives::{ContractAddress, Felt}; -use tokio::process::{Child, Command}; -use tokio::time::sleep; -use tracing::{debug, info, warn}; -use url::Url; - -const LOG_TARGET: &str = "katana::cartridge::vrf::sidecar"; - -pub const VRF_SERVER_PORT: u16 = 3000; -const DEFAULT_VRF_SERVICE_PATH: &str = "vrf-server"; -pub const SIDECAR_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("bootstrap_result not set - call bootstrap() or bootstrap_result()")] - BootstrapResultNotSet, - #[error("sidecar binary not found at {0}")] - BinaryNotFound(PathBuf), - #[error("sidecar binary '{0}' not found in PATH")] - BinaryNotInPath(PathBuf), - #[error("PATH environment variable is not set")] - PathNotSet, - #[error("failed to spawn VRF sidecar")] - Spawn(#[source] io::Error), - #[error("VRF sidecar did not become ready before timeout")] - SidecarTimeout, - #[error("bootstrap failed")] - Bootstrap(#[source] anyhow::Error), -} - -pub type Result = std::result::Result; - -#[derive(Debug, Clone)] -pub struct VrfServiceConfig { - pub vrf_account_address: ContractAddress, - pub vrf_private_key: Felt, - pub secret_key: u64, -} - -#[derive(Debug, Clone)] -pub struct VrfService { - config: VrfServiceConfig, - path: PathBuf, -} - -impl VrfService { - pub fn new(config: VrfServiceConfig) -> Self { - Self { config, path: PathBuf::from(DEFAULT_VRF_SERVICE_PATH) } - } - - /// Sets the path to the vrf service program. - /// - /// If no path is set, the default executable name [`DEFAULT_VRF_SERVICE_PATH`] will be used. - pub fn path>(mut self, path: T) -> Self { - self.path = path.into(); - self - } - - pub async fn start(self) -> Result { - let bin = resolve_executable(&self.path)?; - - let mut command = Command::new(bin); - command - .arg("--port") - .arg(VRF_SERVER_PORT.to_string()) - .arg("--account-address") - .arg(self.config.vrf_account_address.to_hex_string()) - .arg("--account-private-key") - .arg(self.config.vrf_private_key.to_hex_string()) - .arg("--secret-key") - .arg(self.config.secret_key.to_string()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .kill_on_drop(true); - - let process = command.spawn().map_err(Error::Spawn)?; - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), VRF_SERVER_PORT); - - let url = Url::parse(&format!("http://{addr}")).expect("valid url"); - let client = VrfClient::new(url); - wait_for_http_ok(&client, "vrf info", SIDECAR_TIMEOUT).await?; - - info!(%addr, "VRF service started."); - - Ok(VrfServiceProcess { process, addr, inner: self }) - } -} - -/// A running VRF sidecar process. -#[derive(Debug)] -pub struct VrfServiceProcess { - process: Child, - inner: VrfService, - addr: SocketAddr, -} - -impl VrfServiceProcess { - /// Get the address of the VRF service. - pub fn addr(&self) -> &SocketAddr { - &self.addr - } - - pub fn process(&mut self) -> &mut Child { - &mut self.process - } - - pub fn config(&self) -> &VrfServiceConfig { - &self.inner.config - } - - pub async fn shutdown(&mut self) -> io::Result<()> { - self.process.kill().await - } -} - -/// Resolve an executable path, searching in PATH if necessary. -pub fn resolve_executable(path: &Path) -> Result { - if path.components().count() > 1 { - return if path.is_file() { - Ok(path.to_path_buf()) - } else { - Err(Error::BinaryNotFound(path.to_path_buf())) - }; - } - - let path_var = env::var_os("PATH").ok_or(Error::PathNotSet)?; - for dir in env::split_paths(&path_var) { - let candidate = dir.join(path); - if candidate.is_file() { - return Ok(candidate); - } - } - - Err(Error::BinaryNotInPath(path.to_path_buf())) -} - -/// Wait for the VRF sidecar to become ready by polling its `/info` endpoint. -pub async fn wait_for_http_ok(client: &VrfClient, name: &str, timeout: Duration) -> Result<()> { - let start = Instant::now(); - - loop { - match client.info().await { - Ok(_) => { - info!(target: LOG_TARGET, %name, "sidecar ready"); - return Ok(()); - } - Err(err) => { - debug!(target: LOG_TARGET, %name, error = %err, "waiting for sidecar"); - } - } - - if start.elapsed() > timeout { - warn!(target: LOG_TARGET, %name, "sidecar did not become ready in time"); - return Err(Error::SidecarTimeout); - } - - sleep(Duration::from_millis(200)).await; - } -} +pub mod client; +pub mod server; diff --git a/crates/cartridge/src/vrf/bootstrap.rs b/crates/cartridge/src/vrf/server/bootstrap.rs similarity index 100% rename from crates/cartridge/src/vrf/bootstrap.rs rename to crates/cartridge/src/vrf/server/bootstrap.rs diff --git a/crates/cartridge/src/vrf/server/mod.rs b/crates/cartridge/src/vrf/server/mod.rs new file mode 100644 index 000000000..02f2dcb58 --- /dev/null +++ b/crates/cartridge/src/vrf/server/mod.rs @@ -0,0 +1,174 @@ +mod bootstrap; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use std::{env, io}; + +pub use bootstrap::{ + bootstrap_vrf, get_vrf_account, VrfAccountCredentials, VrfBootstrap, VrfBootstrapConfig, + VrfBootstrapResult, BOOTSTRAP_TIMEOUT, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, + VRF_HARDCODED_SECRET_KEY, +}; +use katana_primitives::{ContractAddress, Felt}; +use tokio::process::{Child, Command}; +use tokio::time::sleep; +use tracing::{debug, info, warn}; +use url::Url; + +use crate::vrf::client::VrfClient; + +const LOG_TARGET: &str = "katana::cartridge::vrf::sidecar"; + +pub const VRF_SERVER_PORT: u16 = 3000; +const DEFAULT_VRF_SERVICE_PATH: &str = "vrf-server"; +pub const SIDECAR_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("bootstrap_result not set - call bootstrap() or bootstrap_result()")] + BootstrapResultNotSet, + #[error("sidecar binary not found at {0}")] + BinaryNotFound(PathBuf), + #[error("sidecar binary '{0}' not found in PATH")] + BinaryNotInPath(PathBuf), + #[error("PATH environment variable is not set")] + PathNotSet, + #[error("failed to spawn VRF sidecar")] + Spawn(#[source] io::Error), + #[error("VRF sidecar did not become ready before timeout")] + SidecarTimeout, + #[error("bootstrap failed")] + Bootstrap(#[source] anyhow::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +pub struct VrfServerConfig { + pub vrf_account_address: ContractAddress, + pub vrf_private_key: Felt, + pub secret_key: u64, +} + +#[derive(Debug, Clone)] +pub struct VrfServer { + config: VrfServerConfig, + path: PathBuf, +} + +impl VrfServer { + pub fn new(config: VrfServerConfig) -> Self { + Self { config, path: PathBuf::from(DEFAULT_VRF_SERVICE_PATH) } + } + + /// Sets the path to the vrf service program. + /// + /// If no path is set, the default executable name [`DEFAULT_VRF_SERVICE_PATH`] will be used. + pub fn path>(mut self, path: T) -> Self { + self.path = path.into(); + self + } + + pub async fn start(self) -> Result { + let bin = resolve_executable(&self.path)?; + + let mut command = Command::new(bin); + command + .arg("--port") + .arg(VRF_SERVER_PORT.to_string()) + .arg("--account-address") + .arg(self.config.vrf_account_address.to_hex_string()) + .arg("--account-private-key") + .arg(self.config.vrf_private_key.to_hex_string()) + .arg("--secret-key") + .arg(self.config.secret_key.to_string()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + + let process = command.spawn().map_err(Error::Spawn)?; + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), VRF_SERVER_PORT); + + let url = Url::parse(&format!("http://{addr}")).expect("valid url"); + let client = VrfClient::new(url); + wait_for_http_ok(&client, "vrf info", SIDECAR_TIMEOUT).await?; + + info!(%addr, "VRF service started."); + + Ok(VrfServiceProcess { process, addr, inner: self }) + } +} + +/// A running VRF sidecar process. +#[derive(Debug)] +pub struct VrfServiceProcess { + process: Child, + inner: VrfServer, + addr: SocketAddr, +} + +impl VrfServiceProcess { + /// Get the address of the VRF service. + pub fn addr(&self) -> &SocketAddr { + &self.addr + } + + pub fn process(&mut self) -> &mut Child { + &mut self.process + } + + pub fn config(&self) -> &VrfServerConfig { + &self.inner.config + } + + pub async fn shutdown(&mut self) -> io::Result<()> { + self.process.kill().await + } +} + +/// Resolve an executable path, searching in PATH if necessary. +pub fn resolve_executable(path: &Path) -> Result { + if path.components().count() > 1 { + return if path.is_file() { + Ok(path.to_path_buf()) + } else { + Err(Error::BinaryNotFound(path.to_path_buf())) + }; + } + + let path_var = env::var_os("PATH").ok_or(Error::PathNotSet)?; + for dir in env::split_paths(&path_var) { + let candidate = dir.join(path); + if candidate.is_file() { + return Ok(candidate); + } + } + + Err(Error::BinaryNotInPath(path.to_path_buf())) +} + +/// Wait for the VRF sidecar to become ready by polling its `/info` endpoint. +pub async fn wait_for_http_ok(client: &VrfClient, name: &str, timeout: Duration) -> Result<()> { + let start = Instant::now(); + + loop { + match client.info().await { + Ok(_) => { + info!(target: LOG_TARGET, %name, "sidecar ready"); + return Ok(()); + } + Err(err) => { + debug!(target: LOG_TARGET, %name, error = %err, "waiting for sidecar"); + } + } + + if start.elapsed() > timeout { + warn!(target: LOG_TARGET, %name, "sidecar did not become ready in time"); + return Err(Error::SidecarTimeout); + } + + sleep(Duration::from_millis(200)).await; + } +} diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 1a3b661c3..2af068cc9 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -3,7 +3,7 @@ use std::net::SocketAddr; use anyhow::{anyhow, Result}; #[cfg(feature = "vrf")] pub use cartridge::vrf::{ - get_vrf_account, VrfAccountCredentials, VrfBootstrapResult, VrfService, VrfServiceConfig, + get_vrf_account, VrfAccountCredentials, VrfBootstrapResult, VrfServer, VrfServerConfig, VrfServiceProcess, VRF_SERVER_PORT, }; use katana_chain_spec::ChainSpec; @@ -58,13 +58,13 @@ pub async fn bootstrap_vrf( options: &VrfOptions, rpc_addr: SocketAddr, chain: &ChainSpec, -) -> Result { +) -> Result { let rpc_url = local_rpc_url(&rpc_addr); let (account_address, pk) = prefunded_account(chain, 0)?; let result = cartridge::vrf::bootstrap_vrf(rpc_url, account_address, pk).await?; - let mut vrf_service = VrfService::new(VrfServiceConfig { + let mut vrf_service = VrfServer::new(VrfServerConfig { secret_key: result.secret_key, vrf_account_address: result.vrf_account_address, vrf_private_key: result.vrf_account_private_key, diff --git a/crates/node/full/src/lib.rs b/crates/node/full/src/lib.rs index a99d76650..66f6ebe55 100644 --- a/crates/node/full/src/lib.rs +++ b/crates/node/full/src/lib.rs @@ -171,8 +171,6 @@ impl Node { max_concurrent_estimate_fee_requests: config.rpc.max_concurrent_estimate_fee_requests, simulation_flags: ExecutionFlags::default(), versioned_constant_overrides: None, - #[cfg(feature = "cartridge")] - paymaster: None, }; let chain_spec = match config.network { diff --git a/crates/node/sequencer/Cargo.toml b/crates/node/sequencer/Cargo.toml index b5de59e31..3830783ae 100644 --- a/crates/node/sequencer/Cargo.toml +++ b/crates/node/sequencer/Cargo.toml @@ -6,6 +6,7 @@ repository.workspace = true version.workspace = true [dependencies] +cartridge = { workspace = true, optional = true } katana-node-config.workspace = true katana-chain-spec.workspace = true katana-core.workspace = true @@ -33,6 +34,8 @@ futures.workspace = true http.workspace = true jsonrpsee.workspace = true serde.workspace = true +starknet = { workspace = true, optional = true } +tower.workspace = true tracing.workspace = true url.workspace = true @@ -46,6 +49,8 @@ vrf = [ "cartridge", ] cartridge = [ + "dep:cartridge", + "dep:starknet", "katana-node-config/cartridge", "katana-rpc-api/cartridge", "katana-rpc-server/cartridge", diff --git a/crates/node/sequencer/src/exit.rs b/crates/node/sequencer/src/exit.rs index 15eb9436d..784e26f13 100644 --- a/crates/node/sequencer/src/exit.rs +++ b/crates/node/sequencer/src/exit.rs @@ -18,7 +18,7 @@ pub struct NodeStoppedFuture<'a> { impl<'a> NodeStoppedFuture<'a> { pub(crate) fn new

(handle: &'a LaunchedNode

) -> Self where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index a798d0450..b7e630bca 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -7,10 +7,14 @@ use std::future::IntoFuture; use std::sync::Arc; use anyhow::{bail, Context, Result}; +#[cfg(feature = "cartridge")] +use cartridge::rpc::{layer::PaymasterLayer, Paymaster}; use config::rpc::RpcModuleKind; use config::Config; use http::header::CONTENT_TYPE; use http::Method; +#[cfg(feature = "cartridge")] +use jsonrpsee::core::middleware::layer::Either; use jsonrpsee::RpcModule; use katana_chain_spec::{ChainSpec, SettlementLayer}; use katana_core::backend::Backend; @@ -50,22 +54,41 @@ use katana_rpc_client::starknet::Client as StarknetClient; use katana_rpc_server::cartridge::{CartridgeApi, CartridgeConfig}; use katana_rpc_server::cors::Cors; use katana_rpc_server::dev::DevApi; +use katana_rpc_server::logger::RpcLoggerLayer; +use katana_rpc_server::metrics::RpcServerMetricsLayer; #[cfg(feature = "paymaster")] use katana_rpc_server::paymaster::PaymasterProxy; -#[cfg(feature = "cartridge")] -use katana_rpc_server::starknet::CartridgePaymasterConfig; use katana_rpc_server::starknet::{StarknetApi, StarknetApiConfig}; #[cfg(feature = "tee")] use katana_rpc_server::tee::TeeApi; -use katana_rpc_server::{RpcServer, RpcServerHandle}; +use katana_rpc_server::{RpcServer, RpcServerHandle, RpcServiceBuilder}; use katana_rpc_types::GetBlockWithTxHashesResponse; use katana_stage::Sequencing; use katana_tasks::TaskManager; use num_traits::ToPrimitive; +#[cfg(feature = "cartridge")] +use starknet::signers::SigningKey; +use tower::layer::util::{Identity, Stack}; use tracing::info; use crate::exit::NodeStoppedFuture; +/// The concrete type of the RPC middleware stack used by the node. +#[cfg(feature = "cartridge")] +type NodeRpcMiddleware

= Stack< + Either, Identity>, + Stack>, +>; + +#[cfg(not(feature = "cartridge"))] +type NodeRpcMiddleware = Stack>; + +#[cfg(feature = "cartridge")] +pub type NodeRpcServer

= RpcServer>; + +#[cfg(not(feature = "cartridge"))] +pub type NodeRpcServer = RpcServer; + /// A node instance. /// /// The struct contains the handle to all the components of the node. @@ -73,7 +96,7 @@ use crate::exit::NodeStoppedFuture; #[derive(Debug)] pub struct Node

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { @@ -81,7 +104,10 @@ where provider: P, config: Arc, pool: TxPool, - rpc_server: RpcServer, + #[cfg(feature = "cartridge")] + rpc_server: NodeRpcServer

, + #[cfg(not(feature = "cartridge"))] + rpc_server: NodeRpcServer, #[cfg(feature = "grpc")] grpc_server: Option, task_manager: TaskManager, @@ -225,7 +251,7 @@ where }; #[cfg(feature = "cartridge")] - let cartridge_paymaster = if let Some(cfg) = &config.paymaster { + if let Some(cfg) = &config.paymaster { if let Some(cartridge_api_cfg) = &cfg.cartridge_api { anyhow::ensure!( config.rpc.apis.contains(&RpcModuleKind::Cartridge), @@ -290,8 +316,6 @@ where max_concurrent_estimate_fee_requests: config.rpc.max_concurrent_estimate_fee_requests, simulation_flags: execution_flags, versioned_constant_overrides, - #[cfg(feature = "cartridge")] - paymaster: cartridge_paymaster, }; let chain_spec = backend.chain_spec.clone(); @@ -357,9 +381,62 @@ where } } + // --- build paymaster tower layer (if configured) + + #[cfg(feature = "cartridge")] + let paymaster = if let Some(cfg) = &config.paymaster { + if let Some(cartridge_api_cfg) = &cfg.cartridge_api { + if let Some(vrf_cfg) = &cartridge_api_cfg.vrf { + info!(target: "cartridge", "Paymaster tower layer enabled"); + + let cartridge_api_client = cartridge::CartridgeApiClient::new( + cartridge_api_cfg.cartridge_api_url.clone(), + ); + + let rpc_url = url::Url::parse(&format!("http://{}", config.rpc.socket_addr())) + .expect("valid rpc url"); + + let vrf_client = cartridge::VrfClient::new(vrf_cfg.url.clone()); + + Some(Paymaster::new( + provider.clone(), + cartridge_api_client, + pool.clone(), + config.chain.id(), + cartridge_api_cfg.controller_deployer_address, + SigningKey::from_secret_scalar( + cartridge_api_cfg.controller_deployer_private_key, + ), + vrf_client, + vrf_cfg.vrf_account, + rpc_url, + )) + } else { + None + } + } else { + None + } + } else { + None + }; + + // --- build rpc middleware + + let rpc_middleware = RpcServiceBuilder::new() + .layer(RpcServerMetricsLayer::new(&rpc_modules)) + .layer(RpcLoggerLayer::new()); + + #[cfg(feature = "cartridge")] + let rpc_middleware = rpc_middleware.option_layer(paymaster.map(|p| p.layer())); + #[allow(unused_mut)] - let mut rpc_server = - RpcServer::new().metrics(true).health_check(true).cors(cors).module(rpc_modules)?; + let mut rpc_server = RpcServer::new() + .rpc_middleware(rpc_middleware) + .metrics(true) + .health_check(true) + .cors(cors) + .module(rpc_modules)?; #[cfg(feature = "explorer")] { @@ -569,7 +646,7 @@ impl Node { impl

Node

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { @@ -680,7 +757,14 @@ where } /// Returns a reference to the node's JSON-RPC server. - pub fn rpc(&self) -> &RpcServer { + #[cfg(feature = "cartridge")] + pub fn rpc(&self) -> &NodeRpcServer

{ + &self.rpc_server + } + + /// Returns a reference to the node's JSON-RPC server. + #[cfg(not(feature = "cartridge"))] + pub fn rpc(&self) -> &NodeRpcServer { &self.rpc_server } @@ -704,7 +788,7 @@ where #[derive(Debug)] pub struct LaunchedNode

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { @@ -722,7 +806,7 @@ where impl

LaunchedNode

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index d0a6bc9ad..14c87d41f 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -66,9 +66,11 @@ use starknet_paymaster::core::types::Call as PaymasterCall; use tracing::{debug, info}; use url::Url; #[cfg(feature = "vrf")] -pub use vrf::VrfServiceConfig; use vrf::{outside_execution_calls_len, request_random_call, VrfService}; +pub use vrf::VrfService; +pub use vrf::VrfServiceConfig; + #[derive(Debug, Clone)] pub struct CartridgeConfig { pub api_url: Url, @@ -86,7 +88,7 @@ pub struct CartridgeApi { backend: Arc>, block_producer: BlockProducer, pool: TxPool, - api_client: cartridge::Client, + api_client: cartridge::CartridgeApiClient, paymaster_client: HttpClient, /// The paymaster account address used for controller deployment. controller_deployer_address: ContractAddress, @@ -129,7 +131,7 @@ where task_spawner: TaskSpawner, config: CartridgeConfig, ) -> anyhow::Result { - let api_client = cartridge::Client::new(config.api_url); + let api_client = cartridge::CartridgeApiClient::new(config.api_url); #[cfg(feature = "vrf")] let vrf_service = config.vrf.map(VrfService::new); @@ -358,7 +360,7 @@ pub async fn get_controller_deploy_tx_if_controller_address( tx: &ExecutableTxWithHash, chain_id: ChainId, state: Arc>, - cartridge_api_client: &cartridge::Client, + cartridge_api_client: &cartridge::CartridgeApiClient, ) -> anyhow::Result> { // The whole Cartridge paymaster flow would only be accessible mainly from the Controller // wallet. The Controller wallet only supports V3 transactions (considering < V3 @@ -396,7 +398,7 @@ pub async fn get_controller_deploy_tx_if_controller_address( /// /// Returns None if the provided `controller_address` is not registered in the Cartridge API. pub async fn craft_deploy_cartridge_controller_tx( - cartridge_api_client: &cartridge::Client, + cartridge_api_client: &cartridge::CartridgeApiClient, controller_address: ContractAddress, paymaster_address: ContractAddress, paymaster_private_key: Felt, diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index e8de71329..f126b75ce 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -71,12 +71,8 @@ impl VrfService { pub(super) fn request_random_call( outside_execution: &OutsideExecution, ) -> Option<(katana_rpc_types::outside_execution::Call, usize)> { - let calls = match outside_execution { - OutsideExecution::V2(v2) => &v2.calls, - OutsideExecution::V3(v3) => &v3.calls, - }; - - calls + outside_execution + .calls() .iter() .position(|call| call.selector == selector!("request_random")) .map(|position| (calls[position].clone(), position)) diff --git a/crates/rpc/rpc-server/src/lib.rs b/crates/rpc/rpc-server/src/lib.rs index cf5092c64..1f700d986 100644 --- a/crates/rpc/rpc-server/src/lib.rs +++ b/crates/rpc/rpc-server/src/lib.rs @@ -6,12 +6,14 @@ use std::net::SocketAddr; use std::time::Duration; -use jsonrpsee::core::middleware::RpcServiceBuilder; +use jsonrpsee::core::middleware::RpcServiceT; use jsonrpsee::core::{RegisterMethodError, TEN_MB_SIZE_BYTES}; +use jsonrpsee::server::middleware::rpc::RpcService; use jsonrpsee::server::{Server, ServerConfig, ServerHandle}; -use jsonrpsee::RpcModule; +use jsonrpsee::{MethodResponse, RpcModule}; use katana_tracing::gcloud::GoogleStackDriverMakeSpan; -use tower::ServiceBuilder; +use tower::layer::util::Identity; +use tower::{Layer, ServiceBuilder}; use tower_http::trace::TraceLayer; use tracing::info; @@ -23,20 +25,18 @@ pub mod paymaster; #[cfg(feature = "tee")] pub mod tee; -pub mod cors; pub mod dev; pub mod health; -pub mod metrics; +pub mod middleware; pub mod permit; pub mod starknet; -mod logger; mod utils; use cors::Cors; use health::HealthCheck; +pub use jsonrpsee::core::middleware::RpcServiceBuilder; pub use jsonrpsee::http_client::HttpClient; pub use katana_rpc_api as api; -use metrics::RpcServerMetricsLayer; /// The default maximum number of concurrent RPC connections. pub const DEFAULT_RPC_MAX_CONNECTIONS: u32 = 100; @@ -98,7 +98,7 @@ impl RpcServerHandle { } #[derive(Debug)] -pub struct RpcServer { +pub struct RpcServer { metrics: bool, cors: Option, health_check: bool, @@ -109,9 +109,11 @@ pub struct RpcServer { max_request_body_size: u32, max_response_body_size: u32, timeout: Duration, + + rpc_middleware: RpcServiceBuilder, } -impl RpcServer { +impl RpcServer { pub fn new() -> Self { Self { cors: None, @@ -123,9 +125,12 @@ impl RpcServer { max_request_body_size: TEN_MB_SIZE_BYTES, max_response_body_size: TEN_MB_SIZE_BYTES, timeout: DEFAULT_TIMEOUT, + rpc_middleware: RpcServiceBuilder::new(), } } +} +impl RpcServer { /// Set the maximum number of connections allowed. Default is 100. pub fn max_connections(mut self, max: u32) -> Self { self.max_connections = max; @@ -175,6 +180,22 @@ impl RpcServer { self } + /// Configure custom RPC middleware. + pub fn rpc_middleware(self, middleware: RpcServiceBuilder) -> RpcServer { + RpcServer { + rpc_middleware: middleware, + cors: self.cors, + module: self.module, + timeout: self.timeout, + metrics: self.metrics, + explorer: self.explorer, + health_check: self.health_check, + max_connections: self.max_connections, + max_request_body_size: self.max_request_body_size, + max_response_body_size: self.max_response_body_size, + } + } + /// Adds a new RPC module to the server. /// /// This can be chained with other calls to `module` to add multiple modules. @@ -188,7 +209,19 @@ impl RpcServer { self.module.merge(module)?; Ok(self) } +} +impl RpcServer +where + RpcMiddleware: Layer + Clone + Send + 'static, + >::Service: RpcServiceT< + MethodResponse = MethodResponse, + BatchResponse = MethodResponse, + NotificationResponse = MethodResponse, + > + Send + + Sync + + 'static, +{ pub async fn start(&self, addr: SocketAddr) -> Result { let mut modules = self.module.clone(); @@ -207,7 +240,6 @@ impl RpcServer { None }; - let rpc_metrics = self.metrics.then(|| RpcServerMetricsLayer::new(&modules)); let http_tracer = TraceLayer::new_for_http().make_span_with(GoogleStackDriverMakeSpan); let http_middleware = ServiceBuilder::new() @@ -219,9 +251,6 @@ impl RpcServer { #[cfg(feature = "explorer")] let http_middleware = http_middleware.option_layer(explorer_layer); - let rpc_middleware = - RpcServiceBuilder::new().option_layer(rpc_metrics).layer(logger::RpcLoggerLayer::new()); - let cfg = ServerConfig::builder() .max_connections(self.max_connections) .max_request_body_size(self.max_request_body_size) @@ -230,7 +259,7 @@ impl RpcServer { let server = Server::builder() .set_http_middleware(http_middleware) - .set_rpc_middleware(rpc_middleware) + .set_rpc_middleware(self.rpc_middleware.clone()) .set_config(cfg) .build(addr) .await?; @@ -255,7 +284,7 @@ impl RpcServer { } } -impl Default for RpcServer { +impl Default for RpcServer { fn default() -> Self { Self::new() } diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs new file mode 100644 index 000000000..be7925bff --- /dev/null +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -0,0 +1,628 @@ +use std::borrow::Cow; +use std::collections::HashSet; +use std::future::Future; +use std::iter::once; + +use cainome_cairo_serde::CairoSerde; +use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; +use jsonrpsee::core::traits::ToRpcParams; +use jsonrpsee::http_client::HttpClient; +use jsonrpsee::types::Request; +use jsonrpsee::MethodResponse; +use katana_genesis::constant::DEFAULT_UDC_ADDRESS; +use katana_paymaster::api::PaymasterApiClient; +use katana_pool::{TransactionPool, TxPool}; +use katana_pool_api::PoolError; +use katana_primitives::block::BlockIdOrTag; +use katana_primitives::chain::ChainId; +use katana_primitives::contract::Nonce; +use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; +use katana_primitives::hash::{Poseidon, StarkHash}; +use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; +use katana_primitives::{ContractAddress, Felt}; +use katana_provider::api::state::StateFactoryProvider; +use katana_provider::{ProviderFactory, ProviderRO}; +use katana_provider::{ProviderFactory, ProviderRO}; +use katana_rpc_types::broadcasted::BroadcastedTx; +use katana_rpc_types::broadcasted::BroadcastedTx; +use katana_rpc_types::outside_execution::{Call as OutsideExecutionCall, OutsideExecution}; +use katana_rpc_types::BroadcastedInvokeTx; +use katana_rpc_types::FeeEstimate; +use layer::PaymasterLayer; +use serde::Deserialize; +use starknet::core::types::SimulationFlagForEstimateFee; +use starknet::macros::selector; +use starknet::providers::jsonrpc::JsonRpcResponse; +use starknet::signers::{LocalWallet, Signer, SigningKey}; +use starknet_types_core::hash::Pedersen; +use tracing::{debug, trace}; +use tracing::{debug, trace}; +use url::Url; + +use super::ControllerDeployment; +use crate::cartridge::{VrfService, VrfServiceConfig}; +use crate::utils::{self, encode_calls}; +use crate::Client; + +pub type PaymasterResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("cartridge client error: {0}")] + Client(#[from] crate::client::Error), + + #[error("provider error: {0}")] + Provider(#[from] katana_provider::api::ProviderError), + + #[error("paymaster not found")] + PaymasterNotFound(ContractAddress), + + #[error("VRF error: {0}")] + Vrf(String), + + #[error("failed to sign with paymaster: {0}")] + SigningError(String), + + #[error("failed to add deploy controller transaction to the pool: {0}")] + FailedToAddTransaction(#[from] PoolError), +} + +impl From for Error { + fn from(e: VrfClientError) -> Self { + Error::Vrf(e.to_string()) + } +} + +#[derive(Debug)] +pub struct ControllerDeployment { + chain_id: ChainId, + cartridge_api: Client, + paymaster_client: HttpClient, + // paymaster_key: SigningKey, + // paymaster_address: ContractAddress, + vrf_service: Option, +} + +impl ControllerDeployment { + #[allow(clippy::too_many_arguments)] + pub fn new( + chain_id: ChainId, + cartridge_api: Client, + paymaster_client: HttpClient, + vrf: Option, + ) -> Self { + Self { + chain_id, + cartridge_api, + paymaster_client, + vrf_service: config.vrf.map(VrfService::new), + } + } + + /// Handle the intercept of the 'starknet_estimateFee' end point. + pub async fn handle_estimate_fees( + &self, + _block_id: katana_rpc_types::BlockIdOrTag, + transactions: &Vec, + ) -> PaymasterResult>> { + let mut deployed_controllers: HashSet = HashSet::new(); + let mut new_transactions = Vec::new(); + let mut updated_transactions = Vec::new(); + let mut has_updated_transactions = false; + + let mut paymaster_nonce = self.get_paymaster_nonce()?; + + // Process the transactions to check if some controller needs to be deployed and + // if some VRF calls have to be inserted between the original calls. + for tx in transactions { + let address = match &tx { + BroadcastedTx::Invoke(invoke_tx) => { + // Try to inject VRF calls into invoke transactions. + let updated_tx = match self.decode_calls(&invoke_tx.calldata) { + Some(calls) => match self.get_vrf_calls(&calls).await? { + Some(vrf_calls) => { + // has_updated_transactions = true; + + // let [submit_call, assert_call] = vrf_calls; + // let calls = once(submit_call) + // .chain(calls.iter().cloned()) + // .chain(once(assert_call)) + // .collect::>(); + + // BroadcastedTx::Invoke(BroadcastedInvokeTx { + // sender_address: invoke_tx.sender_address, + // calldata: self.encode_calls(&calls), + // signature: invoke_tx.signature.clone(), + // nonce: invoke_tx.nonce, + // tip: invoke_tx.tip, + // paymaster_data: invoke_tx.paymaster_data.clone(), + // resource_bounds: invoke_tx.resource_bounds.clone(), + // nonce_data_availability_mode: invoke_tx + // .nonce_data_availability_mode, + // fee_data_availability_mode: invoke_tx + // .fee_data_availability_mode, + // account_deployment_data: invoke_tx + // .account_deployment_data + // .clone(), + // is_query: invoke_tx.is_query, + // }) + + todo!() + } + + None => tx.clone(), + }, + + None => tx.clone(), + }; + + updated_transactions.push(updated_tx); + invoke_tx.sender_address + } + BroadcastedTx::Declare(declare_tx) => { + updated_transactions.push(tx.clone()); + declare_tx.sender_address + } + _ => { + updated_transactions.push(tx.clone()); + continue; + } + }; + + // If the address has already been processed in this txs batch, just skip. + if deployed_controllers.contains(&address) { + continue; + } + + let tx_opt = self.craft_controller_deploy_tx(address, paymaster_nonce).await?; + if let Some(tx) = tx_opt { + deployed_controllers.insert(address); + + let tx_hash = self + .pool + .add_transaction(ExecutableTxWithHash::new(tx.clone())) + .await + .map_err(Error::FailedToAddTransaction)?; + + new_transactions.push(self.executable_tx_to_broadcasted(tx)); + + trace!( + target: "cartridge", + controller = %address, + tx_hash = format!("{tx_hash:#x}"), + "Estimate fee: Controller deploy transaction submitted"); + + paymaster_nonce += Nonce::ONE; + } + } + + if !new_transactions.is_empty() || has_updated_transactions { + new_transactions.extend(updated_transactions.iter().cloned()); + return Ok(Some(new_transactions)); + } + + Ok(None) + } + + /// Returns a [`Layer`](tower::Layer) implementation of [`Paymaster`]. + /// + /// This allows the paymaster to be used as a middleware in Katana RPC stack. + pub fn layer(self) -> PaymasterLayer { + PaymasterLayer { paymaster: self } + } + + /// Crafts a deploy controller transaction for a cartridge controller. + /// + /// Returns None if the provided `controller_address` is not registered in the Cartridge API, + /// or if it has already been deployed. + async fn craft_controller_deploy_tx( + &self, + address: ContractAddress, + paymaster_nonce: Felt, + ) -> PaymasterResult> { + // If the address is not a controller, just ignore the tx. + let controller_calldata = match self.get_controller_ctor_calldata(address).await? { + Some(calldata) => calldata, + None => return Ok(None), + }; + + // Check if the address has already been deployed using the provider directly. + let state = self.provider.provider().latest()?; + if state.class_hash_of_contract(address)?.is_some() { + return Ok(None); + } + + // Create a Controller deploy transaction against the latest state of the network. + debug!(target: "cartridge", controller = %address, "Crafting controller deploy transaction"); + + let call = OutsideExecutionCall { + to: DEFAULT_UDC_ADDRESS, + selector: selector!("deployContract"), + calldata: controller_calldata, + }; + + let mut tx = InvokeTxV3 { + nonce: paymaster_nonce, + chain_id: self.chain_id, + tip: 0_u64, + signature: Vec::new(), + sender_address: self.paymaster_address, + paymaster_data: Vec::new(), + calldata: encode_calls(vec![call]), + account_deployment_data: Vec::new(), + nonce_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, + fee_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, + resource_bounds: ResourceBoundsMapping::All(AllResourceBoundsMapping::default()), + }; + + let tx_hash = InvokeTx::V3(tx.clone()).calculate_hash(false); + + let signer = LocalWallet::from(self.paymaster_key.clone()); + let signature = signer.sign_hash(&tx_hash).await.unwrap(); + tx.signature = vec![signature.r, signature.s]; + + let tx = ExecutableTx::Invoke(InvokeTx::V3(tx)); + + Ok(Some(tx)) + } + + /// Get the constructor calldata for a controller account or None if the address is not a + /// controller. + async fn get_controller_ctor_calldata( + &self, + address: ContractAddress, + ) -> PaymasterResult>> { + let result = self.cartridge_api.get_account_calldata(address).await?; + Ok(result.map(|r| r.constructor_calldata)) + } + + fn decode_calls(&self, calldata: &[Felt]) -> Option> { + Vec::::cairo_deserialize(calldata, 0).ok() + } + + fn encode_calls(&self, calls: &Vec) -> Vec { + Vec::::cairo_serialize(calls) + } + + fn get_calls_from_outside_execution( + &self, + outside_execution: &OutsideExecution, + ) -> Vec { + match outside_execution { + OutsideExecution::V2(v2) => v2.calls.clone(), + OutsideExecution::V3(v3) => v3.calls.clone(), + } + } + + /// Get the VRF calls for a given set of decoded invoke transaction calls. + /// + /// Uses the external VRF server via [`VrfClient::proof`] to generate VRF proofs. + /// + /// Returns None if the calls do not contain any 'request_random' VRF call + /// targeting the VRF account. + async fn get_vrf_calls( + &self, + calls: &[OutsideExecutionCall], + ) -> PaymasterResult> { + if calls.is_empty() { + return Ok(None); + } + + if let Some(vrf_service) = &self.vrf_service { + if let Some((req_rand_call, position)) = utils::request_random_call(calls) { + if position + 1 >= calls_len { + return Err(Error::Vrf(format!( + "request_random call must be followed by another call", + ))); + } + + if req_rand_call.to != vrf_service.account_address() { + return Err(Error::Vrf(format!( + "request_random call must target the vrf account", + ))); + } + + let result = vrf_service + .outside_execution(address, &outside_execution, &signature, self.chain_id.id()) + .await?; + + user_address = result.address; + execute_from_outside_call = + build_execute_from_outside_call_from_vrf_result(&result); + } + } + + // // If request_random targeting the VRF account is the only call, just ignore it + // // as the generated random value will not be consumed. + // if calls.len() == 1 { + // return Ok(None); + // } + + // let caller = first_call.calldata[0]; + // let salt_or_nonce_selector = first_call.calldata[1]; + // // Salt or nonce being the salt for the `Salt` variant, and the contract address for the + // // `Nonce` variant. + // let salt_or_nonce = first_call.calldata[2]; + + // let source = if salt_or_nonce_selector == Felt::ZERO { + // let contract_address = salt_or_nonce; + // let state = self.provider.provider().latest()?; + + // let key = Pedersen::hash(&selector!("VrfProvider_nonces"), &contract_address); + // state.storage(self.vrf_account_address, key)?.unwrap_or_default() + // } else if salt_or_nonce_selector == Felt::ONE { + // salt_or_nonce + // } else { + // return Err(Error::Vrf(format!( + // "Invalid salt or nonce for VRF request, expecting 0 or 1, got \ + // {salt_or_nonce_selector}" + // ))); + // }; + + // let seed = Poseidon::hash_array(&[source, caller, self.chain_id.id()]); + + // // Use external VRF server to generate the proof. + // let proof = self.vrf_client.proof(vec![seed.to_hex_string()]).await?; + + // let submit_random_call = OutsideExecutionCall { + // to: self.vrf_account_address, + // selector: selector!("submit_random"), + // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, proof.sqrt_ratio], + // }; + + // let assert_consumed_call = OutsideExecutionCall { + // selector: selector!("assert_consumed"), + // to: self.vrf_account_address, + // calldata: vec![seed], + // }; + + // Ok(Some([submit_random_call, assert_consumed_call])) + + todo!() + } + + /// Get the nonce of the paymaster account. + /// + /// Checks the pool nonce first (for pending state), then falls back to the provider. + fn get_paymaster_nonce(&self) -> PaymasterResult { + // Check pool nonce first for the most up-to-date value. + if let Some(nonce) = self.pool.get_nonce(self.paymaster_address) { + return Ok(nonce); + } + + // Fallback to state from provider. + let state = self.provider.provider().latest()?; + match state.nonce(self.paymaster_address)? { + Some(nonce) => Ok(nonce), + None => Err(Error::PaymasterNotFound(self.paymaster_address)), + } + } +} + +impl Clone for ControllerDeployment { + fn clone(&self) -> Self { + Self { + chain_id: self.chain_id, + vrf_service: self.vrf_service.clone(), + cartridge_api: self.cartridge_api.clone(), + paymaster_client: self.paymaster_client.clone(), + } + } +} + +#[derive(Deserialize)] +struct EstimateFeeParams { + #[serde(alias = "request")] + txs: Vec, + #[serde(alias = "simulationFlags")] + simulation_flags: Vec, + #[serde(alias = "blockId")] + block_id: BlockIdOrTag, +} + +#[derive(Debug, Clone)] +pub struct PaymasterLayer { + pub(crate) paymaster: ControllerDeployment, +} + +impl tower::Layer for PaymasterLayer { + type Service = PaymasterService; + + fn layer(&self, service: S) -> Self::Service { + PaymasterService { service, paymaster: self.paymaster.clone() } + } +} + +#[derive(Debug)] +pub struct PaymasterService { + service: S, + paymaster: ControllerDeployment, +} + +impl PaymasterService +where + S: RpcServiceT + Send + Sync + Clone + 'static, +{ + /// Extract estimate_fee parameters from the request. + fn parse_estimate_fee_params(request: &Request<'_>) -> Option { + let params = request.params(); + + if params.is_object() { + match params.parse() { + Ok(p) => Some(p), + Err(..) => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } else { + let mut seq = params.sequence(); + + let txs_result: Result, _> = seq.next(); + let simulation_flags_result: Result, _> = seq.next(); + let block_id_result: Result = seq.next(); + + match (txs_result, simulation_flags_result, block_id_result) { + (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { + Some(EstimateFeeParams { txs, simulation_flags, block_id }) + } + _ => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } + } + + /// Build a new estimate fee request with the updated transactions. + fn build_new_estimate_fee_request<'a>( + request: &Request<'a>, + params: &EstimateFeeParams, + updated_txs: &Vec, + ) -> Request<'a> { + let mut new_request = request.clone(); + + let mut new_params = jsonrpsee::core::params::ArrayParams::new(); + new_params.insert(updated_txs).unwrap(); + new_params.insert(params.simulation_flags.clone()).unwrap(); + new_params.insert(params.block_id).unwrap(); + + let new_params = new_params.to_rpc_params().unwrap(); + new_request.params = new_params.map(Cow::Owned); + new_request + } + + // <--- TODO: this function should be removed once estimateFee will return 0 fees + // when --dev.no-fee is used. + fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { + let estimate_fees = vec![ + FeeEstimate { + l1_gas_consumed: 0, + l1_gas_price: 0, + l2_gas_consumed: 0, + l2_gas_price: 0, + l1_data_gas_consumed: 0, + l1_data_gas_price: 0, + overall_fee: 0 + }; + count + ]; + + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(estimate_fees), + usize::MAX, + ) + } + // end of the no-fee response + + async fn handle_estimate_fee<'a>( + service: S, + paymaster: ControllerDeployment, + request: Request<'a>, + ) -> S::MethodResponse { + if let Some(params) = Self::parse_estimate_fee_params(&request) { + let updated_txs = paymaster + .handle_estimate_fees(params.block_id, ¶ms.txs) + .await + .unwrap_or_default(); + + if let Some(updated_txs) = updated_txs { + let new_request = + Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); + + let response = service.call(new_request).await; + + // if `handle_estimate_fees` has added some new transactions at the + // beginning of updated_txs, we have to remove + // extras results from estimate_fees to be + // sure to return the same number of result than the number + // of transactions in the request. + let nb_of_txs = params.txs.len(); + let nb_of_extra_txs = updated_txs.len() - nb_of_txs; + + if response.is_success() && nb_of_extra_txs > 0 { + if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = + serde_json::from_str::>>( + response.to_json().get(), + ) + { + if estimate_fees.len() >= nb_of_extra_txs { + estimate_fees.drain(0..nb_of_extra_txs); + } + + trace!( + target: "cartridge", + nb_of_extra_txs = nb_of_extra_txs, + nb_of_estimate_fees = estimate_fees.len(), + "Removing extra transactions from estimate fees response", + ); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint original response returned"); + + // TODO: restore the real response + return Self::build_no_fee_response(&request, nb_of_txs); + } + } + + trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); + service.call(request).await + } +} + +impl RpcServiceT for PaymasterService +where + S: RpcServiceT< + MethodResponse = MethodResponse, + BatchResponse = MethodResponse, + NotificationResponse = MethodResponse, + > + Send + + Sync + + Clone + + 'static, +{ + type MethodResponse = S::MethodResponse; + type BatchResponse = S::BatchResponse; + type NotificationResponse = S::NotificationResponse; + + fn call<'a>( + &self, + request: Request<'a>, + ) -> impl Future + Send + 'a { + let service = self.service.clone(); + let paymaster = self.paymaster.clone(); + + async move { + if request.method_name() == "starknet_estimateFee" { + Self::handle_estimate_fee(service, paymaster, request).await + } else { + service.call(request).await + } + } + } + + fn batch<'a>( + &self, + requests: Batch<'a>, + ) -> impl Future + Send + 'a { + self.service.batch(requests) + } + + fn notification<'a>( + &self, + n: Notification<'a>, + ) -> impl Future + Send + 'a { + self.service.notification(n) + } +} + +impl Clone for PaymasterService { + fn clone(&self) -> Self { + Self { service: self.service.clone(), paymaster: self.paymaster.clone() } + } +} diff --git a/crates/rpc/rpc-server/src/cors.rs b/crates/rpc/rpc-server/src/middleware/cors.rs similarity index 100% rename from crates/rpc/rpc-server/src/cors.rs rename to crates/rpc/rpc-server/src/middleware/cors.rs diff --git a/crates/rpc/rpc-server/src/logger.rs b/crates/rpc/rpc-server/src/middleware/logger.rs similarity index 95% rename from crates/rpc/rpc-server/src/logger.rs rename to crates/rpc/rpc-server/src/middleware/logger.rs index 04a530b8e..89bd9dfa1 100644 --- a/crates/rpc/rpc-server/src/logger.rs +++ b/crates/rpc/rpc-server/src/middleware/logger.rs @@ -16,6 +16,12 @@ impl RpcLoggerLayer { } } +impl Default for RpcLoggerLayer { + fn default() -> Self { + Self::new() + } +} + impl tower::Layer for RpcLoggerLayer { type Service = RpcLogger; diff --git a/crates/rpc/rpc-server/src/metrics.rs b/crates/rpc/rpc-server/src/middleware/metrics.rs similarity index 96% rename from crates/rpc/rpc-server/src/metrics.rs rename to crates/rpc/rpc-server/src/middleware/metrics.rs index 99c08f019..f68fa6fa7 100644 --- a/crates/rpc/rpc-server/src/metrics.rs +++ b/crates/rpc/rpc-server/src/middleware/metrics.rs @@ -96,7 +96,6 @@ struct RpcServerCallMetrics { } /// Tower layer for RPC server metrics -#[allow(missing_debug_implementations)] #[derive(Clone)] pub struct RpcServerMetricsLayer { metrics: RpcServerMetrics, @@ -108,6 +107,12 @@ impl RpcServerMetricsLayer { } } +impl std::fmt::Debug for RpcServerMetricsLayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RpcServerMetricsLayer").field("metrics", &"..").finish() + } +} + impl Layer for RpcServerMetricsLayer { type Service = RpcRequestMetricsService; diff --git a/crates/rpc/rpc-server/src/middleware/mod.rs b/crates/rpc/rpc-server/src/middleware/mod.rs new file mode 100644 index 000000000..1dd633d79 --- /dev/null +++ b/crates/rpc/rpc-server/src/middleware/mod.rs @@ -0,0 +1,6 @@ +pub mod cors; +pub mod logger; +pub mod metrics; + +#[cfg(feature = "cartridge")] +pub mod cartridge; diff --git a/crates/rpc/rpc-server/src/starknet/config.rs b/crates/rpc/rpc-server/src/starknet/config.rs index d832797ae..121f5048a 100644 --- a/crates/rpc/rpc-server/src/starknet/config.rs +++ b/crates/rpc/rpc-server/src/starknet/config.rs @@ -41,21 +41,4 @@ pub struct StarknetApiConfig { /// used for execution (i.e., estimates, simulation, and call) pub versioned_constant_overrides: Option, - #[cfg(feature = "cartridge")] - pub paymaster: Option, -} - -/// Configuration for controller deployment during fee estimation. -/// -/// This is used to deploy Cartridge controller accounts during fee estimation -/// so that the fee estimation can be performed correctly. -#[cfg(feature = "cartridge")] -#[derive(Debug, Clone)] -pub struct CartridgePaymasterConfig { - /// The root URL for the Cartridge API. - pub cartridge_api_url: url::Url, - /// The paymaster account address used for controller deployment. - pub paymaster_address: katana_primitives::ContractAddress, - /// The paymaster account private key. - pub paymaster_private_key: katana_primitives::Felt, } diff --git a/crates/rpc/rpc-server/src/starknet/mod.rs b/crates/rpc/rpc-server/src/starknet/mod.rs index 87accaeaa..036087981 100644 --- a/crates/rpc/rpc-server/src/starknet/mod.rs +++ b/crates/rpc/rpc-server/src/starknet/mod.rs @@ -69,8 +69,6 @@ mod read; mod trace; mod write; -#[cfg(feature = "cartridge")] -pub use config::CartridgePaymasterConfig; pub use config::StarknetApiConfig; pub use pending::PendingBlockProvider; diff --git a/crates/rpc/rpc-server/src/starknet/read.rs b/crates/rpc/rpc-server/src/starknet/read.rs old mode 100644 new mode 100755 index 62b04c44c..becb1be85 --- a/crates/rpc/rpc-server/src/starknet/read.rs +++ b/crates/rpc/rpc-server/src/starknet/read.rs @@ -1,6 +1,3 @@ -#[cfg(feature = "cartridge")] -use std::sync::Arc; - use jsonrpsee::core::{async_trait, RpcResult}; use jsonrpsee::types::ErrorObjectOwned; use katana_pool::TransactionPool; @@ -9,8 +6,6 @@ use katana_primitives::class::ClassHash; use katana_primitives::contract::{Nonce, StorageKey, StorageValue}; use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, TxHash}; use katana_primitives::{ContractAddress, Felt}; -#[cfg(feature = "cartridge")] -use katana_provider::api::state::StateFactoryProvider; use katana_provider::{ProviderFactory, ProviderRO}; use katana_rpc_api::error::starknet::StarknetApiError; use katana_rpc_api::starknet::StarknetApiServer; @@ -31,8 +26,6 @@ use katana_rpc_types::{ }; use super::StarknetApi; -#[cfg(feature = "cartridge")] -use crate::cartridge; use crate::starknet::pending::PendingBlockProvider; #[async_trait] @@ -221,7 +214,7 @@ where }; for tx in &transactions { - let api = ::cartridge::Client::new(paymaster.cartridge_api_url.clone()); + let api = ::cartridge::CartridgeApiClient::new(paymaster.cartridge_api_url.clone()); let deploy_controller_tx = cartridge::get_controller_deploy_tx_if_controller_address( diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index b75b7d3c6..5066bf9c2 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -7,8 +7,8 @@ //! Based on [SNIP-9](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md). //! //! An important note is that the `execute_from_outside_[v2/v3]` functions are not expecting -//! the serialized enum [`OutsideExecution`] but instead the variant already serialized for the -//! matching version. +//! the serialized enum [`OutsideExecution`] but instead the aQ„ERariant already serialized for the +//4 matching version. //! This is why [`OutsideExecution`] is not deriving `CairoSerde` directly. //! @@ -86,6 +86,13 @@ impl OutsideExecution { OutsideExecution::V3(v3) => v3.caller, } } + + pub fn calls(&self) -> &[Call] { + match self { + Self::V2(v) => &v.calls, + Self::V3(v) => &v.calls, + } + } } #[cfg(test)] diff --git a/crates/utils/src/node.rs b/crates/utils/src/node.rs index e2d2f12b6..d864961e7 100644 --- a/crates/utils/src/node.rs +++ b/crates/utils/src/node.rs @@ -48,7 +48,7 @@ pub type ForkTestNode = TestNode; #[derive(Debug)] pub struct TestNode

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { @@ -140,7 +140,7 @@ impl ForkTestNode { impl

TestNode

where - P: ProviderFactory, + P: ProviderFactory + Clone,

::Provider: ProviderRO,

::ProviderMut: ProviderRW, { @@ -194,6 +194,7 @@ where } /// Returns the address of the node's gRPC server (if enabled). + #[cfg(feature = "grpc")] pub fn grpc_addr(&self) -> Option<&SocketAddr> { self.node.grpc().map(|h| h.addr()) } From b4cb1eb9b545380b84dbf1390fc0f8f584934e46 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 19 Feb 2026 17:03:15 -0600 Subject: [PATCH 02/32] wip --- Cargo.lock | 1 + bin/katana/src/cli/rpc/starknet.rs | 5 +- crates/cartridge/src/lib.rs | 12 +- crates/cartridge/src/middlware/layer.rs | 234 ------- crates/cartridge/src/middlware/mod.rs | 634 ------------------ crates/cartridge/src/utils.rs | 15 +- crates/cartridge/src/vrf/client.rs | 26 +- crates/cartridge/src/vrf/mod.rs | 4 +- crates/executor/src/blockifier/call.rs | 12 +- crates/primitives/Cargo.toml | 1 + crates/primitives/src/execution.rs | 11 +- crates/rpc/rpc-server/src/cartridge/mod.rs | 123 ++-- crates/rpc/rpc-server/src/cartridge/vrf.rs | 12 +- .../rpc-server/src/middleware/cartridge.rs | 39 +- crates/rpc/rpc-server/src/starknet/config.rs | 1 - crates/rpc/rpc-types/src/lib.rs | 2 +- crates/rpc/rpc-types/src/outside_execution.rs | 55 +- 17 files changed, 164 insertions(+), 1023 deletions(-) delete mode 100644 crates/cartridge/src/middlware/layer.rs delete mode 100644 crates/cartridge/src/middlware/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 4e1b79b6e..4025f5574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6555,6 +6555,7 @@ dependencies = [ "arbitrary", "assert_matches", "blockifier", + "cainome", "cainome-cairo-serde", "cairo-lang-sierra", "cairo-lang-starknet-classes", diff --git a/bin/katana/src/cli/rpc/starknet.rs b/bin/katana/src/cli/rpc/starknet.rs index f088c76e2..ef5e3086c 100644 --- a/bin/katana/src/cli/rpc/starknet.rs +++ b/bin/katana/src/cli/rpc/starknet.rs @@ -5,7 +5,7 @@ use clap::{Args, Subcommand}; use katana_primitives::block::{BlockHash, BlockIdOrTag, BlockNumber, ConfirmedBlockIdOrTag}; use katana_primitives::class::ClassHash; use katana_primitives::contract::StorageKey; -use katana_primitives::execution::{EntryPointSelector, FunctionCall}; +use katana_primitives::execution::{Call, EntryPointSelector}; use katana_primitives::transaction::TxHash; use katana_primitives::{ContractAddress, Felt}; use katana_rpc_types::event::{EventFilter, EventFilterWithPage, ResultPageRequest}; @@ -398,8 +398,7 @@ impl StarknetCommands { let entry_point_selector = args.selector; let calldata = args.calldata; - let function_call = - FunctionCall { contract_address, entry_point_selector, calldata }; + let function_call = Call { contract_address, entry_point_selector, calldata }; let block_id = args.block.0; let result = client.call(function_call, block_id).await?; diff --git a/crates/cartridge/src/lib.rs b/crates/cartridge/src/lib.rs index 12e0994a9..cad4cbb4f 100644 --- a/crates/cartridge/src/lib.rs +++ b/crates/cartridge/src/lib.rs @@ -1,17 +1,15 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] pub mod api; -pub mod middlware; pub mod utils; pub mod vrf; pub use api::CartridgeApiClient; -pub use vrf::{ - bootstrap_vrf, get_vrf_account, resolve_executable, wait_for_http_ok, InfoResponse, - RequestContext, SignedOutsideExecution, VrfAccountCredentials, VrfBootstrap, - VrfBootstrapConfig, VrfBootstrapResult, VrfClient, VrfClientError, VrfOutsideExecution, - VrfServer, VrfServerConfig, VrfServiceProcess, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, - VRF_HARDCODED_SECRET_KEY, VRF_SERVER_PORT, +pub use vrf::server::{ + bootstrap_vrf, get_vrf_account, resolve_executable, wait_for_http_ok, VrfAccountCredentials, + VrfBootstrap, VrfBootstrapConfig, VrfBootstrapResult, VrfServer, VrfServerConfig, + VrfServiceProcess, VRF_ACCOUNT_SALT, VRF_CONSUMER_SALT, VRF_HARDCODED_SECRET_KEY, + VRF_SERVER_PORT, }; #[rustfmt::skip] diff --git a/crates/cartridge/src/middlware/layer.rs b/crates/cartridge/src/middlware/layer.rs deleted file mode 100644 index 0e943364f..000000000 --- a/crates/cartridge/src/middlware/layer.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::borrow::Cow; -use std::future::Future; - -use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; -use jsonrpsee::core::traits::ToRpcParams; -use jsonrpsee::types::Request; -use jsonrpsee::MethodResponse; -use katana_primitives::block::BlockIdOrTag; -use katana_provider::{ProviderFactory, ProviderRO}; -use katana_rpc_types::broadcasted::BroadcastedTx; -use katana_rpc_types::FeeEstimate; -use serde::Deserialize; -use starknet::core::types::SimulationFlagForEstimateFee; -use starknet::providers::jsonrpc::JsonRpcResponse; -use tracing::{debug, trace}; - -use super::ControllerDeployment; - -#[derive(Deserialize)] -struct EstimateFeeParams { - #[serde(alias = "request")] - txs: Vec, - #[serde(alias = "simulationFlags")] - simulation_flags: Vec, - #[serde(alias = "blockId")] - block_id: BlockIdOrTag, -} - -#[derive(Debug, Clone)] -pub struct PaymasterLayer { - pub(crate) paymaster: ControllerDeployment, -} - -impl tower::Layer for PaymasterLayer { - type Service = PaymasterService; - - fn layer(&self, service: S) -> Self::Service { - PaymasterService { service, paymaster: self.paymaster.clone() } - } -} - -#[derive(Debug)] -pub struct PaymasterService { - service: S, - paymaster: ControllerDeployment, -} - -impl PaymasterService -where - S: RpcServiceT + Send + Sync + Clone + 'static, -{ - /// Extract estimate_fee parameters from the request. - fn parse_estimate_fee_params(request: &Request<'_>) -> Option { - let params = request.params(); - - if params.is_object() { - match params.parse() { - Ok(p) => Some(p), - Err(..) => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } else { - let mut seq = params.sequence(); - - let txs_result: Result, _> = seq.next(); - let simulation_flags_result: Result, _> = seq.next(); - let block_id_result: Result = seq.next(); - - match (txs_result, simulation_flags_result, block_id_result) { - (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { - Some(EstimateFeeParams { txs, simulation_flags, block_id }) - } - _ => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } - } - - /// Build a new estimate fee request with the updated transactions. - fn build_new_estimate_fee_request<'a>( - request: &Request<'a>, - params: &EstimateFeeParams, - updated_txs: &Vec, - ) -> Request<'a> { - let mut new_request = request.clone(); - - let mut new_params = jsonrpsee::core::params::ArrayParams::new(); - new_params.insert(updated_txs).unwrap(); - new_params.insert(params.simulation_flags.clone()).unwrap(); - new_params.insert(params.block_id).unwrap(); - - let new_params = new_params.to_rpc_params().unwrap(); - new_request.params = new_params.map(Cow::Owned); - new_request - } - - // <--- TODO: this function should be removed once estimateFee will return 0 fees - // when --dev.no-fee is used. - fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { - let estimate_fees = vec![ - FeeEstimate { - l1_gas_consumed: 0, - l1_gas_price: 0, - l2_gas_consumed: 0, - l2_gas_price: 0, - l1_data_gas_consumed: 0, - l1_data_gas_price: 0, - overall_fee: 0 - }; - count - ]; - - MethodResponse::response( - request.id().clone(), - jsonrpsee::ResponsePayload::success(estimate_fees), - usize::MAX, - ) - } - // end of the no-fee response - - async fn handle_estimate_fee<'a>( - service: S, - paymaster: ControllerDeployment, - request: Request<'a>, - ) -> S::MethodResponse { - if let Some(params) = Self::parse_estimate_fee_params(&request) { - let updated_txs = paymaster - .handle_estimate_fees(params.block_id, ¶ms.txs) - .await - .unwrap_or_default(); - - if let Some(updated_txs) = updated_txs { - let new_request = - Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); - - let response = service.call(new_request).await; - - // if `handle_estimate_fees` has added some new transactions at the - // beginning of updated_txs, we have to remove - // extras results from estimate_fees to be - // sure to return the same number of result than the number - // of transactions in the request. - let nb_of_txs = params.txs.len(); - let nb_of_extra_txs = updated_txs.len() - nb_of_txs; - - if response.is_success() && nb_of_extra_txs > 0 { - if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = - serde_json::from_str::>>( - response.to_json().get(), - ) - { - if estimate_fees.len() >= nb_of_extra_txs { - estimate_fees.drain(0..nb_of_extra_txs); - } - - trace!( - target: "cartridge", - nb_of_extra_txs = nb_of_extra_txs, - nb_of_estimate_fees = estimate_fees.len(), - "Removing extra transactions from estimate fees response", - ); - - // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); - } - } - - trace!(target: "cartridge", "Estimate fee endpoint original response returned"); - - // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); - } - } - - trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); - service.call(request).await - } -} - -impl RpcServiceT for PaymasterService -where - S: RpcServiceT< - MethodResponse = MethodResponse, - BatchResponse = MethodResponse, - NotificationResponse = MethodResponse, - > + Send - + Sync - + Clone - + 'static, -{ - type MethodResponse = S::MethodResponse; - type BatchResponse = S::BatchResponse; - type NotificationResponse = S::NotificationResponse; - - fn call<'a>( - &self, - request: Request<'a>, - ) -> impl Future + Send + 'a { - let service = self.service.clone(); - let paymaster = self.paymaster.clone(); - - async move { - if request.method_name() == "starknet_estimateFee" { - Self::handle_estimate_fee(service, paymaster, request).await - } else { - service.call(request).await - } - } - } - - fn batch<'a>( - &self, - requests: Batch<'a>, - ) -> impl Future + Send + 'a { - self.service.batch(requests) - } - - fn notification<'a>( - &self, - n: Notification<'a>, - ) -> impl Future + Send + 'a { - self.service.notification(n) - } -} - -impl Clone for PaymasterService { - fn clone(&self) -> Self { - Self { service: self.service.clone(), paymaster: self.paymaster.clone() } - } -} diff --git a/crates/cartridge/src/middlware/mod.rs b/crates/cartridge/src/middlware/mod.rs deleted file mode 100644 index 607638235..000000000 --- a/crates/cartridge/src/middlware/mod.rs +++ /dev/null @@ -1,634 +0,0 @@ -use std::borrow::Cow; -use std::collections::HashSet; -use std::future::Future; -use std::iter::once; - -use cainome_cairo_serde::CairoSerde; -use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; -use jsonrpsee::core::traits::ToRpcParams; -use jsonrpsee::http_client::HttpClient; -use jsonrpsee::types::Request; -use jsonrpsee::MethodResponse; -use katana_genesis::constant::DEFAULT_UDC_ADDRESS; -use katana_paymaster::api::PaymasterApiClient; -use katana_pool::{TransactionPool, TxPool}; -use katana_pool_api::PoolError; -use katana_primitives::block::BlockIdOrTag; -use katana_primitives::chain::ChainId; -use katana_primitives::contract::Nonce; -use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; -use katana_primitives::hash::{Poseidon, StarkHash}; -use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; -use katana_primitives::{ContractAddress, Felt}; -use katana_provider::api::state::StateFactoryProvider; -use katana_provider::{ProviderFactory, ProviderRO}; -use katana_rpc_types::broadcasted::BroadcastedTx; -use katana_rpc_types::outside_execution::{Call as OutsideExecutionCall, OutsideExecution}; -use katana_rpc_types::BroadcastedInvokeTx; -use katana_rpc_types::FeeEstimate; -use layer::PaymasterLayer; -use serde::Deserialize; -use starknet::core::types::SimulationFlagForEstimateFee; -use starknet::macros::selector; -use starknet::providers::jsonrpc::JsonRpcResponse; -use starknet::signers::{LocalWallet, Signer, SigningKey}; -use starknet_types_core::hash::Pedersen; -use tracing::{debug, trace}; -use url::Url; - -use crate::utils::{self, encode_calls}; -use crate::vrf::client::VrfClientError; -use crate::CartridgeApiClient; - -pub mod layer; - -pub type PaymasterResult = Result; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("cartridge client error: {0}")] - Client(#[from] crate::client::Error), - - #[error("provider error: {0}")] - Provider(#[from] katana_provider::api::ProviderError), - - #[error("paymaster not found")] - PaymasterNotFound(ContractAddress), - - #[error("VRF error: {0}")] - Vrf(String), - - #[error("failed to sign with paymaster: {0}")] - SigningError(String), - - #[error("failed to add deploy controller transaction to the pool: {0}")] - FailedToAddTransaction(#[from] PoolError), -} - -impl From for Error { - fn from(e: VrfClientError) -> Self { - Error::Vrf(e.to_string()) - } -} - -#[derive(Debug)] -pub struct ControllerDeployment { - cartridge_api: CartridgeApiClient, - chain_id: ChainId, - paymaster_client: HttpClient, - paymaster_key: SigningKey, - paymaster_address: ContractAddress, - vrf_client: VrfClient, - vrf_account_address: ContractAddress, -} - -impl ControllerDeployment { - #[allow(clippy::too_many_arguments)] - pub fn new( - cartridge_api: CartridgeApiClient, - chain_id: ChainId, - paymaster_address: ContractAddress, - vrf_client: VrfClient, - vrf_account_address: ContractAddress, - ) -> Self { - // Self { - // cartridge_api, - // chain_id, - // paymaster_key, - // paymaster_address, - // vrf_client, - // vrf_account_address, - // } - - todo!() - } - - /// Handle the intercept of the 'starknet_estimateFee' end point. - pub async fn handle_estimate_fees( - &self, - _block_id: katana_rpc_types::BlockIdOrTag, - transactions: &Vec, - ) -> PaymasterResult>> { - let mut deployed_controllers: HashSet = HashSet::new(); - let mut new_transactions = Vec::new(); - let mut updated_transactions = Vec::new(); - let mut has_updated_transactions = false; - - let mut paymaster_nonce = self.get_paymaster_nonce()?; - - // Process the transactions to check if some controller needs to be deployed and - // if some VRF calls have to be inserted between the original calls. - for tx in transactions { - let address = match &tx { - BroadcastedTx::Invoke(invoke_tx) => { - // Try to inject VRF calls into invoke transactions. - let updated_tx = match self.decode_calls(&invoke_tx.calldata) { - Some(calls) => match self.get_vrf_calls(&calls).await? { - Some(vrf_calls) => { - // has_updated_transactions = true; - - // let [submit_call, assert_call] = vrf_calls; - // let calls = once(submit_call) - // .chain(calls.iter().cloned()) - // .chain(once(assert_call)) - // .collect::>(); - - // BroadcastedTx::Invoke(BroadcastedInvokeTx { - // sender_address: invoke_tx.sender_address, - // calldata: self.encode_calls(&calls), - // signature: invoke_tx.signature.clone(), - // nonce: invoke_tx.nonce, - // tip: invoke_tx.tip, - // paymaster_data: invoke_tx.paymaster_data.clone(), - // resource_bounds: invoke_tx.resource_bounds.clone(), - // nonce_data_availability_mode: invoke_tx - // .nonce_data_availability_mode, - // fee_data_availability_mode: invoke_tx - // .fee_data_availability_mode, - // account_deployment_data: invoke_tx - // .account_deployment_data - // .clone(), - // is_query: invoke_tx.is_query, - // }) - - todo!() - } - - None => tx.clone(), - }, - - None => tx.clone(), - }; - - updated_transactions.push(updated_tx); - invoke_tx.sender_address - } - BroadcastedTx::Declare(declare_tx) => { - updated_transactions.push(tx.clone()); - declare_tx.sender_address - } - _ => { - updated_transactions.push(tx.clone()); - continue; - } - }; - - // If the address has already been processed in this txs batch, just skip. - if deployed_controllers.contains(&address) { - continue; - } - - let tx_opt = self.craft_controller_deploy_tx(address, paymaster_nonce).await?; - if let Some(tx) = tx_opt { - deployed_controllers.insert(address); - - let tx_hash = self - .pool - .add_transaction(ExecutableTxWithHash::new(tx.clone())) - .await - .map_err(Error::FailedToAddTransaction)?; - - new_transactions.push(self.executable_tx_to_broadcasted(tx)); - - trace!( - target: "cartridge", - controller = %address, - tx_hash = format!("{tx_hash:#x}"), - "Estimate fee: Controller deploy transaction submitted"); - - paymaster_nonce += Nonce::ONE; - } - } - - if !new_transactions.is_empty() || has_updated_transactions { - new_transactions.extend(updated_transactions.iter().cloned()); - return Ok(Some(new_transactions)); - } - - Ok(None) - } - - /// Returns a [`Layer`](tower::Layer) implementation of [`Paymaster`]. - /// - /// This allows the paymaster to be used as a middleware in Katana RPC stack. - pub fn layer(self) -> PaymasterLayer { - PaymasterLayer { paymaster: self } - } - - /// Crafts a deploy controller transaction for a cartridge controller. - /// - /// Returns None if the provided `controller_address` is not registered in the Cartridge API, - /// or if it has already been deployed. - async fn craft_controller_deploy_tx( - &self, - address: ContractAddress, - paymaster_nonce: Felt, - ) -> PaymasterResult> { - // If the address is not a controller, just ignore the tx. - let controller_calldata = match self.get_controller_ctor_calldata(address).await? { - Some(calldata) => calldata, - None => return Ok(None), - }; - - // Check if the address has already been deployed using the provider directly. - let state = self.provider.provider().latest()?; - if state.class_hash_of_contract(address)?.is_some() { - return Ok(None); - } - - // Create a Controller deploy transaction against the latest state of the network. - debug!(target: "cartridge", controller = %address, "Crafting controller deploy transaction"); - - let call = OutsideExecutionCall { - to: DEFAULT_UDC_ADDRESS, - selector: selector!("deployContract"), - calldata: controller_calldata, - }; - - let mut tx = InvokeTxV3 { - nonce: paymaster_nonce, - chain_id: self.chain_id, - tip: 0_u64, - signature: Vec::new(), - sender_address: self.paymaster_address, - paymaster_data: Vec::new(), - calldata: encode_calls(vec![call]), - account_deployment_data: Vec::new(), - nonce_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, - fee_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, - resource_bounds: ResourceBoundsMapping::All(AllResourceBoundsMapping::default()), - }; - - let tx_hash = InvokeTx::V3(tx.clone()).calculate_hash(false); - - let signer = LocalWallet::from(self.paymaster_key.clone()); - let signature = signer.sign_hash(&tx_hash).await.unwrap(); - tx.signature = vec![signature.r, signature.s]; - - let tx = ExecutableTx::Invoke(InvokeTx::V3(tx)); - - Ok(Some(tx)) - } - - /// Get the constructor calldata for a controller account or None if the address is not a - /// controller. - async fn get_controller_ctor_calldata( - &self, - address: ContractAddress, - ) -> PaymasterResult>> { - let result = self.cartridge_api.get_account_calldata(address).await?; - Ok(result.map(|r| r.constructor_calldata)) - } - - fn decode_calls(&self, calldata: &[Felt]) -> Option> { - Vec::::cairo_deserialize(calldata, 0).ok() - } - - fn encode_calls(&self, calls: &Vec) -> Vec { - Vec::::cairo_serialize(calls) - } - - fn get_calls_from_outside_execution( - &self, - outside_execution: &OutsideExecution, - ) -> Vec { - match outside_execution { - OutsideExecution::V2(v2) => v2.calls.clone(), - OutsideExecution::V3(v3) => v3.calls.clone(), - } - } - - /// Get the VRF calls for a given set of decoded invoke transaction calls. - /// - /// Uses the external VRF server via [`VrfClient::proof`] to generate VRF proofs. - /// - /// Returns None if the calls do not contain any 'request_random' VRF call - /// targeting the VRF account. - async fn get_vrf_calls( - &self, - calls: &[OutsideExecutionCall], - ) -> PaymasterResult> { - if calls.is_empty() { - return Ok(None); - } - - if let Some((req_rand_call, position)) = utils::request_random_call(calls) { - if position + 1 >= calls_len { - return Err(Error::Vrf(format!( - "request_random call must be followed by another call", - ))); - } - - if req_rand_call.to != vrf_service.account_address() { - return Err(Error::Vrf( - format!("request_random call must target the vrf account",), - )); - } - - // Delegate VRF computation to the VRF server - let chain_id = this.backend.chain_spec.id(); - let result = vrf_service - .outside_execution(address, &outside_execution, &signature, chain_id) - .await?; - - user_address = result.address; - execute_from_outside_call = build_execute_from_outside_call_from_vrf_result(&result); - } - - // // If request_random targeting the VRF account is the only call, just ignore it - // // as the generated random value will not be consumed. - // if calls.len() == 1 { - // return Ok(None); - // } - - // let caller = first_call.calldata[0]; - // let salt_or_nonce_selector = first_call.calldata[1]; - // // Salt or nonce being the salt for the `Salt` variant, and the contract address for the - // // `Nonce` variant. - // let salt_or_nonce = first_call.calldata[2]; - - // let source = if salt_or_nonce_selector == Felt::ZERO { - // let contract_address = salt_or_nonce; - // let state = self.provider.provider().latest()?; - - // let key = Pedersen::hash(&selector!("VrfProvider_nonces"), &contract_address); - // state.storage(self.vrf_account_address, key)?.unwrap_or_default() - // } else if salt_or_nonce_selector == Felt::ONE { - // salt_or_nonce - // } else { - // return Err(Error::Vrf(format!( - // "Invalid salt or nonce for VRF request, expecting 0 or 1, got \ - // {salt_or_nonce_selector}" - // ))); - // }; - - // let seed = Poseidon::hash_array(&[source, caller, self.chain_id.id()]); - - // // Use external VRF server to generate the proof. - // let proof = self.vrf_client.proof(vec![seed.to_hex_string()]).await?; - - // let submit_random_call = OutsideExecutionCall { - // to: self.vrf_account_address, - // selector: selector!("submit_random"), - // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, proof.sqrt_ratio], - // }; - - // let assert_consumed_call = OutsideExecutionCall { - // selector: selector!("assert_consumed"), - // to: self.vrf_account_address, - // calldata: vec![seed], - // }; - - // Ok(Some([submit_random_call, assert_consumed_call])) - - todo!() - } - - /// Get the nonce of the paymaster account. - /// - /// Checks the pool nonce first (for pending state), then falls back to the provider. - fn get_paymaster_nonce(&self) -> PaymasterResult { - // Check pool nonce first for the most up-to-date value. - if let Some(nonce) = self.pool.get_nonce(self.paymaster_address) { - return Ok(nonce); - } - - // Fallback to state from provider. - let state = self.provider.provider().latest()?; - match state.nonce(self.paymaster_address)? { - Some(nonce) => Ok(nonce), - None => Err(Error::PaymasterNotFound(self.paymaster_address)), - } - } -} - -impl Clone for ControllerDeployment { - fn clone(&self) -> Self { - Self { - chain_id: self.chain_id, - cartridge_api: self.cartridge_api.clone(), - paymaster_key: self.paymaster_key.clone(), - vrf_client: self.vrf_client.clone(), - vrf_account_address: self.vrf_account_address, - paymaster_address: self.paymaster_address, - paymaster_client: self.paymaster_client.clone(), - } - } -} - -#[derive(Deserialize)] -struct EstimateFeeParams { - #[serde(alias = "request")] - txs: Vec, - #[serde(alias = "simulationFlags")] - simulation_flags: Vec, - #[serde(alias = "blockId")] - block_id: BlockIdOrTag, -} - -#[derive(Debug, Clone)] -pub struct PaymasterLayer { - pub(crate) paymaster: ControllerDeployment, -} - -impl tower::Layer for PaymasterLayer { - type Service = PaymasterService; - - fn layer(&self, service: S) -> Self::Service { - PaymasterService { service, paymaster: self.paymaster.clone() } - } -} - -#[derive(Debug)] -pub struct PaymasterService { - service: S, - paymaster: ControllerDeployment, -} - -impl PaymasterService -where - S: RpcServiceT + Send + Sync + Clone + 'static, -{ - /// Extract estimate_fee parameters from the request. - fn parse_estimate_fee_params(request: &Request<'_>) -> Option { - let params = request.params(); - - if params.is_object() { - match params.parse() { - Ok(p) => Some(p), - Err(..) => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } else { - let mut seq = params.sequence(); - - let txs_result: Result, _> = seq.next(); - let simulation_flags_result: Result, _> = seq.next(); - let block_id_result: Result = seq.next(); - - match (txs_result, simulation_flags_result, block_id_result) { - (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { - Some(EstimateFeeParams { txs, simulation_flags, block_id }) - } - _ => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } - } - - /// Build a new estimate fee request with the updated transactions. - fn build_new_estimate_fee_request<'a>( - request: &Request<'a>, - params: &EstimateFeeParams, - updated_txs: &Vec, - ) -> Request<'a> { - let mut new_request = request.clone(); - - let mut new_params = jsonrpsee::core::params::ArrayParams::new(); - new_params.insert(updated_txs).unwrap(); - new_params.insert(params.simulation_flags.clone()).unwrap(); - new_params.insert(params.block_id).unwrap(); - - let new_params = new_params.to_rpc_params().unwrap(); - new_request.params = new_params.map(Cow::Owned); - new_request - } - - // <--- TODO: this function should be removed once estimateFee will return 0 fees - // when --dev.no-fee is used. - fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { - let estimate_fees = vec![ - FeeEstimate { - l1_gas_consumed: 0, - l1_gas_price: 0, - l2_gas_consumed: 0, - l2_gas_price: 0, - l1_data_gas_consumed: 0, - l1_data_gas_price: 0, - overall_fee: 0 - }; - count - ]; - - MethodResponse::response( - request.id().clone(), - jsonrpsee::ResponsePayload::success(estimate_fees), - usize::MAX, - ) - } - // end of the no-fee response - - async fn handle_estimate_fee<'a>( - service: S, - paymaster: ControllerDeployment, - request: Request<'a>, - ) -> S::MethodResponse { - if let Some(params) = Self::parse_estimate_fee_params(&request) { - let updated_txs = paymaster - .handle_estimate_fees(params.block_id, ¶ms.txs) - .await - .unwrap_or_default(); - - if let Some(updated_txs) = updated_txs { - let new_request = - Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); - - let response = service.call(new_request).await; - - // if `handle_estimate_fees` has added some new transactions at the - // beginning of updated_txs, we have to remove - // extras results from estimate_fees to be - // sure to return the same number of result than the number - // of transactions in the request. - let nb_of_txs = params.txs.len(); - let nb_of_extra_txs = updated_txs.len() - nb_of_txs; - - if response.is_success() && nb_of_extra_txs > 0 { - if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = - serde_json::from_str::>>( - response.to_json().get(), - ) - { - if estimate_fees.len() >= nb_of_extra_txs { - estimate_fees.drain(0..nb_of_extra_txs); - } - - trace!( - target: "cartridge", - nb_of_extra_txs = nb_of_extra_txs, - nb_of_estimate_fees = estimate_fees.len(), - "Removing extra transactions from estimate fees response", - ); - - // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); - } - } - - trace!(target: "cartridge", "Estimate fee endpoint original response returned"); - - // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); - } - } - - trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); - service.call(request).await - } -} - -impl RpcServiceT for PaymasterService -where - S: RpcServiceT< - MethodResponse = MethodResponse, - BatchResponse = MethodResponse, - NotificationResponse = MethodResponse, - > + Send - + Sync - + Clone - + 'static, -{ - type MethodResponse = S::MethodResponse; - type BatchResponse = S::BatchResponse; - type NotificationResponse = S::NotificationResponse; - - fn call<'a>( - &self, - request: Request<'a>, - ) -> impl Future + Send + 'a { - let service = self.service.clone(); - let paymaster = self.paymaster.clone(); - - async move { - if request.method_name() == "starknet_estimateFee" { - Self::handle_estimate_fee(service, paymaster, request).await - } else { - service.call(request).await - } - } - } - - fn batch<'a>( - &self, - requests: Batch<'a>, - ) -> impl Future + Send + 'a { - self.service.batch(requests) - } - - fn notification<'a>( - &self, - n: Notification<'a>, - ) -> impl Future + Send + 'a { - self.service.notification(n) - } -} - -impl Clone for PaymasterService { - fn clone(&self) -> Self { - Self { service: self.service.clone(), paymaster: self.paymaster.clone() } - } -} diff --git a/crates/cartridge/src/utils.rs b/crates/cartridge/src/utils.rs index 2c1f426d6..ff7ed4a58 100644 --- a/crates/cartridge/src/utils.rs +++ b/crates/cartridge/src/utils.rs @@ -1,19 +1,10 @@ -use cainome_cairo_serde::CairoSerde; +use katana_primitives::execution::Call; use katana_primitives::Felt; -use katana_rpc_types::outside_execution::Call; use starknet::macros::selector; -/// Encodes the given calls into a vector of Felt values (New encoding, cairo 1), -/// since controller accounts are Cairo 1 contracts. -pub fn encode_calls(calls: Vec) -> Vec { - Vec::::cairo_serialize(&calls) -} - -pub fn request_random_call( - calls: &[Call], -) -> Option<(katana_rpc_types::outside_execution::Call, usize)> { +pub fn find_request_rand_call(calls: &[Call]) -> Option<(Call, usize)> { calls .iter() - .position(|call| call.selector == selector!("request_random")) + .position(|call| call.entry_point_selector == selector!("request_random")) .map(|position| (calls[position].clone(), position)) } diff --git a/crates/cartridge/src/vrf/client.rs b/crates/cartridge/src/vrf/client.rs index 1d6fe6d21..0ead83bd6 100644 --- a/crates/cartridge/src/vrf/client.rs +++ b/crates/cartridge/src/vrf/client.rs @@ -1,6 +1,9 @@ -use katana_primitives::Felt; +use cainome_cairo_serde::CairoSerde; +use katana_primitives::execution::Call; +use katana_primitives::{ContractAddress, Felt}; use katana_rpc_types::outside_execution::{OutsideExecutionV2, OutsideExecutionV3}; use serde::{Deserialize, Serialize}; +use starknet::macros::selector; use url::Url; #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] @@ -25,11 +28,30 @@ pub enum VrfOutsideExecution { /// A signed outside execution request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SignedOutsideExecution { - pub address: Felt, + pub address: ContractAddress, pub outside_execution: VrfOutsideExecution, pub signature: Vec, } +impl From for Call { + fn from(value: SignedOutsideExecution) -> Self { + let (entry_point_selector, mut calldata) = match &value.outside_execution { + VrfOutsideExecution::V2(v) => { + let calldata = OutsideExecutionV2::cairo_serialize(v); + (selector!("execute_from_outside_v2"), calldata) + } + VrfOutsideExecution::V3(v) => { + let calldata = OutsideExecutionV3::cairo_serialize(v); + (selector!("execute_from_outside_v3"), calldata) + } + }; + + calldata.extend(value.signature); + + Call { contract_address: value.address, entry_point_selector, calldata } + } +} + /// Response from GET /info endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfoResponse { diff --git a/crates/cartridge/src/vrf/mod.rs b/crates/cartridge/src/vrf/mod.rs index a10e0bb24..0c7848cce 100644 --- a/crates/cartridge/src/vrf/mod.rs +++ b/crates/cartridge/src/vrf/mod.rs @@ -1,4 +1,6 @@ //! Cartridge VRF (Verifiable Random Function) service. -pub mod client; +mod client; pub mod server; + +pub use client::*; diff --git a/crates/executor/src/blockifier/call.rs b/crates/executor/src/blockifier/call.rs index a63e93c96..be5ff8b9b 100644 --- a/crates/executor/src/blockifier/call.rs +++ b/crates/executor/src/blockifier/call.rs @@ -17,7 +17,7 @@ use blockifier::state::cached_state::CachedState; use blockifier::state::state_api::StateReader; use blockifier::transaction::objects::{DeprecatedTransactionInfo, TransactionInfo}; use cairo_vm::vm::runners::cairo_runner::RunResources; -use katana_primitives::execution::{FunctionCall, TrackedResource}; +use katana_primitives::execution::{Call, TrackedResource}; use katana_primitives::Felt; use starknet_api::core::EntryPointSelector; use starknet_api::execution_resources::GasAmount; @@ -30,7 +30,7 @@ use crate::error::ExecutionError; /// /// The `max_gas` is the maximum amount of Sierra gas to allocate for the call. pub fn execute_call( - request: FunctionCall, + request: Call, state: &mut CachedState, block_context: Arc, max_gas: u64, @@ -40,7 +40,7 @@ pub fn execute_call( } fn execute_call_inner( - request: FunctionCall, + request: Call, state: &mut CachedState, block_context: Arc, max_sierra_gas: u64, @@ -153,7 +153,7 @@ mod tests { use blockifier::execution::errors::EntryPointExecutionError; use blockifier::state::cached_state::{self}; use katana_primitives::class::ContractClass; - use katana_primitives::execution::FunctionCall; + use katana_primitives::execution::Call; use katana_primitives::{address, felt}; use katana_provider::api::contract::ContractClassWriter; use katana_provider::api::state::{StateFactoryProvider, StateWriter}; @@ -193,7 +193,7 @@ mod tests { let mut state = cached_state::CachedState::new(state); let ctx = Arc::new(BlockContext::create_for_testing()); - let mut req = FunctionCall { + let mut req = Call { calldata: Vec::new(), contract_address: address, entry_point_selector: selector!("bounded_call"), @@ -279,7 +279,7 @@ mod tests { let mut state = cached_state::CachedState::new(state); let ctx = Arc::new(BlockContext::create_for_testing()); - let req = FunctionCall { + let req = Call { calldata: Vec::new(), contract_address: address, entry_point_selector: selector!("call_with_panic"), diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index e28d32eaa..5a9b3113a 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -18,6 +18,7 @@ anyhow.workspace = true arbitrary = { workspace = true, optional = true } blockifier = { workspace = true, features = [ "testing" ] } # some Clone derives are gated behind 'testing' feature cainome-cairo-serde.workspace = true +cainome.workspace = true derive_more.workspace = true lazy_static.workspace = true num-traits.workspace = true diff --git a/crates/primitives/src/execution.rs b/crates/primitives/src/execution.rs index 6776dab3a..ac43c9e63 100644 --- a/crates/primitives/src/execution.rs +++ b/crates/primitives/src/execution.rs @@ -12,6 +12,7 @@ pub use blockifier::fee::resources::{ ComputationResources, StarknetResources, TransactionResources, }; pub use blockifier::transaction::objects::{RevertError, TransactionExecutionInfo}; +use cainome::cairo_serde_derive; pub use cairo_vm::types::builtin_name::BuiltinName; pub use cairo_vm::vm::runners::cairo_runner::ExecutionResources as VmResources; pub use starknet_api::contract_class::EntryPointType; @@ -25,13 +26,15 @@ use crate::{ContractAddress, Felt}; /// The selector of a contract entry point (ie function selector). pub type EntryPointSelector = Felt; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, cairo_serde_derive::CairoSerde)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct FunctionCall { - /// The contract function selector. - pub entry_point_selector: EntryPointSelector, +pub struct Call { /// The address of the contract whose function you're calling. + #[serde(alias = "to")] pub contract_address: ContractAddress, + /// The contract function selector. + #[serde(alias = "selector")] + pub entry_point_selector: EntryPointSelector, /// The input to the function. pub calldata: Vec, } diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 14c87d41f..97d7f646f 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -32,6 +32,7 @@ use std::sync::Arc; use anyhow::anyhow; use cainome::cairo_serde::CairoSerde; +use cartridge::vrf::SignedOutsideExecution; use http::{HeaderMap, HeaderName, HeaderValue}; use jsonrpsee::core::{async_trait, RpcResult}; use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; @@ -41,6 +42,7 @@ use katana_genesis::constant::{DEFAULT_STRK_FEE_TOKEN_ADDRESS, DEFAULT_UDC_ADDRE use katana_pool::{TransactionPool, TxPool}; use katana_primitives::chain::ChainId; use katana_primitives::contract::Nonce; +use katana_primitives::execution::Call; use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; use katana_primitives::{ContractAddress, Felt}; @@ -62,14 +64,12 @@ use paymaster_rpc::{ }; use starknet::macros::selector; use starknet::signers::{LocalWallet, Signer, SigningKey}; -use starknet_paymaster::core::types::Call as PaymasterCall; +use starknet_paymaster::core::types::Call as StarknetRsCall; use tracing::{debug, info}; use url::Url; #[cfg(feature = "vrf")] -use vrf::{outside_execution_calls_len, request_random_call, VrfService}; - -pub use vrf::VrfService; -pub use vrf::VrfServiceConfig; +use vrf::get_request_random_call; +pub use vrf::{VrfService, VrfServiceConfig}; #[derive(Debug, Clone)] pub struct CartridgeConfig { @@ -179,53 +179,60 @@ where pub async fn execute_outside( &self, - address: ContractAddress, + contract_address: ContractAddress, outside_execution: OutsideExecution, signature: Vec, fee_source: Option, ) -> Result { - debug!(%address, ?outside_execution, "Adding execute outside transaction."); + debug!(%contract_address, ?outside_execution, "Adding execute outside transaction."); self.on_cpu_blocking_task(move |this| async move { let pm_address = this.controller_deployer_address; let pm_private_key = this.controller_deployer_private_key; // ====================== CONTROLLER DEPLOYMENT ====================== - let state = this.state().map(Arc::new)?; - let is_controller_deployed = state.class_hash_of_contract(address)?.is_some(); + let state = this.state()?; + let is_controller_deployed = state.class_hash_of_contract(contract_address)?.is_some(); if !is_controller_deployed { - debug!(target: "rpc::cartridge", controller = %address, "Controller not yet deployed"); - if let Some(tx) = craft_deploy_cartridge_controller_tx( + debug!(controller = %contract_address, "Controller not yet deployed"); + + let deploy_tx = craft_deploy_cartridge_controller_tx( &this.api_client, - address, + contract_address, pm_address, pm_private_key, this.backend.chain_spec.id(), this.nonce(pm_address)?.unwrap_or_default(), - ).await? { - debug!(target: "rpc::cartridge", controller = %address, tx = format!("{:#x}", tx.hash), "Inserting Controller deployment transaction"); + ) + .await?; + + if let Some(tx) = deploy_tx { + debug!(controller = %contract_address, tx = format!("{:#x}", tx.hash), "Inserting Controller deployment transaction"); this.pool.add_transaction(tx).await?; this.block_producer.force_mine(); } } // =================================================================== - let mut execute_from_outside_call = - build_execute_from_outside_call(address, &outside_execution, &signature); - let mut user_address: Felt = address.into(); + let entry_point_selector = outside_execution.selector(); + let mut calldata = outside_execution.as_felts(); + calldata.extend(signature); + + let mut call = Call { contract_address, entry_point_selector, calldata }; + let mut user_address: Felt = contract_address.into(); #[cfg(feature = "vrf")] if let Some(vrf_service) = &this.vrf_service { // check first if the outside execution calls include a request_random call if let Some((request_random_call, position)) = - request_random_call(&outside_execution) + get_request_random_call(&outside_execution) { - let calls_len = outside_execution_calls_len(&outside_execution); - if position + 1 >= calls_len { + if position + 1 >= outside_execution.len() { return Err(StarknetApiError::unexpected( "request_random call must be followed by another call", )); } + if request_random_call.to != vrf_service.account_address() { return Err(StarknetApiError::unexpected( "request_random call must target the vrf account", @@ -235,12 +242,16 @@ where // Delegate VRF computation to the VRF server let chain_id = this.backend.chain_spec.id(); let result = vrf_service - .outside_execution(address, &outside_execution, &signature, chain_id) + .outside_execution( + contract_address, + &outside_execution, + &signature, + chain_id, + ) .await?; - user_address = result.address; - execute_from_outside_call = - build_execute_from_outside_call_from_vrf_result(&result); + user_address = result.address.into(); + call = build_execute_from_outside_call_from_vrf_result(&result); } } @@ -249,24 +260,32 @@ where gas_token: DEFAULT_STRK_FEE_TOKEN_ADDRESS.into(), tip: Default::default(), }, - _ => FeeMode::Sponsored { - tip: Default::default(), + _ => FeeMode::Sponsored { tip: Default::default() }, + }; + + + let invoke = RawInvokeParameters { + user_address, + gas_token: None, + max_gas_token_amount: None, + execute_from_outside_call: StarknetRsCall { + calldata: call.calldata, + to: call.contract_address.into(), + selector: call.entry_point_selector, }, }; let request = ExecuteRawRequest { - transaction: ExecuteRawTransactionParameters::RawInvoke { - invoke: RawInvokeParameters { - user_address, - execute_from_outside_call, - gas_token: None, - max_gas_token_amount: None, - }, - }, + transaction: ExecuteRawTransactionParameters::RawInvoke { invoke }, parameters: ExecutionParameters::V1 { fee_mode, time_bounds: None }, }; - let response = this.paymaster_client.execute_raw_transaction(request).await.map_err(StarknetApiError::unexpected)?; + let response = this + .paymaster_client + .execute_raw_transaction(request) + .await + .map_err(StarknetApiError::unexpected)?; + Ok(AddInvokeTransactionResponse { transaction_hash: response.transaction_hash }) }) .await? @@ -278,7 +297,6 @@ where where T: FnOnce(Self) -> F, F: Future + Send + 'static, - F::Output: Send + 'static, { use tokio::runtime::Builder; @@ -447,38 +465,7 @@ pub async fn craft_deploy_cartridge_controller_tx( } } -fn build_execute_from_outside_call_data( - address: ContractAddress, - outside_execution: &OutsideExecution, - signature: &Vec, -) -> katana_rpc_types::outside_execution::Call { - let entrypoint = match outside_execution { - OutsideExecution::V2(_) => selector!("execute_from_outside_v2"), - OutsideExecution::V3(_) => selector!("execute_from_outside_v3"), - }; - - let mut calldata = match outside_execution { - OutsideExecution::V2(v2) => OutsideExecutionV2::cairo_serialize(v2), - OutsideExecution::V3(v3) => OutsideExecutionV3::cairo_serialize(v3), - }; - - calldata.extend(Vec::::cairo_serialize(signature)); - - katana_rpc_types::outside_execution::Call { to: address, selector: entrypoint, calldata } -} - -fn build_execute_from_outside_call( - address: ContractAddress, - outside_execution: &OutsideExecution, - signature: &Vec, -) -> PaymasterCall { - let call = build_execute_from_outside_call_data(address, outside_execution, signature); - PaymasterCall { to: call.to.into(), selector: call.selector, calldata: call.calldata } -} - -fn build_execute_from_outside_call_from_vrf_result( - result: &cartridge::vrf::SignedOutsideExecution, -) -> PaymasterCall { +pub fn build_execute_from_outside_call_from_vrf_result(result: &SignedOutsideExecution) -> Call { let (selector, calldata) = match &result.outside_execution { cartridge::vrf::VrfOutsideExecution::V2(v2) => { let mut calldata = OutsideExecutionV2::cairo_serialize(v2); diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index f126b75ce..6a938d2d5 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -1,5 +1,6 @@ //! VRF (Verifiable Random Function) service for Cartridge. +use cartridge::vrf::client::SignedOutsideExecution; use cartridge::vrf::{RequestContext, SignedOutsideExecution, VrfOutsideExecution}; use cartridge::VrfClient; use katana_primitives::chain::ChainId; @@ -68,7 +69,7 @@ impl VrfService { } } -pub(super) fn request_random_call( +pub(super) fn get_request_random_call( outside_execution: &OutsideExecution, ) -> Option<(katana_rpc_types::outside_execution::Call, usize)> { outside_execution @@ -78,13 +79,6 @@ pub(super) fn request_random_call( .map(|position| (calls[position].clone(), position)) } -pub(super) fn outside_execution_calls_len(outside_execution: &OutsideExecution) -> usize { - match outside_execution { - OutsideExecution::V2(v2) => v2.calls.len(), - OutsideExecution::V3(v3) => v3.calls.len(), - } -} - #[cfg(test)] mod tests { use katana_primitives::{felt, Felt}; @@ -117,7 +111,7 @@ mod tests { }); let (call, position) = - request_random_call(&outside_execution).expect("request_random found"); + get_request_random_call(&outside_execution).expect("request_random found"); assert_eq!(position, 1); assert_eq!(call.selector, vrf_call.selector); assert_eq!(call.calldata, vrf_call.calldata); diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index be7925bff..00f9aa889 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -4,6 +4,8 @@ use std::future::Future; use std::iter::once; use cainome_cairo_serde::CairoSerde; +use cartridge::utils::find_request_rand_call; +use cartridge::CartridgeApiClient; use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; use jsonrpsee::core::traits::ToRpcParams; use jsonrpsee::http_client::HttpClient; @@ -21,28 +23,26 @@ use katana_primitives::hash::{Poseidon, StarkHash}; use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; use katana_primitives::{ContractAddress, Felt}; use katana_provider::api::state::StateFactoryProvider; -use katana_provider::{ProviderFactory, ProviderRO}; -use katana_provider::{ProviderFactory, ProviderRO}; -use katana_rpc_types::broadcasted::BroadcastedTx; +use katana_provider::{ProviderFactory, ProviderRO, ProviderRO}; use katana_rpc_types::broadcasted::BroadcastedTx; use katana_rpc_types::outside_execution::{Call as OutsideExecutionCall, OutsideExecution}; -use katana_rpc_types::BroadcastedInvokeTx; -use katana_rpc_types::FeeEstimate; +use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate}; use layer::PaymasterLayer; use serde::Deserialize; +use serde_json::to_string; use starknet::core::types::SimulationFlagForEstimateFee; use starknet::macros::selector; use starknet::providers::jsonrpc::JsonRpcResponse; use starknet::signers::{LocalWallet, Signer, SigningKey}; use starknet_types_core::hash::Pedersen; -use tracing::{debug, trace}; -use tracing::{debug, trace}; +use tracing::{debug, trace, trace}; use url::Url; use super::ControllerDeployment; -use crate::cartridge::{VrfService, VrfServiceConfig}; +use crate::cartridge::{ + build_execute_from_outside_call_from_vrf_result, VrfService, VrfServiceConfig, +}; use crate::utils::{self, encode_calls}; -use crate::Client; pub type PaymasterResult = Result; @@ -76,10 +76,8 @@ impl From for Error { #[derive(Debug)] pub struct ControllerDeployment { chain_id: ChainId, - cartridge_api: Client, + cartridge_api: CartridgeApiClient, paymaster_client: HttpClient, - // paymaster_key: SigningKey, - // paymaster_address: ContractAddress, vrf_service: Option, } @@ -87,7 +85,7 @@ impl ControllerDeployment { #[allow(clippy::too_many_arguments)] pub fn new( chain_id: ChainId, - cartridge_api: Client, + cartridge_api: CartridgeApiClient, paymaster_client: HttpClient, vrf: Option, ) -> Self { @@ -309,22 +307,23 @@ impl ControllerDeployment { } if let Some(vrf_service) = &self.vrf_service { - if let Some((req_rand_call, position)) = utils::request_random_call(calls) { - if position + 1 >= calls_len { + if let Some((rand_call, pos)) = find_request_rand_call(calls) { + if pos + 1 >= calls_len { return Err(Error::Vrf(format!( "request_random call must be followed by another call", ))); } - if req_rand_call.to != vrf_service.account_address() { + if rand_call.to != vrf_service.account_address() { return Err(Error::Vrf(format!( "request_random call must target the vrf account", ))); } let result = vrf_service - .outside_execution(address, &outside_execution, &signature, self.chain_id.id()) - .await?; + .outside_execution(address, &outside_execution, &signature, self.chain_id) + .await + .map_err(|e| Error::Vrf(e.to_string()))?; user_address = result.address; execute_from_outside_call = @@ -367,8 +366,8 @@ impl ControllerDeployment { // let submit_random_call = OutsideExecutionCall { // to: self.vrf_account_address, // selector: selector!("submit_random"), - // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, proof.sqrt_ratio], - // }; + // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, + // proof.sqrt_ratio], }; // let assert_consumed_call = OutsideExecutionCall { // selector: selector!("assert_consumed"), diff --git a/crates/rpc/rpc-server/src/starknet/config.rs b/crates/rpc/rpc-server/src/starknet/config.rs index 121f5048a..eecceb070 100644 --- a/crates/rpc/rpc-server/src/starknet/config.rs +++ b/crates/rpc/rpc-server/src/starknet/config.rs @@ -40,5 +40,4 @@ pub struct StarknetApiConfig { /// [`VersionedConstants`](katana_executor::implementation::blockifier::blockifier::VersionedConstants) /// used for execution (i.e., estimates, simulation, and call) pub versioned_constant_overrides: Option, - } diff --git a/crates/rpc/rpc-types/src/lib.rs b/crates/rpc/rpc-types/src/lib.rs index d693bac94..1ba43d5f4 100644 --- a/crates/rpc/rpc-types/src/lib.rs +++ b/crates/rpc/rpc-types/src/lib.rs @@ -42,7 +42,7 @@ pub type BlockIdOrTag = katana_primitives::block::BlockIdOrTag; pub type ConfirmedBlockIdOrTag = katana_primitives::block::ConfirmedBlockIdOrTag; /// Request type for `starknet_call` RPC method. -pub type FunctionCall = katana_primitives::execution::FunctionCall; +pub type FunctionCall = katana_primitives::execution::Call; /// Finality status of a block or transaction. pub type FinalityStatus = katana_primitives::block::FinalityStatus; diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 5066bf9c2..53983f4f5 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -8,26 +8,17 @@ //! //! An important note is that the `execute_from_outside_[v2/v3]` functions are not expecting //! the serialized enum [`OutsideExecution`] but instead the aQ„ERariant already serialized for the -//4 matching version. +//! matching version. //! This is why [`OutsideExecution`] is not deriving `CairoSerde` directly. //! use cainome::cairo_serde::{deserialize_from_hex, serialize_as_hex}; use cainome::cairo_serde_derive::CairoSerde; -use katana_primitives::execution::EntryPointSelector; +use cainome_cairo_serde::CairoSerde; +use katana_primitives::execution::Call; use katana_primitives::{ContractAddress, Felt}; use serde::{Deserialize, Serialize}; - -/// A single call to be executed as part of an outside execution. -#[derive(Clone, CairoSerde, Serialize, Deserialize, PartialEq, Debug)] -pub struct Call { - /// Contract address to call. - pub to: ContractAddress, - /// Function selector to invoke. - pub selector: EntryPointSelector, - /// Arguments to pass to the function. - pub calldata: Vec, -} +use starknet::macros::selector; /// Nonce channel #[derive(Clone, CairoSerde, PartialEq, Debug, Serialize, Deserialize)] @@ -93,6 +84,28 @@ impl OutsideExecution { Self::V3(v) => &v.calls, } } + + pub fn as_felts(&self) -> Vec { + match self { + Self::V2(v) => OutsideExecutionV2::cairo_serialize(&v), + Self::V3(v) => OutsideExecutionV3::cairo_serialize(&v), + } + } + + /// Returns the number of calls in the outside execution. + pub fn len(&self) -> usize { + match self { + Self::V2(v) => v.calls.len(), + Self::V3(v) => v.calls.len(), + } + } + + pub fn selector(&self) -> Felt { + match self { + Self::V2(_) => selector!("execute_from_outside_v2"), + Self::V3(_) => selector!("execute_from_outside_v3"), + } + } } #[cfg(test)] @@ -112,10 +125,10 @@ mod tests { execute_before: 3000000000, calls: vec![ Call { - to: address!( + contract_address: address!( "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" ), - selector: selector!("approve"), + entry_point_selector: selector!("approve"), calldata: vec![ felt!("0x50302d9f4df7a96567423f64f1271ef07537469d8e8c4dd2409cf3cc4274de4"), felt!("0x11c37937e08000"), @@ -123,10 +136,10 @@ mod tests { ], }, Call { - to: address!( + contract_address: address!( "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" ), - selector: selector!("transfer"), + entry_point_selector: selector!("transfer"), calldata: vec![ felt!("0x50302d9f4df7a96567423f64f1271ef07537469d8e8c4dd2409cf3cc4274de4"), felt!("0x11c37937e08000"), @@ -177,10 +190,10 @@ mod tests { execute_before: 3000000000, calls: vec![ Call { - to: address!( + contract_address: address!( "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" ), - selector: selector!("approve"), + entry_point_selector: selector!("approve"), calldata: vec![ felt!("0x50302d9f4df7a96567423f64f1271ef07537469d8e8c4dd2409cf3cc4274de4"), felt!("0x11c37937e08000"), @@ -188,10 +201,10 @@ mod tests { ], }, Call { - to: address!( + contract_address: address!( "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7" ), - selector: selector!("transfer"), + entry_point_selector: selector!("transfer"), calldata: vec![ felt!("0x50302d9f4df7a96567423f64f1271ef07537469d8e8c4dd2409cf3cc4274de4"), felt!("0x11c37937e08000"), From 1ef42314de66a7f071b2b733a241513c605befad Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 19 Feb 2026 17:13:16 -0600 Subject: [PATCH 03/32] wip --- crates/rpc/rpc-server/src/middleware/cartridge.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 00f9aa889..b65af07bb 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -98,7 +98,7 @@ impl ControllerDeployment { } /// Handle the intercept of the 'starknet_estimateFee' end point. - pub async fn handle_estimate_fees( + pub async fn handle_estimate_fee_inner( &self, _block_id: katana_rpc_types::BlockIdOrTag, transactions: &Vec, @@ -522,7 +522,7 @@ where ) -> S::MethodResponse { if let Some(params) = Self::parse_estimate_fee_params(&request) { let updated_txs = paymaster - .handle_estimate_fees(params.block_id, ¶ms.txs) + .handle_estimate_fee_inner(params.block_id, ¶ms.txs) .await .unwrap_or_default(); From 5e05c31e0bc2c635389761f6c6ea9cb7cd6cd0de Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 19 Feb 2026 18:03:53 -0600 Subject: [PATCH 04/32] wip --- crates/rpc/rpc-api/src/error/cartridge.rs | 87 ++++++++++++++++++++++ crates/rpc/rpc-api/src/error/mod.rs | 1 + crates/rpc/rpc-server/src/cartridge/mod.rs | 28 ++++--- crates/rpc/rpc-server/src/cartridge/vrf.rs | 11 +-- 4 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 crates/rpc/rpc-api/src/error/cartridge.rs diff --git a/crates/rpc/rpc-api/src/error/cartridge.rs b/crates/rpc/rpc-api/src/error/cartridge.rs new file mode 100644 index 000000000..4641e46ad --- /dev/null +++ b/crates/rpc/rpc-api/src/error/cartridge.rs @@ -0,0 +1,87 @@ +use jsonrpsee::types::ErrorObjectOwned; +use katana_pool_api::PoolError; +use katana_provider_api::ProviderError; + +/// Error codes for Cartridge API (starting at 200 to avoid conflicts). +const CONTROLLER_DEPLOYMENT_FAILED: i32 = 200; +const VRF_MISSING_FOLLOW_UP_CALL: i32 = 201; +const VRF_INVALID_TARGET: i32 = 202; +const VRF_EXECUTION_FAILED: i32 = 203; +const PAYMASTER_EXECUTION_FAILED: i32 = 204; +const POOL_ERROR: i32 = 205; +const PROVIDER_ERROR: i32 = 206; +const INTERNAL_ERROR: i32 = 299; + +#[derive(Debug, thiserror::Error, Clone)] +pub enum CartridgeApiError { + /// Failed to deploy a Cartridge controller account. + #[error("Controller deployment failed: {reason}")] + ControllerDeployment { reason: String }, + + /// The `request_random` call is not followed by another call in the outside execution. + #[error("request_random call must be followed by another call")] + VrfMissingFollowUpCall, + + /// The `request_random` call does not target the expected VRF account. + #[error("request_random call must target the VRF account")] + VrfInvalidTarget, + + /// The VRF outside execution request failed. + /// + /// Error returns by the VRF server. + #[error("VRF execution failed: {reason}")] + VrfExecutionFailed { reason: String }, + + /// The paymaster failed to execute the transaction. + /// + /// Error returns by the Paymaster server. + #[error("Paymaster execution failed: {reason}")] + PaymasterExecutionFailed { reason: String }, + + /// Failed to submit transaction to the pool. + #[error("Transaction pool error: {reason}")] + PoolError { reason: String }, + + /// Storage provider error. + #[error("Provider error: {reason}")] + ProviderError { reason: String }, + + /// Internal error (e.g., task execution failure). + #[error("Internal error: {reason}")] + InternalError { reason: String }, +} + +impl From for ErrorObjectOwned { + fn from(err: CartridgeApiError) -> Self { + let code = match &err { + CartridgeApiError::ControllerDeployment { .. } => CONTROLLER_DEPLOYMENT_FAILED, + CartridgeApiError::VrfMissingFollowUpCall => VRF_MISSING_FOLLOW_UP_CALL, + CartridgeApiError::VrfInvalidTarget => VRF_INVALID_TARGET, + CartridgeApiError::VrfExecutionFailed { .. } => VRF_EXECUTION_FAILED, + CartridgeApiError::PaymasterExecutionFailed { .. } => PAYMASTER_EXECUTION_FAILED, + CartridgeApiError::PoolError { .. } => POOL_ERROR, + CartridgeApiError::ProviderError { .. } => PROVIDER_ERROR, + CartridgeApiError::InternalError { .. } => INTERNAL_ERROR, + }; + + ErrorObjectOwned::owned(code, err.to_string(), None::<()>) + } +} + +impl From for CartridgeApiError { + fn from(value: ProviderError) -> Self { + CartridgeApiError::ProviderError { reason: value.to_string() } + } +} + +impl From for CartridgeApiError { + fn from(value: anyhow::Error) -> Self { + CartridgeApiError::ControllerDeployment { reason: value.to_string() } + } +} + +impl From for CartridgeApiError { + fn from(error: PoolError) -> Self { + CartridgeApiError::PoolError { reason: error.to_string() } + } +} diff --git a/crates/rpc/rpc-api/src/error/mod.rs b/crates/rpc/rpc-api/src/error/mod.rs index 2979fa8e6..0ba5da5c7 100644 --- a/crates/rpc/rpc-api/src/error/mod.rs +++ b/crates/rpc/rpc-api/src/error/mod.rs @@ -1,3 +1,4 @@ +pub mod cartridge; pub mod dev; pub mod katana; pub mod starknet; diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 97d7f646f..00537e186 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -49,7 +49,7 @@ use katana_primitives::{ContractAddress, Felt}; use katana_provider::api::state::{StateFactoryProvider, StateProvider}; use katana_provider::{ProviderFactory, ProviderRO, ProviderRW}; use katana_rpc_api::cartridge::CartridgeApiServer; -use katana_rpc_api::error::starknet::StarknetApiError; +use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_api::paymaster::PaymasterApiClient; use katana_rpc_types::broadcasted::AddInvokeTransactionResponse; use katana_rpc_types::cartridge::FeeSource; @@ -163,14 +163,14 @@ where }) } - fn nonce(&self, address: ContractAddress) -> Result, StarknetApiError> { + fn nonce(&self, address: ContractAddress) -> Result, CartridgeApiError> { match self.pool.get_nonce(address) { pending_nonce @ Some(..) => Ok(pending_nonce), None => Ok(self.state()?.nonce(address)?), } } - fn state(&self) -> Result, StarknetApiError> { + fn state(&self) -> Result, CartridgeApiError> { match &*self.block_producer.producer.read() { BlockProducerMode::Instant(_) => Ok(self.backend.storage.provider().latest()?), BlockProducerMode::Interval(producer) => Ok(producer.executor().read().state()), @@ -183,7 +183,7 @@ where outside_execution: OutsideExecution, signature: Vec, fee_source: Option, - ) -> Result { + ) -> Result { debug!(%contract_address, ?outside_execution, "Adding execute outside transaction."); self.on_cpu_blocking_task(move |this| async move { let pm_address = this.controller_deployer_address; @@ -228,15 +228,11 @@ where get_request_random_call(&outside_execution) { if position + 1 >= outside_execution.len() { - return Err(StarknetApiError::unexpected( - "request_random call must be followed by another call", - )); + return Err(CartridgeApiError::VrfMissingFollowUpCall); } if request_random_call.to != vrf_service.account_address() { - return Err(StarknetApiError::unexpected( - "request_random call must target the vrf account", - )); + return Err(CartridgeApiError::VrfInvalidTarget); } // Delegate VRF computation to the VRF server @@ -284,7 +280,9 @@ where .paymaster_client .execute_raw_transaction(request) .await - .map_err(StarknetApiError::unexpected)?; + .map_err(|e| CartridgeApiError::PaymasterExecutionFailed { + reason: e.to_string(), + })?; Ok(AddInvokeTransactionResponse { transaction_hash: response.transaction_hash }) }) @@ -293,7 +291,7 @@ where /// Spawns an async function that is mostly CPU-bound blocking task onto the manager's blocking /// pool. - async fn on_cpu_blocking_task(&self, func: T) -> Result + async fn on_cpu_blocking_task(&self, func: T) -> Result where T: FnOnce(Self) -> F, F: Future + Send + 'static, @@ -315,9 +313,9 @@ where match self.task_spawner.cpu_bound().spawn(task).await { TaskResult::Ok(result) => Ok(result), - TaskResult::Err(err) => { - Err(StarknetApiError::unexpected(format!("internal task execution failed: {err}"))) - } + TaskResult::Err(err) => Err(CartridgeApiError::InternalError { + reason: format!("task execution failed: {err}"), + }), } } } diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index 6a938d2d5..b8ac87aed 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -5,7 +5,7 @@ use cartridge::vrf::{RequestContext, SignedOutsideExecution, VrfOutsideExecution use cartridge::VrfClient; use katana_primitives::chain::ChainId; use katana_primitives::{ContractAddress, Felt}; -use katana_rpc_api::error::starknet::StarknetApiError; +use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_types::outside_execution::OutsideExecution; use starknet::macros::selector; use url::Url; @@ -46,7 +46,7 @@ impl VrfService { outside_execution: &OutsideExecution, signature: &[Felt], chain_id: ChainId, - ) -> Result { + ) -> Result { let vrf_outside_execution = match outside_execution { OutsideExecution::V2(v2) => VrfOutsideExecution::V2(v2.clone()), OutsideExecution::V3(v3) => VrfOutsideExecution::V3(v3.clone()), @@ -63,9 +63,10 @@ impl VrfService { rpc_url: Some(self.rpc_url.clone()), }; - self.client.outside_execution(request, context).await.map_err(|err| { - StarknetApiError::unexpected(format!("vrf outside_execution failed: {err}")) - }) + self.client + .outside_execution(request, context) + .await + .map_err(|err| CartridgeApiError::VrfExecutionFailed { reason: err.to_string() }) } } From e5f8d735ecafbec58f74596cc05c0c4238525580 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 01:27:21 -0600 Subject: [PATCH 05/32] wip --- Cargo.lock | 1 + crates/core/src/service/mod.rs | 3 +- crates/messaging/src/service.rs | 3 +- crates/pool/pool/src/lib.rs | 5 +- crates/rpc/rpc-server/Cargo.toml | 3 +- crates/rpc/rpc-server/src/cartridge/mod.rs | 38 +- crates/rpc/rpc-server/src/cartridge/vrf.rs | 15 +- crates/rpc/rpc-server/src/lib.rs | 2 +- .../rpc-server/src/middleware/cartridge.rs | 740 +++++++----------- crates/rpc/rpc-server/src/starknet/list.rs | 2 +- crates/rpc/rpc-server/src/starknet/mod.rs | 2 +- crates/rpc/rpc-server/src/starknet/read.rs | 4 +- crates/rpc/rpc-server/src/starknet/trace.rs | 2 +- crates/rpc/rpc-server/src/starknet/write.rs | 2 +- crates/sync/stage/src/sequencing.rs | 3 +- 15 files changed, 297 insertions(+), 528 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4025f5574..7b88944cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6700,6 +6700,7 @@ dependencies = [ "katana-genesis", "katana-messaging", "katana-metrics", + "katana-paymaster", "katana-pool", "katana-primitives", "katana-provider", diff --git a/crates/core/src/service/mod.rs b/crates/core/src/service/mod.rs index 06cd8255d..b741627db 100644 --- a/crates/core/src/service/mod.rs +++ b/crates/core/src/service/mod.rs @@ -4,7 +4,8 @@ use std::task::{Context, Poll}; use block_producer::BlockProductionError; use futures::stream::StreamExt; -use katana_pool::{PendingTransactions, PoolOrd, TransactionPool, TxPool}; +use katana_pool::api::{PendingTransactions, PoolOrd, TransactionPool}; +use katana_pool::TxPool; use katana_primitives::transaction::ExecutableTxWithHash; use katana_provider::{ProviderFactory, ProviderRO, ProviderRW}; use tracing::{error, info}; diff --git a/crates/messaging/src/service.rs b/crates/messaging/src/service.rs index 101992d04..f7e411797 100644 --- a/crates/messaging/src/service.rs +++ b/crates/messaging/src/service.rs @@ -5,7 +5,8 @@ use std::time::Duration; use futures::{Future, FutureExt, Stream}; use katana_chain_spec::ChainSpec; -use katana_pool::{TransactionPool, TxPool}; +use katana_pool::api::TransactionPool; +use katana_pool::TxPool; use katana_primitives::chain::ChainId; use katana_primitives::transaction::{ExecutableTxWithHash, L1HandlerTx, TxHash}; use tokio::time::{interval_at, Instant, Interval}; diff --git a/crates/pool/pool/src/lib.rs b/crates/pool/pool/src/lib.rs index 79537ae5a..5c7cf947d 100644 --- a/crates/pool/pool/src/lib.rs +++ b/crates/pool/pool/src/lib.rs @@ -4,7 +4,6 @@ pub mod ordering; pub mod pool; pub mod validation; -pub use katana_pool_api::{PendingTransactions, PoolOrd, PoolTransaction, TransactionPool}; use katana_primitives::transaction::ExecutableTxWithHash; use ordering::FiFo; use pool::Pool; @@ -13,6 +12,10 @@ use validation::stateful::TxValidator; /// Katana default transacstion pool type. pub type TxPool = Pool>; +pub mod api { + pub use katana_pool_api::*; +} + #[cfg(test)] mod tests { diff --git a/crates/rpc/rpc-server/Cargo.toml b/crates/rpc/rpc-server/Cargo.toml index ee39b16e7..ca287d685 100644 --- a/crates/rpc/rpc-server/Cargo.toml +++ b/crates/rpc/rpc-server/Cargo.toml @@ -14,6 +14,7 @@ katana-explorer = { workspace = true, features = [ "jsonrpsee" ], optional = tru katana-metrics.workspace = true katana-pool.workspace = true katana-primitives.workspace = true +katana-paymaster.workspace = true katana-genesis = { workspace = true, optional = true } katana-provider = { workspace = true, features = [ "test-utils" ] } katana-rpc-api = { workspace = true, features = [ "client" ] } @@ -36,6 +37,7 @@ tokio.workspace = true tower.workspace = true tower-http = { workspace = true, features = [ "cors", "trace" ] } tracing.workspace = true +serde.workspace = true cainome = { workspace = true, optional = true } cartridge = { workspace = true, optional = true } @@ -71,7 +73,6 @@ jsonrpsee = { workspace = true, features = [ "client" ] } num-traits.workspace = true rand.workspace = true rstest.workspace = true -serde.workspace = true serde_json.workspace = true similar-asserts.workspace = true starknet.workspace = true diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 00537e186..94314ba15 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -32,14 +32,14 @@ use std::sync::Arc; use anyhow::anyhow; use cainome::cairo_serde::CairoSerde; -use cartridge::vrf::SignedOutsideExecution; use http::{HeaderMap, HeaderName, HeaderValue}; use jsonrpsee::core::{async_trait, RpcResult}; use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; use katana_core::backend::Backend; use katana_core::service::block_producer::{BlockProducer, BlockProducerMode}; use katana_genesis::constant::{DEFAULT_STRK_FEE_TOKEN_ADDRESS, DEFAULT_UDC_ADDRESS}; -use katana_pool::{TransactionPool, TxPool}; +use katana_pool::api::TransactionPool; +use katana_pool::TxPool; use katana_primitives::chain::ChainId; use katana_primitives::contract::Nonce; use katana_primitives::execution::Call; @@ -53,9 +53,7 @@ use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_api::paymaster::PaymasterApiClient; use katana_rpc_types::broadcasted::AddInvokeTransactionResponse; use katana_rpc_types::cartridge::FeeSource; -use katana_rpc_types::outside_execution::{ - OutsideExecution, OutsideExecutionV2, OutsideExecutionV3, -}; +use katana_rpc_types::outside_execution::OutsideExecution; use katana_rpc_types::FunctionCall; use katana_tasks::{Result as TaskResult, TaskSpawner}; use paymaster_rpc::{ @@ -218,7 +216,7 @@ where let mut calldata = outside_execution.as_felts(); calldata.extend(signature); - let mut call = Call { contract_address, entry_point_selector, calldata }; + let mut call: Call = Call { contract_address, entry_point_selector, calldata }; let mut user_address: Felt = contract_address.into(); #[cfg(feature = "vrf")] @@ -231,7 +229,7 @@ where return Err(CartridgeApiError::VrfMissingFollowUpCall); } - if request_random_call.to != vrf_service.account_address() { + if request_random_call.contract_address != vrf_service.account_address() { return Err(CartridgeApiError::VrfInvalidTarget); } @@ -247,7 +245,7 @@ where .await?; user_address = result.address.into(); - call = build_execute_from_outside_call_from_vrf_result(&result); + call = result.into(); } } @@ -353,13 +351,8 @@ where pub fn encode_calls(calls: Vec) -> Vec { let mut execute_calldata: Vec = vec![calls.len().into()]; for call in calls { - execute_calldata.push(call.contract_address.into()); - execute_calldata.push(call.entry_point_selector); - - execute_calldata.push(call.calldata.len().into()); - execute_calldata.extend_from_slice(&call.calldata); + execute_calldata.extend(Call::cairo_serialize(&call)); } - execute_calldata } @@ -462,20 +455,3 @@ pub async fn craft_deploy_cartridge_controller_tx( Ok(None) } } - -pub fn build_execute_from_outside_call_from_vrf_result(result: &SignedOutsideExecution) -> Call { - let (selector, calldata) = match &result.outside_execution { - cartridge::vrf::VrfOutsideExecution::V2(v2) => { - let mut calldata = OutsideExecutionV2::cairo_serialize(v2); - calldata.extend(Vec::::cairo_serialize(&result.signature)); - (selector!("execute_from_outside_v2"), calldata) - } - cartridge::vrf::VrfOutsideExecution::V3(v3) => { - let mut calldata = OutsideExecutionV3::cairo_serialize(v3); - calldata.extend(Vec::::cairo_serialize(&result.signature)); - (selector!("execute_from_outside_v3"), calldata) - } - }; - - PaymasterCall { to: result.address, selector, calldata } -} diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index b8ac87aed..50448352d 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -1,9 +1,8 @@ //! VRF (Verifiable Random Function) service for Cartridge. -use cartridge::vrf::client::SignedOutsideExecution; -use cartridge::vrf::{RequestContext, SignedOutsideExecution, VrfOutsideExecution}; -use cartridge::VrfClient; +use cartridge::vrf::{RequestContext, SignedOutsideExecution, VrfClient, VrfOutsideExecution}; use katana_primitives::chain::ChainId; +use katana_primitives::execution::Call; use katana_primitives::{ContractAddress, Felt}; use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_types::outside_execution::OutsideExecution; @@ -17,7 +16,7 @@ pub struct VrfServiceConfig { pub vrf_contract: ContractAddress, } -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct VrfService { client: VrfClient, account_address: ContractAddress, @@ -72,11 +71,11 @@ impl VrfService { pub(super) fn get_request_random_call( outside_execution: &OutsideExecution, -) -> Option<(katana_rpc_types::outside_execution::Call, usize)> { - outside_execution - .calls() +) -> Option<(Call, usize)> { + let calls = outside_execution.calls(); + calls .iter() - .position(|call| call.selector == selector!("request_random")) + .position(|call| call.entry_point_selector == selector!("request_random")) .map(|position| (calls[position].clone(), position)) } diff --git a/crates/rpc/rpc-server/src/lib.rs b/crates/rpc/rpc-server/src/lib.rs index 1f700d986..93dea96f3 100644 --- a/crates/rpc/rpc-server/src/lib.rs +++ b/crates/rpc/rpc-server/src/lib.rs @@ -32,11 +32,11 @@ pub mod permit; pub mod starknet; mod utils; -use cors::Cors; use health::HealthCheck; pub use jsonrpsee::core::middleware::RpcServiceBuilder; pub use jsonrpsee::http_client::HttpClient; pub use katana_rpc_api as api; +use middleware::cors::Cors; /// The default maximum number of concurrent RPC connections. pub const DEFAULT_RPC_MAX_CONNECTIONS: u32 = 100; diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index b65af07bb..7f8d72458 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -1,10 +1,8 @@ use std::borrow::Cow; use std::collections::HashSet; use std::future::Future; -use std::iter::once; -use cainome_cairo_serde::CairoSerde; -use cartridge::utils::find_request_rand_call; +use cartridge::vrf::VrfClientError; use cartridge::CartridgeApiClient; use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; use jsonrpsee::core::traits::ToRpcParams; @@ -12,44 +10,57 @@ use jsonrpsee::http_client::HttpClient; use jsonrpsee::types::Request; use jsonrpsee::MethodResponse; use katana_genesis::constant::DEFAULT_UDC_ADDRESS; -use katana_paymaster::api::PaymasterApiClient; -use katana_pool::{TransactionPool, TxPool}; -use katana_pool_api::PoolError; +use katana_pool::api::{PoolError, TransactionPool}; use katana_primitives::block::BlockIdOrTag; -use katana_primitives::chain::ChainId; use katana_primitives::contract::Nonce; +use katana_primitives::da::DataAvailabilityMode; +use katana_primitives::execution::Call; use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; -use katana_primitives::hash::{Poseidon, StarkHash}; -use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; -use katana_primitives::{ContractAddress, Felt}; -use katana_provider::api::state::StateFactoryProvider; -use katana_provider::{ProviderFactory, ProviderRO, ProviderRO}; -use katana_rpc_types::broadcasted::BroadcastedTx; -use katana_rpc_types::outside_execution::{Call as OutsideExecutionCall, OutsideExecution}; +use katana_primitives::ContractAddress; +use katana_provider::{ProviderFactory, ProviderRO}; +use katana_rpc_client::starknet::StarknetApiError; +use katana_rpc_types::broadcasted::{BroadcastedTx, BroadcastedTxWithChainId}; use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate}; -use layer::PaymasterLayer; use serde::Deserialize; -use serde_json::to_string; use starknet::core::types::SimulationFlagForEstimateFee; use starknet::macros::selector; use starknet::providers::jsonrpc::JsonRpcResponse; +use starknet::signers::local_wallet::SignError; use starknet::signers::{LocalWallet, Signer, SigningKey}; -use starknet_types_core::hash::Pedersen; -use tracing::{debug, trace, trace}; -use url::Url; +use tracing::{debug, trace}; -use super::ControllerDeployment; -use crate::cartridge::{ - build_execute_from_outside_call_from_vrf_result, VrfService, VrfServiceConfig, -}; -use crate::utils::{self, encode_calls}; +use crate::cartridge::{encode_calls, VrfService}; +use crate::starknet::{PendingBlockProvider, StarknetApi}; -pub type PaymasterResult = Result; +#[derive(Debug, Clone)] +pub struct ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, +{ + inner: ControllerDeployment, +} + +impl tower::Layer for ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, +{ + type Service = ControllerDeploymentService; + + fn layer(&self, service: S) -> Self::Service { + ControllerDeploymentService { service, inner: self.inner.clone() } + } +} #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("cartridge client error: {0}")] - Client(#[from] crate::client::Error), + #[error("cartridge api error: {0}")] + Client(#[from] cartridge::api::Error), #[error("provider error: {0}")] Provider(#[from] katana_provider::api::ProviderError), @@ -61,7 +72,7 @@ pub enum Error { Vrf(String), #[error("failed to sign with paymaster: {0}")] - SigningError(String), + SigningError(SignError), #[error("failed to add deploy controller transaction to the pool: {0}")] FailedToAddTransaction(#[from] PoolError), @@ -73,517 +84,217 @@ impl From for Error { } } -#[derive(Debug)] -pub struct ControllerDeployment { - chain_id: ChainId, +#[derive(Debug, Clone)] +pub struct ControllerDeployment +where + Pool: TransactionPool, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + starknet: StarknetApi, cartridge_api: CartridgeApiClient, paymaster_client: HttpClient, + deployer_address: ContractAddress, + deployer_private_key: SigningKey, vrf_service: Option, } -impl ControllerDeployment { - #[allow(clippy::too_many_arguments)] +impl ControllerDeployment +where + Pool: TransactionPool, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, +{ pub fn new( - chain_id: ChainId, + starknet: StarknetApi, cartridge_api: CartridgeApiClient, paymaster_client: HttpClient, - vrf: Option, + vrf_service: Option, ) -> Self { - Self { - chain_id, - cartridge_api, - paymaster_client, - vrf_service: config.vrf.map(VrfService::new), - } + Self { starknet, cartridge_api, paymaster_client, vrf_service } } - /// Handle the intercept of the 'starknet_estimateFee' end point. pub async fn handle_estimate_fee_inner( &self, - _block_id: katana_rpc_types::BlockIdOrTag, - transactions: &Vec, - ) -> PaymasterResult>> { + block_id: BlockIdOrTag, + transactions: Vec, + ) -> Result, Error> { + let mut new_transactions: Vec = Vec::new(); + let mut updated_transactions: Vec = Vec::new(); let mut deployed_controllers: HashSet = HashSet::new(); - let mut new_transactions = Vec::new(); - let mut updated_transactions = Vec::new(); - let mut has_updated_transactions = false; - let mut paymaster_nonce = self.get_paymaster_nonce()?; + let mut deployer_nonce = + self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); - // Process the transactions to check if some controller needs to be deployed and - // if some VRF calls have to be inserted between the original calls. + // iterate thru all txs and deploy any undeployed contract (if they are a Controller) for tx in transactions { - let address = match &tx { - BroadcastedTx::Invoke(invoke_tx) => { - // Try to inject VRF calls into invoke transactions. - let updated_tx = match self.decode_calls(&invoke_tx.calldata) { - Some(calls) => match self.get_vrf_calls(&calls).await? { - Some(vrf_calls) => { - // has_updated_transactions = true; - - // let [submit_call, assert_call] = vrf_calls; - // let calls = once(submit_call) - // .chain(calls.iter().cloned()) - // .chain(once(assert_call)) - // .collect::>(); - - // BroadcastedTx::Invoke(BroadcastedInvokeTx { - // sender_address: invoke_tx.sender_address, - // calldata: self.encode_calls(&calls), - // signature: invoke_tx.signature.clone(), - // nonce: invoke_tx.nonce, - // tip: invoke_tx.tip, - // paymaster_data: invoke_tx.paymaster_data.clone(), - // resource_bounds: invoke_tx.resource_bounds.clone(), - // nonce_data_availability_mode: invoke_tx - // .nonce_data_availability_mode, - // fee_data_availability_mode: invoke_tx - // .fee_data_availability_mode, - // account_deployment_data: invoke_tx - // .account_deployment_data - // .clone(), - // is_query: invoke_tx.is_query, - // }) - - todo!() - } - - None => tx.clone(), - }, - - None => tx.clone(), - }; - - updated_transactions.push(updated_tx); - invoke_tx.sender_address - } - BroadcastedTx::Declare(declare_tx) => { - updated_transactions.push(tx.clone()); - declare_tx.sender_address - } - _ => { - updated_transactions.push(tx.clone()); - continue; - } + let contract_address = match &tx { + BroadcastedTx::Invoke(tx) => tx.sender_address, + BroadcastedTx::Declare(tx) => tx.sender_address, + _ => continue, }; // If the address has already been processed in this txs batch, just skip. - if deployed_controllers.contains(&address) { + if deployed_controllers.contains(&contract_address) { continue; } - let tx_opt = self.craft_controller_deploy_tx(address, paymaster_nonce).await?; - if let Some(tx) = tx_opt { - deployed_controllers.insert(address); - - let tx_hash = self - .pool - .add_transaction(ExecutableTxWithHash::new(tx.clone())) - .await - .map_err(Error::FailedToAddTransaction)?; - - new_transactions.push(self.executable_tx_to_broadcasted(tx)); - - trace!( - target: "cartridge", - controller = %address, - tx_hash = format!("{tx_hash:#x}"), - "Estimate fee: Controller deploy transaction submitted"); + // check if the address has already been deployed. + match self.starknet.class_hash_at_address(block_id, contract_address).await { + // attempt to deploy if the address belongs to a Controller account + Err(StarknetApiError::ContractNotFound) => { + let result = self + .get_controller_deployment_tx(contract_address, deployer_nonce) + .await? + .map(BroadcastedTx::Invoke); + + // none means the address is not a Controller + if let Some(tx) = result { + deployed_controllers.insert(contract_address); + new_transactions.push(tx); + deployer_nonce += Nonce::ONE; + } + } - paymaster_nonce += Nonce::ONE; + Err(e) => panic!("{}", e.to_string()), + Ok(..) => continue, } } - if !new_transactions.is_empty() || has_updated_transactions { - new_transactions.extend(updated_transactions.iter().cloned()); - return Ok(Some(new_transactions)); + if new_transactions.is_empty() { + Ok(transactions) + } else { + new_transactions.extend(updated_transactions); + Ok(new_transactions) } - - Ok(None) } - /// Returns a [`Layer`](tower::Layer) implementation of [`Paymaster`]. - /// - /// This allows the paymaster to be used as a middleware in Katana RPC stack. - pub fn layer(self) -> PaymasterLayer { - PaymasterLayer { paymaster: self } - } - - /// Crafts a deploy controller transaction for a cartridge controller. - /// - /// Returns None if the provided `controller_address` is not registered in the Cartridge API, - /// or if it has already been deployed. - async fn craft_controller_deploy_tx( + async fn get_controller_deployment_tx( &self, address: ContractAddress, - paymaster_nonce: Felt, - ) -> PaymasterResult> { - // If the address is not a controller, just ignore the tx. - let controller_calldata = match self.get_controller_ctor_calldata(address).await? { - Some(calldata) => calldata, - None => return Ok(None), - }; - - // Check if the address has already been deployed using the provider directly. - let state = self.provider.provider().latest()?; - if state.class_hash_of_contract(address)?.is_some() { + paymaster_nonce: Nonce, + ) -> Result, Error> { + let Some(ctor_calldata) = self.cartridge_api.get_account_calldata(address).await? else { + // this means no controller with the given address return Ok(None); - } - - // Create a Controller deploy transaction against the latest state of the network. - debug!(target: "cartridge", controller = %address, "Crafting controller deploy transaction"); + }; - let call = OutsideExecutionCall { - to: DEFAULT_UDC_ADDRESS, - selector: selector!("deployContract"), - calldata: controller_calldata, + let call = Call { + contract_address: DEFAULT_UDC_ADDRESS, + calldata: ctor_calldata.constructor_calldata, + entry_point_selector: selector!("deployContract"), }; - let mut tx = InvokeTxV3 { - nonce: paymaster_nonce, - chain_id: self.chain_id, - tip: 0_u64, + let mut tx = BroadcastedInvokeTx { + sender_address: self.deployer_address, + calldata: encode_calls(vec![call]), signature: Vec::new(), - sender_address: self.paymaster_address, + nonce: paymaster_nonce, paymaster_data: Vec::new(), - calldata: encode_calls(vec![call]), + tip: 0u64.into(), account_deployment_data: Vec::new(), - nonce_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, - fee_data_availability_mode: katana_primitives::da::DataAvailabilityMode::L1, resource_bounds: ResourceBoundsMapping::All(AllResourceBoundsMapping::default()), + fee_data_availability_mode: DataAvailabilityMode::L1, + nonce_data_availability_mode: DataAvailabilityMode::L1, + is_query: false, }; - let tx_hash = InvokeTx::V3(tx.clone()).calculate_hash(false); - - let signer = LocalWallet::from(self.paymaster_key.clone()); - let signature = signer.sign_hash(&tx_hash).await.unwrap(); - tx.signature = vec![signature.r, signature.s]; - - let tx = ExecutableTx::Invoke(InvokeTx::V3(tx)); - - Ok(Some(tx)) - } - - /// Get the constructor calldata for a controller account or None if the address is not a - /// controller. - async fn get_controller_ctor_calldata( - &self, - address: ContractAddress, - ) -> PaymasterResult>> { - let result = self.cartridge_api.get_account_calldata(address).await?; - Ok(result.map(|r| r.constructor_calldata)) - } - - fn decode_calls(&self, calldata: &[Felt]) -> Option> { - Vec::::cairo_deserialize(calldata, 0).ok() - } - - fn encode_calls(&self, calls: &Vec) -> Vec { - Vec::::cairo_serialize(calls) - } + let signature = { + let chain = self.starknet.chain_id(); + let tx = BroadcastedTx::Invoke(tx.clone()); + let tx = BroadcastedTxWithChainId { tx, chain: chain.into() }; - fn get_calls_from_outside_execution( - &self, - outside_execution: &OutsideExecution, - ) -> Vec { - match outside_execution { - OutsideExecution::V2(v2) => v2.calls.clone(), - OutsideExecution::V3(v3) => v3.calls.clone(), - } - } - - /// Get the VRF calls for a given set of decoded invoke transaction calls. - /// - /// Uses the external VRF server via [`VrfClient::proof`] to generate VRF proofs. - /// - /// Returns None if the calls do not contain any 'request_random' VRF call - /// targeting the VRF account. - async fn get_vrf_calls( - &self, - calls: &[OutsideExecutionCall], - ) -> PaymasterResult> { - if calls.is_empty() { - return Ok(None); - } - - if let Some(vrf_service) = &self.vrf_service { - if let Some((rand_call, pos)) = find_request_rand_call(calls) { - if pos + 1 >= calls_len { - return Err(Error::Vrf(format!( - "request_random call must be followed by another call", - ))); - } - - if rand_call.to != vrf_service.account_address() { - return Err(Error::Vrf(format!( - "request_random call must target the vrf account", - ))); - } - - let result = vrf_service - .outside_execution(address, &outside_execution, &signature, self.chain_id) - .await - .map_err(|e| Error::Vrf(e.to_string()))?; - - user_address = result.address; - execute_from_outside_call = - build_execute_from_outside_call_from_vrf_result(&result); - } - } - - // // If request_random targeting the VRF account is the only call, just ignore it - // // as the generated random value will not be consumed. - // if calls.len() == 1 { - // return Ok(None); - // } - - // let caller = first_call.calldata[0]; - // let salt_or_nonce_selector = first_call.calldata[1]; - // // Salt or nonce being the salt for the `Salt` variant, and the contract address for the - // // `Nonce` variant. - // let salt_or_nonce = first_call.calldata[2]; - - // let source = if salt_or_nonce_selector == Felt::ZERO { - // let contract_address = salt_or_nonce; - // let state = self.provider.provider().latest()?; - - // let key = Pedersen::hash(&selector!("VrfProvider_nonces"), &contract_address); - // state.storage(self.vrf_account_address, key)?.unwrap_or_default() - // } else if salt_or_nonce_selector == Felt::ONE { - // salt_or_nonce - // } else { - // return Err(Error::Vrf(format!( - // "Invalid salt or nonce for VRF request, expecting 0 or 1, got \ - // {salt_or_nonce_selector}" - // ))); - // }; - - // let seed = Poseidon::hash_array(&[source, caller, self.chain_id.id()]); - - // // Use external VRF server to generate the proof. - // let proof = self.vrf_client.proof(vec![seed.to_hex_string()]).await?; - - // let submit_random_call = OutsideExecutionCall { - // to: self.vrf_account_address, - // selector: selector!("submit_random"), - // calldata: vec![seed, proof.gamma_x, proof.gamma_y, proof.c, proof.s, - // proof.sqrt_ratio], }; - - // let assert_consumed_call = OutsideExecutionCall { - // selector: selector!("assert_consumed"), - // to: self.vrf_account_address, - // calldata: vec![seed], - // }; - - // Ok(Some([submit_random_call, assert_consumed_call])) - - todo!() - } + let signer = LocalWallet::from(self.deployer_private_key.clone()); - /// Get the nonce of the paymaster account. - /// - /// Checks the pool nonce first (for pending state), then falls back to the provider. - fn get_paymaster_nonce(&self) -> PaymasterResult { - // Check pool nonce first for the most up-to-date value. - if let Some(nonce) = self.pool.get_nonce(self.paymaster_address) { - return Ok(nonce); - } + let tx_hash = tx.calculate_hash(); + signer.sign_hash(&tx_hash).await.map_err(Error::SigningError)? + }; - // Fallback to state from provider. - let state = self.provider.provider().latest()?; - match state.nonce(self.paymaster_address)? { - Some(nonce) => Ok(nonce), - None => Err(Error::PaymasterNotFound(self.paymaster_address)), - } - } -} + tx.signature = vec![signature.r, signature.s]; -impl Clone for ControllerDeployment { - fn clone(&self) -> Self { - Self { - chain_id: self.chain_id, - vrf_service: self.vrf_service.clone(), - cartridge_api: self.cartridge_api.clone(), - paymaster_client: self.paymaster_client.clone(), - } + Ok(Some(tx)) } } -#[derive(Deserialize)] -struct EstimateFeeParams { - #[serde(alias = "request")] - txs: Vec, - #[serde(alias = "simulationFlags")] - simulation_flags: Vec, - #[serde(alias = "blockId")] - block_id: BlockIdOrTag, -} - #[derive(Debug, Clone)] -pub struct PaymasterLayer { - pub(crate) paymaster: ControllerDeployment, -} - -impl tower::Layer for PaymasterLayer { - type Service = PaymasterService; - - fn layer(&self, service: S) -> Self::Service { - PaymasterService { service, paymaster: self.paymaster.clone() } - } -} - -#[derive(Debug)] -pub struct PaymasterService { +pub struct ControllerDeploymentService +where + Pool: TransactionPool, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + inner: ControllerDeployment, service: S, - paymaster: ControllerDeployment, } -impl PaymasterService +impl ControllerDeploymentService where - S: RpcServiceT + Send + Sync + Clone + 'static, + S: RpcServiceT, + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, { - /// Extract estimate_fee parameters from the request. - fn parse_estimate_fee_params(request: &Request<'_>) -> Option { - let params = request.params(); - - if params.is_object() { - match params.parse() { - Ok(p) => Some(p), - Err(..) => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } else { - let mut seq = params.sequence(); - - let txs_result: Result, _> = seq.next(); - let simulation_flags_result: Result, _> = seq.next(); - let block_id_result: Result = seq.next(); - - match (txs_result, simulation_flags_result, block_id_result) { - (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { - Some(EstimateFeeParams { txs, simulation_flags, block_id }) - } - _ => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } - } - - /// Build a new estimate fee request with the updated transactions. - fn build_new_estimate_fee_request<'a>( - request: &Request<'a>, - params: &EstimateFeeParams, - updated_txs: &Vec, - ) -> Request<'a> { - let mut new_request = request.clone(); - - let mut new_params = jsonrpsee::core::params::ArrayParams::new(); - new_params.insert(updated_txs).unwrap(); - new_params.insert(params.simulation_flags.clone()).unwrap(); - new_params.insert(params.block_id).unwrap(); - - let new_params = new_params.to_rpc_params().unwrap(); - new_request.params = new_params.map(Cow::Owned); - new_request - } - - // <--- TODO: this function should be removed once estimateFee will return 0 fees - // when --dev.no-fee is used. - fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { - let estimate_fees = vec![ - FeeEstimate { - l1_gas_consumed: 0, - l1_gas_price: 0, - l2_gas_consumed: 0, - l2_gas_price: 0, - l1_data_gas_consumed: 0, - l1_data_gas_price: 0, - overall_fee: 0 - }; - count - ]; - - MethodResponse::response( - request.id().clone(), - jsonrpsee::ResponsePayload::success(estimate_fees), - usize::MAX, - ) - } - // end of the no-fee response + async fn handle_estimate_fee<'a>(&self, request: Request<'a>) -> S::MethodResponse { + let Some(params) = parse_estimate_fee_params(&request) else { + return self.service.call(request).await; + }; - async fn handle_estimate_fee<'a>( - service: S, - paymaster: ControllerDeployment, - request: Request<'a>, - ) -> S::MethodResponse { - if let Some(params) = Self::parse_estimate_fee_params(&request) { - let updated_txs = paymaster - .handle_estimate_fee_inner(params.block_id, ¶ms.txs) - .await - .unwrap_or_default(); - - if let Some(updated_txs) = updated_txs { - let new_request = - Self::build_new_estimate_fee_request(&request, ¶ms, &updated_txs); - - let response = service.call(new_request).await; - - // if `handle_estimate_fees` has added some new transactions at the - // beginning of updated_txs, we have to remove - // extras results from estimate_fees to be - // sure to return the same number of result than the number - // of transactions in the request. - let nb_of_txs = params.txs.len(); - let nb_of_extra_txs = updated_txs.len() - nb_of_txs; - - if response.is_success() && nb_of_extra_txs > 0 { - if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = - serde_json::from_str::>>( - response.to_json().get(), - ) - { - if estimate_fees.len() >= nb_of_extra_txs { - estimate_fees.drain(0..nb_of_extra_txs); - } - - trace!( - target: "cartridge", - nb_of_extra_txs = nb_of_extra_txs, - nb_of_estimate_fees = estimate_fees.len(), - "Removing extra transactions from estimate fees response", - ); - - // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); - } + let updated_txs = self + .inner + .handle_estimate_fee_inner(params.block_id, params.txs) + .await + .unwrap_or_default(); + + // if `handle_estimate_fees` has added some new transactions at the + // beginning of updated_txs, we have to remove + // extras results from estimate_fees to be + // sure to return the same number of result than the number + // of transactions in the request. + let nb_of_txs = params.txs.len(); + let nb_of_extra_txs = updated_txs.len() - nb_of_txs; + + let new_request = build_new_estimate_fee_request(&request, ¶ms, updated_txs); + let response = self.service.call(new_request).await; + + if response.is_success() && nb_of_extra_txs > 0 { + if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = + serde_json::from_str::>>(response.to_json().get()) + { + if estimate_fees.len() >= nb_of_extra_txs { + estimate_fees.drain(0..nb_of_extra_txs); } - trace!(target: "cartridge", "Estimate fee endpoint original response returned"); + trace!( + target: "cartridge", + nb_of_extra_txs = nb_of_extra_txs, + nb_of_estimate_fees = estimate_fees.len(), + "Removing extra transactions from estimate fees response", + ); // TODO: restore the real response return Self::build_no_fee_response(&request, nb_of_txs); } } - trace!(target: "cartridge", "Estimate fee endpoint called with the original transaction"); - service.call(request).await + // TODO: restore the real response + build_no_fee_response(&request, nb_of_txs) } } -impl RpcServiceT for PaymasterService +impl RpcServiceT for ControllerDeploymentService where + S: RpcServiceT + Send + Sync + Clone + 'static, S: RpcServiceT< - MethodResponse = MethodResponse, - BatchResponse = MethodResponse, - NotificationResponse = MethodResponse, - > + Send - + Sync - + Clone - + 'static, + MethodResponse = MethodResponse, + BatchResponse = MethodResponse, + NotificationResponse = MethodResponse, + >, + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, { type MethodResponse = S::MethodResponse; type BatchResponse = S::BatchResponse; @@ -593,14 +304,11 @@ where &self, request: Request<'a>, ) -> impl Future + Send + 'a { - let service = self.service.clone(); - let paymaster = self.paymaster.clone(); - async move { if request.method_name() == "starknet_estimateFee" { - Self::handle_estimate_fee(service, paymaster, request).await + self.handle_estimate_fee(request).await } else { - service.call(request).await + self.service.call(request).await } } } @@ -620,8 +328,84 @@ where } } -impl Clone for PaymasterService { - fn clone(&self) -> Self { - Self { service: self.service.clone(), paymaster: self.paymaster.clone() } +#[derive(Deserialize)] +struct EstimateFeeParams { + #[serde(alias = "request")] + txs: Vec, + #[serde(alias = "simulationFlags")] + simulation_flags: Vec, + #[serde(alias = "blockId")] + block_id: BlockIdOrTag, +} + +/// Extract estimate_fee parameters from the request. +fn parse_estimate_fee_params(request: &Request<'_>) -> Option { + let params = request.params(); + + if params.is_object() { + match params.parse() { + Ok(p) => Some(p), + Err(..) => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } + } else { + let mut seq = params.sequence(); + + let txs_result: Result, _> = seq.next(); + let simulation_flags_result: Result, _> = seq.next(); + let block_id_result: Result = seq.next(); + + match (txs_result, simulation_flags_result, block_id_result) { + (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { + Some(EstimateFeeParams { txs, simulation_flags, block_id }) + } + _ => { + debug!(target: "cartridge", "Failed to parse estimate fee params."); + None + } + } } } + +/// Build a new estimate fee request with the updated transactions. +fn build_new_estimate_fee_request<'a>( + request: &Request<'a>, + params: &EstimateFeeParams, + updated_txs: Vec, +) -> Request<'a> { + let mut new_request = request.clone(); + + let mut new_params = jsonrpsee::core::params::ArrayParams::new(); + new_params.insert(updated_txs).unwrap(); + new_params.insert(params.simulation_flags.clone()).unwrap(); + new_params.insert(params.block_id).unwrap(); + + let new_params = new_params.to_rpc_params().unwrap(); + new_request.params = new_params.map(Cow::Owned); + new_request +} + +// <--- TODO: this function should be removed once estimateFee will return 0 fees +// when --dev.no-fee is used. +fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { + let estimate_fees = vec![ + FeeEstimate { + l1_gas_consumed: 0, + l1_gas_price: 0, + l2_gas_consumed: 0, + l2_gas_price: 0, + l1_data_gas_consumed: 0, + l1_data_gas_price: 0, + overall_fee: 0 + }; + count + ]; + + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(estimate_fees), + usize::MAX, + ) +} diff --git a/crates/rpc/rpc-server/src/starknet/list.rs b/crates/rpc/rpc-server/src/starknet/list.rs index 77b4b7c71..09af290af 100644 --- a/crates/rpc/rpc-server/src/starknet/list.rs +++ b/crates/rpc/rpc-server/src/starknet/list.rs @@ -1,7 +1,7 @@ //! Implementation of list endpoints for the Starknet API. use jsonrpsee::core::{async_trait, RpcResult}; -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::transaction::TxNumber; use katana_provider::{ProviderFactory, ProviderRO}; use katana_rpc_api::starknet_ext::StarknetApiExtServer; diff --git a/crates/rpc/rpc-server/src/starknet/mod.rs b/crates/rpc/rpc-server/src/starknet/mod.rs index 036087981..f7258207d 100644 --- a/crates/rpc/rpc-server/src/starknet/mod.rs +++ b/crates/rpc/rpc-server/src/starknet/mod.rs @@ -8,7 +8,7 @@ use katana_chain_spec::ChainSpec; use katana_core::utils::get_current_timestamp; use katana_executor::{ExecutionResult, ResultAndStates}; use katana_gas_price_oracle::GasPriceOracle; -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::block::{BlockHashOrNumber, BlockIdOrTag, FinalityStatus, GasPrices}; use katana_primitives::class::{ClassHash, CompiledClass}; use katana_primitives::contract::{ContractAddress, Nonce, StorageKey, StorageValue}; diff --git a/crates/rpc/rpc-server/src/starknet/read.rs b/crates/rpc/rpc-server/src/starknet/read.rs index becb1be85..33575f18b 100755 --- a/crates/rpc/rpc-server/src/starknet/read.rs +++ b/crates/rpc/rpc-server/src/starknet/read.rs @@ -1,6 +1,6 @@ use jsonrpsee::core::{async_trait, RpcResult}; use jsonrpsee::types::ErrorObjectOwned; -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::block::BlockIdOrTag; use katana_primitives::class::ClassHash; use katana_primitives::contract::{Nonce, StorageKey, StorageValue}; @@ -186,6 +186,8 @@ where // for more details. #[cfg(feature = "cartridge")] let transactions = if let Some(paymaster) = &self.inner.config.paymaster { + use std::sync::Arc; + let paymaster_address = paymaster.paymaster_address; let paymaster_private_key = paymaster.paymaster_private_key; diff --git a/crates/rpc/rpc-server/src/starknet/trace.rs b/crates/rpc/rpc-server/src/starknet/trace.rs index 9658492c7..ca75c5d18 100644 --- a/crates/rpc/rpc-server/src/starknet/trace.rs +++ b/crates/rpc/rpc-server/src/starknet/trace.rs @@ -1,5 +1,5 @@ use jsonrpsee::core::{async_trait, RpcResult}; -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::block::{BlockIdOrTag, ConfirmedBlockIdOrTag}; use katana_primitives::transaction::TxHash; use katana_provider::{ProviderFactory, ProviderRO}; diff --git a/crates/rpc/rpc-server/src/starknet/write.rs b/crates/rpc/rpc-server/src/starknet/write.rs index 89f88ec1f..ad4cda2a1 100644 --- a/crates/rpc/rpc-server/src/starknet/write.rs +++ b/crates/rpc/rpc-server/src/starknet/write.rs @@ -1,7 +1,7 @@ use std::time::Duration; use jsonrpsee::core::{async_trait, RpcResult}; -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::transaction::TxHash; use katana_provider::{ProviderFactory, ProviderRO}; use katana_rpc_api::error::starknet::StarknetApiError; diff --git a/crates/sync/stage/src/sequencing.rs b/crates/sync/stage/src/sequencing.rs index d3a8b8b6d..73f5b2f2a 100644 --- a/crates/sync/stage/src/sequencing.rs +++ b/crates/sync/stage/src/sequencing.rs @@ -8,7 +8,8 @@ use katana_core::backend::Backend; use katana_core::service::block_producer::{BlockProducer, BlockProductionError}; use katana_core::service::{BlockProductionTask, TransactionMiner}; use katana_messaging::{MessagingConfig, MessagingService, MessagingTask}; -use katana_pool::{TransactionPool, TxPool}; +use katana_pool::api::TransactionPool; +use katana_pool::TxPool; use katana_provider::{ProviderFactory, ProviderRO, ProviderRW}; use katana_tasks::{JoinHandle, TaskSpawner}; use tracing::error; From f7391a0ef1f0ab30f4f4c5170cea762309e9489e Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 01:45:22 -0600 Subject: [PATCH 06/32] wip --- crates/rpc/rpc-server/src/cartridge/mod.rs | 61 ++++++++--------- .../rpc-server/src/middleware/cartridge.rs | 66 ++++++++++++++----- 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 94314ba15..8eed5e307 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -187,34 +187,35 @@ where let pm_address = this.controller_deployer_address; let pm_private_key = this.controller_deployer_private_key; - // ====================== CONTROLLER DEPLOYMENT ====================== - let state = this.state()?; - let is_controller_deployed = state.class_hash_of_contract(contract_address)?.is_some(); - - if !is_controller_deployed { - debug!(controller = %contract_address, "Controller not yet deployed"); - - let deploy_tx = craft_deploy_cartridge_controller_tx( - &this.api_client, - contract_address, - pm_address, - pm_private_key, - this.backend.chain_spec.id(), - this.nonce(pm_address)?.unwrap_or_default(), - ) - .await?; - - if let Some(tx) = deploy_tx { - debug!(controller = %contract_address, tx = format!("{:#x}", tx.hash), "Inserting Controller deployment transaction"); - this.pool.add_transaction(tx).await?; - this.block_producer.force_mine(); - } - } - // =================================================================== + // // ====================== CONTROLLER DEPLOYMENT ====================== + // let state = this.state()?; + // let is_controller_deployed = + // state.class_hash_of_contract(contract_address)?.is_some(); + + // if !is_controller_deployed { + // debug!(controller = %contract_address, "Controller not yet deployed"); + + // let deploy_tx = craft_deploy_cartridge_controller_tx( + // &this.api_client, + // contract_address, + // pm_address, + // pm_private_key, + // this.backend.chain_spec.id(), + // this.nonce(pm_address)?.unwrap_or_default(), + // ) + // .await?; + + // if let Some(tx) = deploy_tx { + // debug!(controller = %contract_address, tx = format!("{:#x}", tx.hash), + // "Inserting Controller deployment transaction"); this.pool. + // add_transaction(tx).await?; this.block_producer.force_mine(); + // } + // } + // // =================================================================== let entry_point_selector = outside_execution.selector(); let mut calldata = outside_execution.as_felts(); - calldata.extend(signature); + calldata.extend(signature.clone()); let mut call: Call = Call { contract_address, entry_point_selector, calldata }; let mut user_address: Felt = contract_address.into(); @@ -257,7 +258,6 @@ where _ => FeeMode::Sponsored { tip: Default::default() }, }; - let invoke = RawInvokeParameters { user_address, gas_token: None, @@ -274,12 +274,9 @@ where parameters: ExecutionParameters::V1 { fee_mode, time_bounds: None }, }; - let response = this - .paymaster_client - .execute_raw_transaction(request) - .await - .map_err(|e| CartridgeApiError::PaymasterExecutionFailed { - reason: e.to_string(), + let response = + this.paymaster_client.execute_raw_transaction(request).await.map_err(|e| { + CartridgeApiError::PaymasterExecutionFailed { reason: e.to_string() } })?; Ok(AddInvokeTransactionResponse { transaction_hash: response.transaction_hash }) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 7f8d72458..507bddc03 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -18,7 +18,7 @@ use katana_primitives::execution::Call; use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; use katana_primitives::ContractAddress; use katana_provider::{ProviderFactory, ProviderRO}; -use katana_rpc_client::starknet::StarknetApiError; +use katana_rpc_api::error::starknet::StarknetApiError; use katana_rpc_types::broadcasted::{BroadcastedTx, BroadcastedTxWithChainId}; use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate}; use serde::Deserialize; @@ -99,9 +99,10 @@ where vrf_service: Option, } -impl ControllerDeployment +impl ControllerDeployment where - Pool: TransactionPool, + Pool: TransactionPool + Send + Sync + 'static, + PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, @@ -110,9 +111,18 @@ where starknet: StarknetApi, cartridge_api: CartridgeApiClient, paymaster_client: HttpClient, + deployer_address: ContractAddress, + deployer_private_key: SigningKey, vrf_service: Option, ) -> Self { - Self { starknet, cartridge_api, paymaster_client, vrf_service } + Self { + starknet, + cartridge_api, + paymaster_client, + deployer_address, + deployer_private_key, + vrf_service, + } } pub async fn handle_estimate_fee_inner( @@ -170,6 +180,31 @@ where } } + pub async fn handle_execute_outside_inner( + &self, + address: ContractAddress, + ) -> Result<(), Error> { + // check if the address has already been deployed. + match self.starknet.class_hash_at_address(block_id, address).await { + // attempt to deploy if the address belongs to a Controller account + Err(StarknetApiError::ContractNotFound) => { + let mut deployer_nonce = + self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); + + let result = self.get_controller_deployment_tx(address, deployer_nonce).await?; + + // none means the address is not a Controller + if let Some(tx) = result { + let _ = self.starknet.add_invoke_tx(tx).await.unwrap(); + } + } + Err(e) => panic!("{}", e.to_string()), + Ok(..) => {} + } + + Ok(()) + } + async fn get_controller_deployment_tx( &self, address: ContractAddress, @@ -241,18 +276,19 @@ where return self.service.call(request).await; }; - let updated_txs = self - .inner - .handle_estimate_fee_inner(params.block_id, params.txs) - .await - .unwrap_or_default(); - // if `handle_estimate_fees` has added some new transactions at the // beginning of updated_txs, we have to remove // extras results from estimate_fees to be // sure to return the same number of result than the number // of transactions in the request. let nb_of_txs = params.txs.len(); + + let updated_txs = self + .inner + .handle_estimate_fee_inner(params.block_id, params.txs) + .await + .unwrap_or_default(); + let nb_of_extra_txs = updated_txs.len() - nb_of_txs; let new_request = build_new_estimate_fee_request(&request, ¶ms, updated_txs); @@ -274,7 +310,7 @@ where ); // TODO: restore the real response - return Self::build_no_fee_response(&request, nb_of_txs); + return build_no_fee_response(&request, nb_of_txs); } } @@ -305,10 +341,10 @@ where request: Request<'a>, ) -> impl Future + Send + 'a { async move { - if request.method_name() == "starknet_estimateFee" { - self.handle_estimate_fee(request).await - } else { - self.service.call(request).await + match request.method_name() { + "starknet_estimateFee" => self.handle_estimate_fee(request).await, + "addExecuteOutsideTransaction" | "addExecuteFromOutside" => todo!(), + _ => self.service.call(request).await, } } } From 9118f6ebd7f3f46ac69fcdc7eeed8c1991d94dbb Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 22:26:22 -0600 Subject: [PATCH 07/32] wip --- .../rpc-server/src/middleware/cartridge.rs | 421 ++++++++++-------- 1 file changed, 231 insertions(+), 190 deletions(-) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 507bddc03..1b9a58500 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -7,8 +7,8 @@ use cartridge::CartridgeApiClient; use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; use jsonrpsee::core::traits::ToRpcParams; use jsonrpsee::http_client::HttpClient; -use jsonrpsee::types::Request; -use jsonrpsee::MethodResponse; +use jsonrpsee::types::{ErrorObjectOwned, Request, Response, ResponsePayload}; +use jsonrpsee::{rpc_params, MethodResponse}; use katana_genesis::constant::DEFAULT_UDC_ADDRESS; use katana_pool::api::{PoolError, TransactionPool}; use katana_primitives::block::BlockIdOrTag; @@ -16,15 +16,15 @@ use katana_primitives::contract::Nonce; use katana_primitives::da::DataAvailabilityMode; use katana_primitives::execution::Call; use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; -use katana_primitives::ContractAddress; +use katana_primitives::{ContractAddress, Felt}; use katana_provider::{ProviderFactory, ProviderRO}; +use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_api::error::starknet::StarknetApiError; use katana_rpc_types::broadcasted::{BroadcastedTx, BroadcastedTxWithChainId}; -use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate}; +use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate, FeeSource, OutsideExecution}; use serde::Deserialize; use starknet::core::types::SimulationFlagForEstimateFee; use starknet::macros::selector; -use starknet::providers::jsonrpc::JsonRpcResponse; use starknet::signers::local_wallet::SignError; use starknet::signers::{LocalWallet, Signer, SigningKey}; use tracing::{debug, trace}; @@ -40,52 +40,26 @@ where PF: ProviderFactory, ::Provider: ProviderRO, { - inner: ControllerDeployment, + starknet: StarknetApi, + cartridge_api: CartridgeApiClient, + paymaster_client: HttpClient, + deployer_address: ContractAddress, + deployer_private_key: SigningKey, + vrf_service: Option, } -impl tower::Layer for ControllerDeploymentLayer +impl ControllerDeployment where - Pool: TransactionPool + 'static, + Pool: TransactionPool + Send + Sync + 'static, + PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, { - type Service = ControllerDeploymentService; - - fn layer(&self, service: S) -> Self::Service { - ControllerDeploymentService { service, inner: self.inner.clone() } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("cartridge api error: {0}")] - Client(#[from] cartridge::api::Error), - - #[error("provider error: {0}")] - Provider(#[from] katana_provider::api::ProviderError), - - #[error("paymaster not found")] - PaymasterNotFound(ContractAddress), - - #[error("VRF error: {0}")] - Vrf(String), - - #[error("failed to sign with paymaster: {0}")] - SigningError(SignError), - - #[error("failed to add deploy controller transaction to the pool: {0}")] - FailedToAddTransaction(#[from] PoolError), -} - -impl From for Error { - fn from(e: VrfClientError) -> Self { - Error::Vrf(e.to_string()) - } } #[derive(Debug, Clone)] -pub struct ControllerDeployment +pub struct ControllerDeploymentService where Pool: TransactionPool, PP: PendingBlockProvider, @@ -97,41 +71,141 @@ where deployer_address: ContractAddress, deployer_private_key: SigningKey, vrf_service: Option, + service: S, } -impl ControllerDeployment +impl ControllerDeploymentService where + S: RpcServiceT, Pool: TransactionPool + Send + Sync + 'static, PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, { - pub fn new( - starknet: StarknetApi, - cartridge_api: CartridgeApiClient, - paymaster_client: HttpClient, - deployer_address: ContractAddress, - deployer_private_key: SigningKey, - vrf_service: Option, - ) -> Self { - Self { - starknet, - cartridge_api, - paymaster_client, - deployer_address, - deployer_private_key, - vrf_service, + // if `handle_estimate_fees` has added some new transactions at the + // beginning of updated_txs, we have to remove + // extras results from estimate_fees to be + // sure to return the same number of result than the number + // of transactions in the request. + async fn handle_estimate_fee<'a>( + &self, + params: EstimateFeeParams, + request: Request<'a>, + ) -> S::MethodResponse { + let EstimateFeeParams { block_id, simulation_flags, txs } = params; + + let deploy_controller_txs = + self.get_deploy_controller_txs(block_id, &txs).await.unwrap_or_default(); + + // no Controller to deploy, simply forward the request + if deploy_controller_txs.is_empty() { + return self.service.call(request).await; + } + + let original_txs_count = txs.len(); + let deploy_controller_txs_count = deploy_controller_txs.len(); + + let new_txs = [deploy_controller_txs, txs].concat(); + let new_txs_count = new_txs.len(); + + // craft a new estimate fee request with the deploy Controller txs included + let new_request = { + let params = rpc_params!(new_txs, simulation_flags, block_id); + let params = params.to_rpc_params().unwrap(); + + let mut new_request = request.clone(); + new_request.params = params.map(Cow::Owned); + + new_request + }; + + let response = self.service.call(new_request).await; + + let res = response.as_json().get(); + let mut res = serde_json::from_str::>>(res).unwrap(); + + match res.payload { + ResponsePayload::Success(estimates) => { + assert_eq!(estimates.len(), new_txs_count); + estimates.to_mut().drain(0..deploy_controller_txs_count); + build_no_fee_response(&request, original_txs_count) + } + + ResponsePayload::Error(..) => response, } } - pub async fn handle_estimate_fee_inner( + async fn handle_execute_outside<'a>( + &self, + params: AddExecuteOutsideParams, + request: Request<'a>, + ) -> S::MethodResponse { + if let Err(err) = self.handle_execute_outside_inner(params).await { + MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)) + } else { + self.service.call(request).await + } + } + + async fn handle_execute_outside_inner( + &self, + params: AddExecuteOutsideParams, + ) -> Result<(), CartridgeApiError> { + let address = params.address; + let block_id = BlockIdOrTag::PreConfirmed; + + // check if the address has already been deployed. + let is_deployed = match self.starknet.class_hash_at_address(block_id, address).await { + Ok(..) => true, + Err(StarknetApiError::ContractNotFound) => false, + Err(e) => { + return Err(CartridgeApiError::ControllerDeployment { + reason: format!("failed to check Controller deployment status: {e}"), + }); + } + }; + + if is_deployed { + return Ok(()); + } + + let result = self.starknet.nonce_at(block_id, self.deployer_address).await; + let nonce = match result { + Ok(nonce) => nonce, + Err(e) => { + return Err(CartridgeApiError::ControllerDeployment { + reason: format!("failed to get deployer nonce: {e}"), + }); + } + }; + + let result = self.get_controller_deployment_tx(address, nonce).await; + let deploy_tx = match result { + Ok(tx) => tx, + Err(e) => { + return Err(CartridgeApiError::ControllerDeployment { reason: e.to_string() }); + } + }; + + // None means the address is not of a Controller + if let Some(tx) = deploy_tx { + if let Err(e) = self.inner.starknet.add_invoke_tx(tx).await { + return Err(CartridgeApiError::ControllerDeployment { + reason: format!("failed to submit deployment tx: {e}"), + }); + } + } + + Ok(()) + } + + async fn get_deploy_controller_txs( &self, block_id: BlockIdOrTag, - transactions: Vec, + transactions: &[BroadcastedTx], ) -> Result, Error> { - let mut new_transactions: Vec = Vec::new(); - let mut updated_transactions: Vec = Vec::new(); + let mut deploy_transactions: Vec = Vec::new(); let mut deployed_controllers: HashSet = HashSet::new(); let mut deployer_nonce = @@ -139,7 +213,7 @@ where // iterate thru all txs and deploy any undeployed contract (if they are a Controller) for tx in transactions { - let contract_address = match &tx { + let contract_address = match tx { BroadcastedTx::Invoke(tx) => tx.sender_address, BroadcastedTx::Declare(tx) => tx.sender_address, _ => continue, @@ -162,7 +236,7 @@ where // none means the address is not a Controller if let Some(tx) = result { deployed_controllers.insert(contract_address); - new_transactions.push(tx); + deploy_transactions.push(tx); deployer_nonce += Nonce::ONE; } } @@ -172,37 +246,7 @@ where } } - if new_transactions.is_empty() { - Ok(transactions) - } else { - new_transactions.extend(updated_transactions); - Ok(new_transactions) - } - } - - pub async fn handle_execute_outside_inner( - &self, - address: ContractAddress, - ) -> Result<(), Error> { - // check if the address has already been deployed. - match self.starknet.class_hash_at_address(block_id, address).await { - // attempt to deploy if the address belongs to a Controller account - Err(StarknetApiError::ContractNotFound) => { - let mut deployer_nonce = - self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); - - let result = self.get_controller_deployment_tx(address, deployer_nonce).await?; - - // none means the address is not a Controller - if let Some(tx) = result { - let _ = self.starknet.add_invoke_tx(tx).await.unwrap(); - } - } - Err(e) => panic!("{}", e.to_string()), - Ok(..) => {} - } - - Ok(()) + Ok(deploy_transactions) } async fn get_controller_deployment_tx( @@ -252,82 +296,12 @@ where } } -#[derive(Debug, Clone)] -pub struct ControllerDeploymentService -where - Pool: TransactionPool, - PP: PendingBlockProvider, - PF: ProviderFactory, -{ - inner: ControllerDeployment, - service: S, -} - -impl ControllerDeploymentService -where - S: RpcServiceT, - Pool: TransactionPool + 'static, - PP: PendingBlockProvider, - PF: ProviderFactory, - ::Provider: ProviderRO, -{ - async fn handle_estimate_fee<'a>(&self, request: Request<'a>) -> S::MethodResponse { - let Some(params) = parse_estimate_fee_params(&request) else { - return self.service.call(request).await; - }; - - // if `handle_estimate_fees` has added some new transactions at the - // beginning of updated_txs, we have to remove - // extras results from estimate_fees to be - // sure to return the same number of result than the number - // of transactions in the request. - let nb_of_txs = params.txs.len(); - - let updated_txs = self - .inner - .handle_estimate_fee_inner(params.block_id, params.txs) - .await - .unwrap_or_default(); - - let nb_of_extra_txs = updated_txs.len() - nb_of_txs; - - let new_request = build_new_estimate_fee_request(&request, ¶ms, updated_txs); - let response = self.service.call(new_request).await; - - if response.is_success() && nb_of_extra_txs > 0 { - if let Ok(JsonRpcResponse::Success { result: mut estimate_fees, .. }) = - serde_json::from_str::>>(response.to_json().get()) - { - if estimate_fees.len() >= nb_of_extra_txs { - estimate_fees.drain(0..nb_of_extra_txs); - } - - trace!( - target: "cartridge", - nb_of_extra_txs = nb_of_extra_txs, - nb_of_estimate_fees = estimate_fees.len(), - "Removing extra transactions from estimate fees response", - ); - - // TODO: restore the real response - return build_no_fee_response(&request, nb_of_txs); - } - } - - // TODO: restore the real response - build_no_fee_response(&request, nb_of_txs) - } -} - -impl RpcServiceT for ControllerDeploymentService +impl RpcServiceT for ControllerDeploymentService where S: RpcServiceT + Send + Sync + Clone + 'static, - S: RpcServiceT< - MethodResponse = MethodResponse, - BatchResponse = MethodResponse, - NotificationResponse = MethodResponse, - >, - Pool: TransactionPool + 'static, + S: RpcServiceT, + Pool: TransactionPool + Send + Sync + 'static, + PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, @@ -341,11 +315,27 @@ where request: Request<'a>, ) -> impl Future + Send + 'a { async move { - match request.method_name() { - "starknet_estimateFee" => self.handle_estimate_fee(request).await, - "addExecuteOutsideTransaction" | "addExecuteFromOutside" => todo!(), - _ => self.service.call(request).await, + let method = request.method_name(); + + match method { + "starknet_estimateFee" => { + trace!(%method, "Intercepting JSON-RPC method."); + if let Some(params) = parse_estimate_fee_params(&request) { + return self.handle_estimate_fee(params, request).await; + } + } + + "addExecuteOutsideTransaction" | "addExecuteFromOutside" => { + trace!(%method, "Intercepting JSON-RPC method."); + if let Some(params) = parse_execute_outside_params(&request) { + return self.handle_execute_outside(params, request).await; + } + } + + _ => {} } + + self.service.call(request).await } } @@ -364,6 +354,41 @@ where } } +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("cartridge api error: {0}")] + Client(#[from] cartridge::api::Error), + + #[error("provider error: {0}")] + Provider(#[from] katana_provider::api::ProviderError), + + #[error("paymaster not found")] + PaymasterNotFound(ContractAddress), + + #[error("VRF error: {0}")] + Vrf(String), + + #[error("failed to sign with paymaster: {0}")] + SigningError(SignError), + + #[error("failed to add deploy controller transaction to the pool: {0}")] + FailedToAddTransaction(#[from] PoolError), +} + +impl From for Error { + fn from(e: VrfClientError) -> Self { + Error::Vrf(e.to_string()) + } +} + +#[derive(Deserialize)] +struct AddExecuteOutsideParams { + address: ContractAddress, + outside_execution: OutsideExecution, + signature: Vec, + fee_source: Option, +} + #[derive(Deserialize)] struct EstimateFeeParams { #[serde(alias = "request")] @@ -374,6 +399,40 @@ struct EstimateFeeParams { block_id: BlockIdOrTag, } +fn parse_execute_outside_params(request: &Request<'_>) -> Option { + let params = request.params(); + + if params.is_object() { + match params.parse() { + Ok(p) => Some(p), + Err(..) => { + debug!(target: "cartridge", "Failed to parse execute outside params."); + None + } + } + } else { + let mut seq = params.sequence(); + + let address: Result = seq.next(); + let outside_execution: Result = seq.next(); + let signature: Result, _> = seq.next(); + let fee_source: Result, _> = seq.next(); + + match (address, outside_execution, signature) { + (Ok(address), Ok(outside_execution), Ok(signature)) => Some(AddExecuteOutsideParams { + address, + outside_execution, + signature, + fee_source: fee_source.ok().flatten(), + }), + _ => { + debug!(target: "cartridge", "Failed to parse execute outside params."); + None + } + } + } +} + /// Extract estimate_fee parameters from the request. fn parse_estimate_fee_params(request: &Request<'_>) -> Option { let params = request.params(); @@ -405,24 +464,6 @@ fn parse_estimate_fee_params(request: &Request<'_>) -> Option } } -/// Build a new estimate fee request with the updated transactions. -fn build_new_estimate_fee_request<'a>( - request: &Request<'a>, - params: &EstimateFeeParams, - updated_txs: Vec, -) -> Request<'a> { - let mut new_request = request.clone(); - - let mut new_params = jsonrpsee::core::params::ArrayParams::new(); - new_params.insert(updated_txs).unwrap(); - new_params.insert(params.simulation_flags.clone()).unwrap(); - new_params.insert(params.block_id).unwrap(); - - let new_params = new_params.to_rpc_params().unwrap(); - new_request.params = new_params.map(Cow::Owned); - new_request -} - // <--- TODO: this function should be removed once estimateFee will return 0 fees // when --dev.no-fee is used. fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { From 79356673a1e87868ef2ba534c5d891cee81cd1b4 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 22:28:00 -0600 Subject: [PATCH 08/32] wip --- crates/rpc/rpc-server/src/cartridge/mod.rs | 29 -------- .../rpc-server/src/middleware/cartridge.rs | 12 +--- crates/rpc/rpc-server/src/starknet/read.rs | 66 ------------------- 3 files changed, 1 insertion(+), 106 deletions(-) diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 8eed5e307..e88ce06ea 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -184,35 +184,6 @@ where ) -> Result { debug!(%contract_address, ?outside_execution, "Adding execute outside transaction."); self.on_cpu_blocking_task(move |this| async move { - let pm_address = this.controller_deployer_address; - let pm_private_key = this.controller_deployer_private_key; - - // // ====================== CONTROLLER DEPLOYMENT ====================== - // let state = this.state()?; - // let is_controller_deployed = - // state.class_hash_of_contract(contract_address)?.is_some(); - - // if !is_controller_deployed { - // debug!(controller = %contract_address, "Controller not yet deployed"); - - // let deploy_tx = craft_deploy_cartridge_controller_tx( - // &this.api_client, - // contract_address, - // pm_address, - // pm_private_key, - // this.backend.chain_spec.id(), - // this.nonce(pm_address)?.unwrap_or_default(), - // ) - // .await?; - - // if let Some(tx) = deploy_tx { - // debug!(controller = %contract_address, tx = format!("{:#x}", tx.hash), - // "Inserting Controller deployment transaction"); this.pool. - // add_transaction(tx).await?; this.block_producer.force_mine(); - // } - // } - // // =================================================================== - let entry_point_selector = outside_execution.selector(); let mut calldata = outside_execution.as_felts(); calldata.extend(signature.clone()); diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 1b9a58500..04eddf338 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -48,16 +48,6 @@ where vrf_service: Option, } -impl ControllerDeployment -where - Pool: TransactionPool + Send + Sync + 'static, - PoolTx: From, - PP: PendingBlockProvider, - PF: ProviderFactory, - ::Provider: ProviderRO, -{ -} - #[derive(Debug, Clone)] pub struct ControllerDeploymentService where @@ -190,7 +180,7 @@ where // None means the address is not of a Controller if let Some(tx) = deploy_tx { - if let Err(e) = self.inner.starknet.add_invoke_tx(tx).await { + if let Err(e) = self.starknet.add_invoke_tx(tx).await { return Err(CartridgeApiError::ControllerDeployment { reason: format!("failed to submit deployment tx: {e}"), }); diff --git a/crates/rpc/rpc-server/src/starknet/read.rs b/crates/rpc/rpc-server/src/starknet/read.rs index 33575f18b..ceab979d6 100755 --- a/crates/rpc/rpc-server/src/starknet/read.rs +++ b/crates/rpc/rpc-server/src/starknet/read.rs @@ -180,72 +180,6 @@ where .with_account_validation(should_validate) .with_nonce_check(false); - // Hook the estimate fee to pre-deploy the controller contract - // and enhance UX on the client side. - // Refer to the `handle_cartridge_controller_deploy` function in `cartridge.rs` - // for more details. - #[cfg(feature = "cartridge")] - let transactions = if let Some(paymaster) = &self.inner.config.paymaster { - use std::sync::Arc; - - let paymaster_address = paymaster.paymaster_address; - let paymaster_private_key = paymaster.paymaster_private_key; - - let state = - self.storage().provider().latest().map(Arc::new).map_err(StarknetApiError::from)?; - - let mut ctrl_deploy_txs = Vec::new(); - - // Check if any of the transactions are sent from an address associated with a Cartridge - // Controller account. If yes, we craft a Controller deployment transaction - // for each of the unique sender and push it at the beginning of the - // transaction list so that all the requested transactions are executed against a state - // with the Controller accounts deployed. - - let paymaster_nonce = match self.nonce_at(block_id, paymaster_address).await { - Ok(nonce) => nonce, - Err(err) => match err { - StarknetApiError::ContractNotFound => { - return Err(StarknetApiError::unexpected( - "Cartridge paymaster account doesn't exist", - ) - .into()); - } - _ => return Err(ErrorObjectOwned::from(err)), - }, - }; - - for tx in &transactions { - let api = ::cartridge::CartridgeApiClient::new(paymaster.cartridge_api_url.clone()); - - let deploy_controller_tx = - cartridge::get_controller_deploy_tx_if_controller_address( - paymaster_address, - paymaster_private_key, - paymaster_nonce, - tx, - self.inner.chain_spec.id(), - state.clone(), - &api, - ) - .await - .map_err(StarknetApiError::from)?; - - if let Some(tx) = deploy_controller_tx { - ctrl_deploy_txs.push(tx); - } - } - - if !ctrl_deploy_txs.is_empty() { - ctrl_deploy_txs.extend(transactions); - ctrl_deploy_txs - } else { - transactions - } - } else { - transactions - }; - let permit = self.inner.estimate_fee_permit.acquire().await.map_err(|e| { StarknetApiError::unexpected(format!("Failed to acquire permit: {e}")) From 1c7b1a303cd287fb9b07a0480844b0c81164c68f Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 23:11:59 -0600 Subject: [PATCH 09/32] wip --- Cargo.lock | 7 - crates/cartridge/Cargo.toml | 7 - crates/cartridge/src/lib.rs | 1 - crates/cartridge/src/utils.rs | 10 -- .../rpc-server/src/middleware/cartridge.rs | 139 +++++++++++------- 5 files changed, 83 insertions(+), 81 deletions(-) delete mode 100644 crates/cartridge/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 7b88944cf..c836d8cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2880,14 +2880,9 @@ dependencies = [ "anyhow", "ark-ff 0.4.2", "cainome-cairo-serde", - "jsonrpsee 0.26.0", "katana-contracts", "katana-genesis", - "katana-paymaster", - "katana-pool", - "katana-pool-api", "katana-primitives", - "katana-provider", "katana-rpc-types", "lazy_static", "reqwest", @@ -2895,10 +2890,8 @@ dependencies = [ "serde_json", "stark-vrf", "starknet 0.17.0 (registry+https://github.com/rust-lang/crates.io-index)", - "starknet-types-core 0.2.3", "thiserror 1.0.69", "tokio", - "tower 0.5.2", "tracing", "url", ] diff --git a/crates/cartridge/Cargo.toml b/crates/cartridge/Cargo.toml index 2c27a272b..b84daf4c2 100644 --- a/crates/cartridge/Cargo.toml +++ b/crates/cartridge/Cargo.toml @@ -9,26 +9,19 @@ build = "build.rs" [dependencies] katana-contracts.workspace = true katana-genesis.workspace = true -katana-pool = { workspace = true } -katana-pool-api = { workspace = true } katana-primitives.workspace = true -katana-provider = { workspace = true } katana-rpc-types = { workspace = true } -katana-paymaster = { workspace = true } anyhow.workspace = true ark-ff = "0.4.2" cainome-cairo-serde.workspace = true -jsonrpsee = { workspace = true, features = ["server"] } lazy_static.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true stark-vrf.workspace = true starknet.workspace = true -starknet-types-core.workspace = true thiserror.workspace = true tokio.workspace = true -tower.workspace = true tracing.workspace = true url.workspace = true diff --git a/crates/cartridge/src/lib.rs b/crates/cartridge/src/lib.rs index cad4cbb4f..082479c41 100644 --- a/crates/cartridge/src/lib.rs +++ b/crates/cartridge/src/lib.rs @@ -1,7 +1,6 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] pub mod api; -pub mod utils; pub mod vrf; pub use api::CartridgeApiClient; diff --git a/crates/cartridge/src/utils.rs b/crates/cartridge/src/utils.rs deleted file mode 100644 index ff7ed4a58..000000000 --- a/crates/cartridge/src/utils.rs +++ /dev/null @@ -1,10 +0,0 @@ -use katana_primitives::execution::Call; -use katana_primitives::Felt; -use starknet::macros::selector; - -pub fn find_request_rand_call(calls: &[Call]) -> Option<(Call, usize)> { - calls - .iter() - .position(|call| call.entry_point_selector == selector!("request_random")) - .map(|position| (calls[position].clone(), position)) -} diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 04eddf338..840a87cdc 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -32,7 +32,7 @@ use tracing::{debug, trace}; use crate::cartridge::{encode_calls, VrfService}; use crate::starknet::{PendingBlockProvider, StarknetApi}; -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ControllerDeploymentLayer where Pool: TransactionPool + 'static, @@ -48,7 +48,7 @@ where vrf_service: Option, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ControllerDeploymentService where Pool: TransactionPool, @@ -83,14 +83,51 @@ where params: EstimateFeeParams, request: Request<'a>, ) -> S::MethodResponse { - let EstimateFeeParams { block_id, simulation_flags, txs } = params; + match self.handle_estimate_fee_inner(params, request).await { + Ok(response) => response, + Err(err) => MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)), + } + } + + async fn handle_execute_outside<'a>( + &self, + params: AddExecuteOutsideParams, + request: Request<'a>, + ) -> S::MethodResponse { + if let Err(err) = self.handle_execute_outside_inner(params).await { + MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)) + } else { + self.service.call(request).await + } + } + + async fn handle_estimate_fee_inner<'a>( + &self, + params: EstimateFeeParams, + request: Request<'a>, + ) -> Result { + let EstimateFeeParams { block_id, simulation_flags, transactions } = params; - let deploy_controller_txs = - self.get_deploy_controller_txs(block_id, &txs).await.unwrap_or_default(); + let mut undeployed_addresses: Vec = Vec::new(); + + // iterate thru all txs and deploy any undeployed contract (if they are a Controller) + for tx in transactions { + let address = match tx { + BroadcastedTx::Invoke(tx) => tx.sender_address, + BroadcastedTx::Declare(tx) => tx.sender_address, + _ => continue, + }; + + undeployed_addresses.push(address); + } + + let deployer_nonce = self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); + let deploy_txs = + self.get_controller_deployment_txs(undeployed_addresses, deployer_nonce).await.unwrap(); // no Controller to deploy, simply forward the request - if deploy_controller_txs.is_empty() { - return self.service.call(request).await; + if deploy_txs.is_empty() { + return Ok(self.service.call(request).await); } let original_txs_count = txs.len(); @@ -119,22 +156,10 @@ where ResponsePayload::Success(estimates) => { assert_eq!(estimates.len(), new_txs_count); estimates.to_mut().drain(0..deploy_controller_txs_count); - build_no_fee_response(&request, original_txs_count) + Ok(build_no_fee_response(&request, original_txs_count)) } - ResponsePayload::Error(..) => response, - } - } - - async fn handle_execute_outside<'a>( - &self, - params: AddExecuteOutsideParams, - request: Request<'a>, - ) -> S::MethodResponse { - if let Err(err) = self.handle_execute_outside_inner(params).await { - MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)) - } else { - self.service.call(request).await + ResponsePayload::Error(..) => Ok(response), } } @@ -190,49 +215,29 @@ where Ok(()) } - async fn get_deploy_controller_txs( + async fn get_controller_deployment_txs( &self, - block_id: BlockIdOrTag, - transactions: &[BroadcastedTx], + controller_addreses: Vec, + initial_nonce: Nonce, ) -> Result, Error> { let mut deploy_transactions: Vec = Vec::new(); - let mut deployed_controllers: HashSet = HashSet::new(); + let mut processed_addresses: Vec = Vec::new(); - let mut deployer_nonce = - self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); - - // iterate thru all txs and deploy any undeployed contract (if they are a Controller) - for tx in transactions { - let contract_address = match tx { - BroadcastedTx::Invoke(tx) => tx.sender_address, - BroadcastedTx::Declare(tx) => tx.sender_address, - _ => continue, - }; + let mut deployer_nonce = initial_nonce; + for address in controller_addreses { // If the address has already been processed in this txs batch, just skip. - if deployed_controllers.contains(&contract_address) { + if processed_addresses.contains(&address) { continue; } - // check if the address has already been deployed. - match self.starknet.class_hash_at_address(block_id, contract_address).await { - // attempt to deploy if the address belongs to a Controller account - Err(StarknetApiError::ContractNotFound) => { - let result = self - .get_controller_deployment_tx(contract_address, deployer_nonce) - .await? - .map(BroadcastedTx::Invoke); - - // none means the address is not a Controller - if let Some(tx) = result { - deployed_controllers.insert(contract_address); - deploy_transactions.push(tx); - deployer_nonce += Nonce::ONE; - } - } + let deploy_tx = self.get_controller_deployment_tx(address, deployer_nonce).await?; - Err(e) => panic!("{}", e.to_string()), - Ok(..) => continue, + // None means the address is not a Controller + if let Some(tx) = deploy_tx { + deployer_nonce += Nonce::ONE; + processed_addresses.push(address); + deploy_transactions.push(BroadcastedTx::Invoke(tx)); } } @@ -304,6 +309,8 @@ where &self, request: Request<'a>, ) -> impl Future + Send + 'a { + let this = self.clone(); + async move { let method = request.method_name(); @@ -344,6 +351,26 @@ where } } +impl Clone for ControllerDeploymentService +where + S: Clone, + Pool: TransactionPool, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + starknet: self.starknet.clone(), + vrf_service: self.vrf_service.clone(), + cartridge_api: self.cartridge_api.clone(), + paymaster_client: self.paymaster_client.clone(), + deployer_address: self.deployer_address.clone(), + deployer_private_key: self.deployer_private_key.clone(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("cartridge api error: {0}")] @@ -382,7 +409,7 @@ struct AddExecuteOutsideParams { #[derive(Deserialize)] struct EstimateFeeParams { #[serde(alias = "request")] - txs: Vec, + transactions: Vec, #[serde(alias = "simulationFlags")] simulation_flags: Vec, #[serde(alias = "blockId")] @@ -444,7 +471,7 @@ fn parse_estimate_fee_params(request: &Request<'_>) -> Option match (txs_result, simulation_flags_result, block_id_result) { (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { - Some(EstimateFeeParams { txs, simulation_flags, block_id }) + Some(EstimateFeeParams { transactions: txs, simulation_flags, block_id }) } _ => { debug!(target: "cartridge", "Failed to parse estimate fee params."); From 5217e3db414cdcdb68db49e2beec2e616f42589b Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Fri, 20 Feb 2026 23:23:59 -0600 Subject: [PATCH 10/32] wip --- Cargo.lock | 1 - crates/rpc/rpc-server/Cargo.toml | 1 - .../rpc/rpc-server/src/middleware/cartridge.rs | 18 +++++++++--------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c836d8cc7..0559b91dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6693,7 +6693,6 @@ dependencies = [ "katana-genesis", "katana-messaging", "katana-metrics", - "katana-paymaster", "katana-pool", "katana-primitives", "katana-provider", diff --git a/crates/rpc/rpc-server/Cargo.toml b/crates/rpc/rpc-server/Cargo.toml index ca287d685..24ff6d13d 100644 --- a/crates/rpc/rpc-server/Cargo.toml +++ b/crates/rpc/rpc-server/Cargo.toml @@ -14,7 +14,6 @@ katana-explorer = { workspace = true, features = [ "jsonrpsee" ], optional = tru katana-metrics.workspace = true katana-pool.workspace = true katana-primitives.workspace = true -katana-paymaster.workspace = true katana-genesis = { workspace = true, optional = true } katana-provider = { workspace = true, features = [ "test-utils" ] } katana-rpc-api = { workspace = true, features = [ "client" ] } diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 840a87cdc..44cf8e5fd 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -111,7 +111,7 @@ where let mut undeployed_addresses: Vec = Vec::new(); // iterate thru all txs and deploy any undeployed contract (if they are a Controller) - for tx in transactions { + for tx in &transactions { let address = match tx { BroadcastedTx::Invoke(tx) => tx.sender_address, BroadcastedTx::Declare(tx) => tx.sender_address, @@ -122,18 +122,18 @@ where } let deployer_nonce = self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); - let deploy_txs = + let deploy_controller_txs = self.get_controller_deployment_txs(undeployed_addresses, deployer_nonce).await.unwrap(); // no Controller to deploy, simply forward the request - if deploy_txs.is_empty() { + if deploy_controller_txs.is_empty() { return Ok(self.service.call(request).await); } - let original_txs_count = txs.len(); + let original_txs_count = transactions.len(); let deploy_controller_txs_count = deploy_controller_txs.len(); - let new_txs = [deploy_controller_txs, txs].concat(); + let new_txs = [deploy_controller_txs, transactions].concat(); let new_txs_count = new_txs.len(); // craft a new estimate fee request with the deploy Controller txs included @@ -153,7 +153,7 @@ where let mut res = serde_json::from_str::>>(res).unwrap(); match res.payload { - ResponsePayload::Success(estimates) => { + ResponsePayload::Success(mut estimates) => { assert_eq!(estimates.len(), new_txs_count); estimates.to_mut().drain(0..deploy_controller_txs_count); Ok(build_no_fee_response(&request, original_txs_count)) @@ -318,21 +318,21 @@ where "starknet_estimateFee" => { trace!(%method, "Intercepting JSON-RPC method."); if let Some(params) = parse_estimate_fee_params(&request) { - return self.handle_estimate_fee(params, request).await; + return this.handle_estimate_fee(params, request).await; } } "addExecuteOutsideTransaction" | "addExecuteFromOutside" => { trace!(%method, "Intercepting JSON-RPC method."); if let Some(params) = parse_execute_outside_params(&request) { - return self.handle_execute_outside(params, request).await; + return this.handle_execute_outside(params, request).await; } } _ => {} } - self.service.call(request).await + this.service.call(request).await } } From fc439e730b22cd244c8b90f12cacb8ce00eaa611 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 09:42:07 -0600 Subject: [PATCH 11/32] wip --- crates/pool/pool-api/src/lib.rs | 2 +- crates/pool/pool-api/src/validation.rs | 4 +-- crates/pool/pool/src/validation/stateful.rs | 10 +++---- .../rpc-server/src/middleware/cartridge.rs | 26 ++++++++++++------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/crates/pool/pool-api/src/lib.rs b/crates/pool/pool-api/src/lib.rs index 6cbe4a26c..cd089eeca 100644 --- a/crates/pool/pool-api/src/lib.rs +++ b/crates/pool/pool-api/src/lib.rs @@ -24,7 +24,7 @@ pub enum PoolError { #[error("Invalid transaction: {0}")] InvalidTransaction(Box), #[error("Internal error: {0}")] - Internal(Box), + Internal(Box), } pub type PoolResult = Result; diff --git a/crates/pool/pool-api/src/validation.rs b/crates/pool/pool-api/src/validation.rs index b9a0ccbef..cbb616e2e 100644 --- a/crates/pool/pool-api/src/validation.rs +++ b/crates/pool/pool-api/src/validation.rs @@ -153,11 +153,11 @@ pub struct Error { /// The hash of the transaction that failed validation. pub hash: TxHash, /// The actual error object. - pub error: Box, + pub error: Box, } impl Error { - pub fn new(hash: TxHash, error: Box) -> Self { + pub fn new(hash: TxHash, error: Box) -> Self { Self { hash, error } } } diff --git a/crates/pool/pool/src/validation/stateful.rs b/crates/pool/pool/src/validation/stateful.rs index 958a0f11a..cf6a3ba6f 100644 --- a/crates/pool/pool/src/validation/stateful.rs +++ b/crates/pool/pool/src/validation/stateful.rs @@ -213,7 +213,7 @@ fn validate( fn map_invalid_tx_err( err: StatefulValidatorError, -) -> Result> { +) -> Result> { match err { StatefulValidatorError::StateError(err) => Err(Box::new(err)), StatefulValidatorError::TransactionExecutorError(err) => map_executor_err(err), @@ -224,7 +224,7 @@ fn map_invalid_tx_err( fn map_fee_err( err: TransactionFeeError, -) -> Result> { +) -> Result> { match err { TransactionFeeError::GasBoundsExceedBalance { resource, @@ -281,7 +281,7 @@ fn map_fee_err( fn map_executor_err( err: TransactionExecutorError, -) -> Result> { +) -> Result> { match err { TransactionExecutorError::TransactionExecutionError(e) => match e { TransactionExecutionError::TransactionFeeError(e) => map_fee_err(*e), @@ -298,7 +298,7 @@ fn map_executor_err( fn map_execution_err( err: TransactionExecutionError, -) -> Result> { +) -> Result> { match err { e @ TransactionExecutionError::ValidateTransactionError { storage_address, @@ -326,7 +326,7 @@ fn map_execution_err( fn map_pre_validation_err( err: TransactionPreValidationError, -) -> Result> { +) -> Result> { match err { TransactionPreValidationError::TransactionFeeError(err) => map_fee_err(*err), TransactionPreValidationError::StateError(err) => Err(Box::new(err)), diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 44cf8e5fd..cea056f05 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -32,6 +32,10 @@ use tracing::{debug, trace}; use crate::cartridge::{encode_calls, VrfService}; use crate::starknet::{PendingBlockProvider, StarknetApi}; +const STARKNET_ESTIMATE_FEE: &str = "starknet_estimateFee"; +const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE: &str = "cartridge_addExecuteFromOutside"; +const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX: &str = "cartridge_addExecuteOutsideTransaction"; + #[derive(Debug)] pub struct ControllerDeploymentLayer where @@ -66,8 +70,9 @@ where impl ControllerDeploymentService where + S: RpcServiceT + Send + Sync + Clone + 'static, S: RpcServiceT, - Pool: TransactionPool + Send + Sync + 'static, + Pool: TransactionPool + 'static, PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, @@ -78,18 +83,19 @@ where // extras results from estimate_fees to be // sure to return the same number of result than the number // of transactions in the request. - async fn handle_estimate_fee<'a>( + async fn starknet_estimate_fee<'a>( &self, params: EstimateFeeParams, request: Request<'a>, ) -> S::MethodResponse { + let request_id = request.id().clone(); match self.handle_estimate_fee_inner(params, request).await { Ok(response) => response, - Err(err) => MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)), + Err(err) => MethodResponse::error(request_id, ErrorObjectOwned::from(err)), } } - async fn handle_execute_outside<'a>( + async fn cartridge_add_execute_from_outside<'a>( &self, params: AddExecuteOutsideParams, request: Request<'a>, @@ -150,7 +156,7 @@ where let response = self.service.call(new_request).await; let res = response.as_json().get(); - let mut res = serde_json::from_str::>>(res).unwrap(); + let res = serde_json::from_str::>>(res).unwrap(); match res.payload { ResponsePayload::Success(mut estimates) => { @@ -295,7 +301,7 @@ impl RpcServiceT for ControllerDeploymentService, - Pool: TransactionPool + Send + Sync + 'static, + Pool: TransactionPool + 'static, PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, @@ -315,17 +321,17 @@ where let method = request.method_name(); match method { - "starknet_estimateFee" => { + STARKNET_ESTIMATE_FEE => { trace!(%method, "Intercepting JSON-RPC method."); if let Some(params) = parse_estimate_fee_params(&request) { - return this.handle_estimate_fee(params, request).await; + return this.starknet_estimate_fee(params, request).await; } } - "addExecuteOutsideTransaction" | "addExecuteFromOutside" => { + CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE | CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX => { trace!(%method, "Intercepting JSON-RPC method."); if let Some(params) = parse_execute_outside_params(&request) { - return this.handle_execute_outside(params, request).await; + return this.cartridge_add_execute_from_outside(params, request).await; } } From e35b307520e590f531c21880c1e372aafbed6a39 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 10:23:29 -0600 Subject: [PATCH 12/32] wip --- crates/gateway/gateway-server/src/lib.rs | 2 +- crates/grpc/src/handlers/starknet.rs | 2 +- crates/node/full/src/lib.rs | 2 +- crates/node/sequencer/src/lib.rs | 80 +++++++------------ crates/rpc/rpc-server/src/cartridge/mod.rs | 17 +--- .../rpc-server/src/middleware/cartridge.rs | 66 +++++++++++++-- 6 files changed, 92 insertions(+), 77 deletions(-) diff --git a/crates/gateway/gateway-server/src/lib.rs b/crates/gateway/gateway-server/src/lib.rs index 1bd071e3c..7120616c2 100644 --- a/crates/gateway/gateway-server/src/lib.rs +++ b/crates/gateway/gateway-server/src/lib.rs @@ -7,7 +7,7 @@ use axum::Router; use katana_core::service::block_producer::BlockProducer; use katana_pool_api::TransactionPool; use katana_provider::{ProviderFactory, ProviderRO, ProviderRW}; -use katana_rpc_server::cors::Cors; +use katana_rpc_server::middleware::cors::Cors; use katana_rpc_server::starknet::StarknetApi; use tokio::net::TcpListener; use tokio::sync::watch; diff --git a/crates/grpc/src/handlers/starknet.rs b/crates/grpc/src/handlers/starknet.rs index d3bb7b96c..5e43f26c1 100644 --- a/crates/grpc/src/handlers/starknet.rs +++ b/crates/grpc/src/handlers/starknet.rs @@ -1,6 +1,6 @@ //! Starknet service handler implementation. -use katana_pool::TransactionPool; +use katana_pool::api::TransactionPool; use katana_primitives::transaction::TxHash; use katana_primitives::Felt; use katana_provider::{ProviderFactory, ProviderRO}; diff --git a/crates/node/full/src/lib.rs b/crates/node/full/src/lib.rs index 66f6ebe55..0099c1a54 100644 --- a/crates/node/full/src/lib.rs +++ b/crates/node/full/src/lib.rs @@ -26,7 +26,7 @@ use katana_pool::ordering::TipOrdering; use katana_provider::DbProviderFactory; use katana_rpc_api::katana::KatanaApiServer; use katana_rpc_api::starknet::{StarknetApiServer, StarknetTraceApiServer, StarknetWriteApiServer}; -use katana_rpc_server::cors::Cors; +use katana_rpc_server::middleware::cors::Cors; use katana_rpc_server::starknet::{StarknetApi, StarknetApiConfig}; use katana_rpc_server::{RpcServer, RpcServerHandle}; use katana_stage::blocks::BatchBlockDownloader; diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index b7e630bca..62c837b0f 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -238,11 +238,15 @@ where let mut rpc_modules = RpcModule::new(()); - let cors = Cors::new() - .allow_origins(config.rpc.cors_origins.clone()) // Allow `POST` when accessing the resource - .allow_methods([Method::POST, Method::GET]) - .allow_headers([CONTENT_TYPE, "argent-client".parse().unwrap(), "argent-version".parse().unwrap()]); + let cors = Cors::new() + .allow_origins(config.rpc.cors_origins.clone()) + .allow_methods([Method::POST, Method::GET]) + .allow_headers([ + CONTENT_TYPE, + "argent-client".parse().unwrap(), + "argent-version".parse().unwrap(), + ]); #[cfg(feature = "paymaster")] if let Some(cfg) = &config.paymaster { @@ -251,8 +255,12 @@ where }; #[cfg(feature = "cartridge")] - if let Some(cfg) = &config.paymaster { + let controller_deployment_layer = if let Some(cfg) = &config.paymaster { if let Some(cartridge_api_cfg) = &cfg.cartridge_api { + use katana_rpc_client::HttpClient; + use katana_rpc_server::cartridge::VrfService; + use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; + anyhow::ensure!( config.rpc.apis.contains(&RpcModuleKind::Cartridge), "Cartridge API should be enabled when paymaster is set" @@ -274,6 +282,9 @@ where None }; + let cartridge_api_client = + cartridge::CartridgeApiClient::new(cartridge_api_cfg.cartridge_api_url.clone()); + let cartridge_api_config = CartridgeConfig { paymaster_url: cfg.url.clone(), paymaster_api_key: cfg.api_key.clone(), @@ -282,7 +293,7 @@ where controller_deployer_private_key: cartridge_api_cfg .controller_deployer_private_key, #[cfg(feature = "vrf")] - vrf, + vrf: vrf.clone(), }; let cartrige_api = CartridgeApi::new( @@ -295,11 +306,16 @@ where rpc_modules.merge(CartridgeApiServer::into_rpc(cartrige_api))?; - Some(CartridgePaymasterConfig { - cartridge_api_url: cartridge_api_cfg.cartridge_api_url.clone(), - paymaster_address: cartridge_api_cfg.controller_deployer_address, - paymaster_private_key: cartridge_api_cfg.controller_deployer_private_key, - }) + Some(ControllerDeploymentLayer::new( + starknet_api.clone(), + cartridge_api_client, + HttpClient::builder().build(cfg.url)?, + cartridge_api_cfg.controller_deployer_address, + SigningKey::from_secret_scalar( + cartridge_api_cfg.controller_deployer_private_key, + ), + vrf.map(VrfService::new), + )) } else { None } @@ -381,46 +397,6 @@ where } } - // --- build paymaster tower layer (if configured) - - #[cfg(feature = "cartridge")] - let paymaster = if let Some(cfg) = &config.paymaster { - if let Some(cartridge_api_cfg) = &cfg.cartridge_api { - if let Some(vrf_cfg) = &cartridge_api_cfg.vrf { - info!(target: "cartridge", "Paymaster tower layer enabled"); - - let cartridge_api_client = cartridge::CartridgeApiClient::new( - cartridge_api_cfg.cartridge_api_url.clone(), - ); - - let rpc_url = url::Url::parse(&format!("http://{}", config.rpc.socket_addr())) - .expect("valid rpc url"); - - let vrf_client = cartridge::VrfClient::new(vrf_cfg.url.clone()); - - Some(Paymaster::new( - provider.clone(), - cartridge_api_client, - pool.clone(), - config.chain.id(), - cartridge_api_cfg.controller_deployer_address, - SigningKey::from_secret_scalar( - cartridge_api_cfg.controller_deployer_private_key, - ), - vrf_client, - vrf_cfg.vrf_account, - rpc_url, - )) - } else { - None - } - } else { - None - } - } else { - None - }; - // --- build rpc middleware let rpc_middleware = RpcServiceBuilder::new() @@ -428,7 +404,7 @@ where .layer(RpcLoggerLayer::new()); #[cfg(feature = "cartridge")] - let rpc_middleware = rpc_middleware.option_layer(paymaster.map(|p| p.layer())); + let rpc_middleware = rpc_middleware.option_layer(controller_deployment_layer); #[allow(unused_mut)] let mut rpc_server = RpcServer::new() diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index e88ce06ea..be2aa8184 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -36,7 +36,7 @@ use http::{HeaderMap, HeaderName, HeaderValue}; use jsonrpsee::core::{async_trait, RpcResult}; use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; use katana_core::backend::Backend; -use katana_core::service::block_producer::{BlockProducer, BlockProducerMode}; +use katana_core::service::block_producer::BlockProducer; use katana_genesis::constant::{DEFAULT_STRK_FEE_TOKEN_ADDRESS, DEFAULT_UDC_ADDRESS}; use katana_pool::api::TransactionPool; use katana_pool::TxPool; @@ -161,20 +161,6 @@ where }) } - fn nonce(&self, address: ContractAddress) -> Result, CartridgeApiError> { - match self.pool.get_nonce(address) { - pending_nonce @ Some(..) => Ok(pending_nonce), - None => Ok(self.state()?.nonce(address)?), - } - } - - fn state(&self) -> Result, CartridgeApiError> { - match &*self.block_producer.producer.read() { - BlockProducerMode::Instant(_) => Ok(self.backend.storage.provider().latest()?), - BlockProducerMode::Interval(producer) => Ok(producer.executor().read().state()), - } - } - pub async fn execute_outside( &self, contract_address: ContractAddress, @@ -261,6 +247,7 @@ where where T: FnOnce(Self) -> F, F: Future + Send + 'static, + F::Output: Send + 'static, { use tokio::runtime::Builder; diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index cea056f05..2f0ddcce3 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::HashSet; use std::future::Future; use cartridge::vrf::VrfClientError; @@ -27,6 +26,7 @@ use starknet::core::types::SimulationFlagForEstimateFee; use starknet::macros::selector; use starknet::signers::local_wallet::SignError; use starknet::signers::{LocalWallet, Signer, SigningKey}; +use tower::Layer; use tracing::{debug, trace}; use crate::cartridge::{encode_calls, VrfService}; @@ -37,9 +37,10 @@ const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE: &str = "cartridge_addExecuteFromOutsid const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX: &str = "cartridge_addExecuteOutsideTransaction"; #[derive(Debug)] -pub struct ControllerDeploymentLayer +pub struct ControllerDeploymentLayer where - Pool: TransactionPool + 'static, + Pool: TransactionPool + 'static, + PoolTx: From, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, @@ -52,6 +53,56 @@ where vrf_service: Option, } +impl ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PoolTx: From, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, +{ + pub fn new( + starknet: StarknetApi, + cartridge_api: CartridgeApiClient, + paymaster_client: HttpClient, + deployer_address: ContractAddress, + deployer_private_key: SigningKey, + vrf_service: Option, + ) -> Self { + Self { + starknet, + cartridge_api, + paymaster_client, + deployer_address, + deployer_private_key, + vrf_service, + } + } +} + +impl Layer for ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PoolTx: From, + PP: PendingBlockProvider, + PF: ProviderFactory, + ::Provider: ProviderRO, +{ + type Service = ControllerDeploymentService; + + fn layer(&self, inner: S) -> Self::Service { + ControllerDeploymentService { + service: inner, + starknet: self.starknet.clone(), + cartridge_api: self.cartridge_api.clone(), + paymaster_client: self.paymaster_client.clone(), + vrf_service: self.vrf_service.clone(), + deployer_address: self.deployer_address, + deployer_private_key: self.deployer_private_key.clone(), + } + } +} + #[derive(Debug)] pub struct ControllerDeploymentService where @@ -89,7 +140,7 @@ where request: Request<'a>, ) -> S::MethodResponse { let request_id = request.id().clone(); - match self.handle_estimate_fee_inner(params, request).await { + match self.starknet_estimate_fee_inner(params, request).await { Ok(response) => response, Err(err) => MethodResponse::error(request_id, ErrorObjectOwned::from(err)), } @@ -100,14 +151,14 @@ where params: AddExecuteOutsideParams, request: Request<'a>, ) -> S::MethodResponse { - if let Err(err) = self.handle_execute_outside_inner(params).await { + if let Err(err) = self.cartridge_add_execute_from_outside_inner(params).await { MethodResponse::error(request.id().clone(), ErrorObjectOwned::from(err)) } else { self.service.call(request).await } } - async fn handle_estimate_fee_inner<'a>( + async fn starknet_estimate_fee_inner<'a>( &self, params: EstimateFeeParams, request: Request<'a>, @@ -169,7 +220,7 @@ where } } - async fn handle_execute_outside_inner( + async fn cartridge_add_execute_from_outside_inner( &self, params: AddExecuteOutsideParams, ) -> Result<(), CartridgeApiError> { @@ -180,6 +231,7 @@ where let is_deployed = match self.starknet.class_hash_at_address(block_id, address).await { Ok(..) => true, Err(StarknetApiError::ContractNotFound) => false, + Err(e) => { return Err(CartridgeApiError::ControllerDeployment { reason: format!("failed to check Controller deployment status: {e}"), From 8e116cccdbd086441fe2df826d8ae53f2d81c8b1 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 10:39:16 -0600 Subject: [PATCH 13/32] wip --- crates/node/sequencer/src/lib.rs | 110 +++++++++--------- crates/rpc/rpc-server/src/cartridge/mod.rs | 10 -- .../rpc-server/src/middleware/cartridge.rs | 12 +- 3 files changed, 60 insertions(+), 72 deletions(-) diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index 62c837b0f..291fe6ebf 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -7,8 +7,6 @@ use std::future::IntoFuture; use std::sync::Arc; use anyhow::{bail, Context, Result}; -#[cfg(feature = "cartridge")] -use cartridge::rpc::{layer::PaymasterLayer, Paymaster}; use config::rpc::RpcModuleKind; use config::Config; use http::header::CONTENT_TYPE; @@ -52,10 +50,12 @@ use katana_rpc_api::tee::TeeApiServer; use katana_rpc_client::starknet::Client as StarknetClient; #[cfg(feature = "cartridge")] use katana_rpc_server::cartridge::{CartridgeApi, CartridgeConfig}; -use katana_rpc_server::cors::Cors; use katana_rpc_server::dev::DevApi; -use katana_rpc_server::logger::RpcLoggerLayer; -use katana_rpc_server::metrics::RpcServerMetricsLayer; +#[cfg(feature = "cartridge")] +use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; +use katana_rpc_server::middleware::cors::Cors; +use katana_rpc_server::middleware::logger::RpcLoggerLayer; +use katana_rpc_server::middleware::metrics::RpcServerMetricsLayer; #[cfg(feature = "paymaster")] use katana_rpc_server::paymaster::PaymasterProxy; use katana_rpc_server::starknet::{StarknetApi, StarknetApiConfig}; @@ -75,8 +75,8 @@ use crate::exit::NodeStoppedFuture; /// The concrete type of the RPC middleware stack used by the node. #[cfg(feature = "cartridge")] -type NodeRpcMiddleware

= Stack< - Either, Identity>, +type NodeRpcMiddleware = Stack< + Either, Identity>, Stack>, >; @@ -84,7 +84,7 @@ type NodeRpcMiddleware

= Stack< type NodeRpcMiddleware = Stack>; #[cfg(feature = "cartridge")] -pub type NodeRpcServer

= RpcServer>; +pub type NodeRpcServer = RpcServer>; #[cfg(not(feature = "cartridge"))] pub type NodeRpcServer = RpcServer; @@ -254,14 +254,60 @@ where rpc_modules.merge(proxy.into_rpc())?; }; + // --- build starknet api + + let starknet_api_cfg = StarknetApiConfig { + max_event_page_size: config.rpc.max_event_page_size, + max_proof_keys: config.rpc.max_proof_keys, + max_call_gas: config.rpc.max_call_gas, + max_concurrent_estimate_fee_requests: config.rpc.max_concurrent_estimate_fee_requests, + simulation_flags: execution_flags, + versioned_constant_overrides, + }; + + let chain_spec = backend.chain_spec.clone(); + + let starknet_api = StarknetApi::new( + chain_spec.clone(), + pool.clone(), + task_spawner.clone(), + block_producer.clone(), + gas_oracle.clone(), + starknet_api_cfg, + provider.clone(), + ); + + if config.rpc.apis.contains(&RpcModuleKind::Starknet) { + #[cfg(feature = "explorer")] + if config.rpc.explorer { + rpc_modules.merge(StarknetApiExtServer::into_rpc(starknet_api.clone()))?; + } + + rpc_modules.merge(StarknetApiServer::into_rpc(starknet_api.clone()))?; + rpc_modules.merge(StarknetWriteApiServer::into_rpc(starknet_api.clone()))?; + rpc_modules.merge(StarknetTraceApiServer::into_rpc(starknet_api.clone()))?; + } + + if config.rpc.apis.contains(&RpcModuleKind::Starknet) { + rpc_modules.merge(KatanaApiServer::into_rpc(starknet_api.clone()))?; + } + + if config.rpc.apis.contains(&RpcModuleKind::Dev) { + let api = DevApi::new(backend.clone(), block_producer.clone()); + rpc_modules.merge(DevApiServer::into_rpc(api))?; + } + + // --- build cartridge api (plus middleware) + #[cfg(feature = "cartridge")] let controller_deployment_layer = if let Some(cfg) = &config.paymaster { if let Some(cartridge_api_cfg) = &cfg.cartridge_api { + use anyhow::ensure; use katana_rpc_client::HttpClient; use katana_rpc_server::cartridge::VrfService; use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; - anyhow::ensure!( + ensure!( config.rpc.apis.contains(&RpcModuleKind::Cartridge), "Cartridge API should be enabled when paymaster is set" ); @@ -289,9 +335,6 @@ where paymaster_url: cfg.url.clone(), paymaster_api_key: cfg.api_key.clone(), api_url: cartridge_api_cfg.cartridge_api_url.clone(), - controller_deployer_address: cartridge_api_cfg.controller_deployer_address, - controller_deployer_private_key: cartridge_api_cfg - .controller_deployer_private_key, #[cfg(feature = "vrf")] vrf: vrf.clone(), }; @@ -323,49 +366,6 @@ where None }; - // --- build starknet api - - let starknet_api_cfg = StarknetApiConfig { - max_event_page_size: config.rpc.max_event_page_size, - max_proof_keys: config.rpc.max_proof_keys, - max_call_gas: config.rpc.max_call_gas, - max_concurrent_estimate_fee_requests: config.rpc.max_concurrent_estimate_fee_requests, - simulation_flags: execution_flags, - versioned_constant_overrides, - }; - - let chain_spec = backend.chain_spec.clone(); - - let starknet_api = StarknetApi::new( - chain_spec.clone(), - pool.clone(), - task_spawner.clone(), - block_producer.clone(), - gas_oracle.clone(), - starknet_api_cfg, - provider.clone(), - ); - - if config.rpc.apis.contains(&RpcModuleKind::Starknet) { - #[cfg(feature = "explorer")] - if config.rpc.explorer { - rpc_modules.merge(StarknetApiExtServer::into_rpc(starknet_api.clone()))?; - } - - rpc_modules.merge(StarknetApiServer::into_rpc(starknet_api.clone()))?; - rpc_modules.merge(StarknetWriteApiServer::into_rpc(starknet_api.clone()))?; - rpc_modules.merge(StarknetTraceApiServer::into_rpc(starknet_api.clone()))?; - } - - if config.rpc.apis.contains(&RpcModuleKind::Starknet) { - rpc_modules.merge(KatanaApiServer::into_rpc(starknet_api.clone()))?; - } - - if config.rpc.apis.contains(&RpcModuleKind::Dev) { - let api = DevApi::new(backend.clone(), block_producer.clone()); - rpc_modules.merge(DevApiServer::into_rpc(api))?; - } - // --- build tee api (if configured) #[cfg(feature = "tee")] if config.rpc.apis.contains(&RpcModuleKind::Tee) { diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index be2aa8184..3d6a6dd57 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -74,8 +74,6 @@ pub struct CartridgeConfig { pub api_url: Url, pub paymaster_url: Url, pub paymaster_api_key: Option, - pub controller_deployer_address: ContractAddress, - pub controller_deployer_private_key: Felt, #[cfg(feature = "vrf")] pub vrf: Option, } @@ -88,10 +86,6 @@ pub struct CartridgeApi { pool: TxPool, api_client: cartridge::CartridgeApiClient, paymaster_client: HttpClient, - /// The paymaster account address used for controller deployment. - controller_deployer_address: ContractAddress, - /// The paymaster account private key. - controller_deployer_private_key: Felt, #[cfg(feature = "vrf")] vrf_service: Option, } @@ -108,8 +102,6 @@ where pool: self.pool.clone(), api_client: self.api_client.clone(), paymaster_client: self.paymaster_client.clone(), - controller_deployer_address: self.controller_deployer_address, - controller_deployer_private_key: self.controller_deployer_private_key, #[cfg(feature = "vrf")] vrf_service: self.vrf_service.clone(), } @@ -154,8 +146,6 @@ where pool, api_client, paymaster_client, - controller_deployer_address: config.controller_deployer_address, - controller_deployer_private_key: config.controller_deployer_private_key, #[cfg(feature = "vrf")] vrf_service, }) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 2f0ddcce3..d386df1b7 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -37,10 +37,9 @@ const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE: &str = "cartridge_addExecuteFromOutsid const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX: &str = "cartridge_addExecuteOutsideTransaction"; #[derive(Debug)] -pub struct ControllerDeploymentLayer +pub struct ControllerDeploymentLayer where - Pool: TransactionPool + 'static, - PoolTx: From, + Pool: TransactionPool + 'static, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, @@ -53,10 +52,9 @@ where vrf_service: Option, } -impl ControllerDeploymentLayer +impl ControllerDeploymentLayer where - Pool: TransactionPool + 'static, - PoolTx: From, + Pool: TransactionPool + 'static, PP: PendingBlockProvider, PF: ProviderFactory, ::Provider: ProviderRO, @@ -80,7 +78,7 @@ where } } -impl Layer for ControllerDeploymentLayer +impl Layer for ControllerDeploymentLayer where Pool: TransactionPool + 'static, PoolTx: From, From 0ce615162038de4cd673f4dca462869c03d961cc Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 10:46:02 -0600 Subject: [PATCH 14/32] wip --- crates/node/sequencer/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index 291fe6ebf..e6a470720 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -105,7 +105,7 @@ where config: Arc, pool: TxPool, #[cfg(feature = "cartridge")] - rpc_server: NodeRpcServer

, + rpc_server: NodeRpcServer, #[cfg(not(feature = "cartridge"))] rpc_server: NodeRpcServer, #[cfg(feature = "grpc")] From 46a5636c5d537e5e667f5900317f4e911a7a81a3 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 13:47:28 -0600 Subject: [PATCH 15/32] wip --- crates/cli/src/args.rs | 2 +- crates/cli/src/options.rs | 2 +- crates/cli/src/sidecar.rs | 4 ++-- crates/cli/src/utils.rs | 2 +- crates/node/sequencer/src/lib.rs | 10 +++++----- crates/rpc/rpc-server/src/cartridge/mod.rs | 3 +-- crates/rpc/rpc-server/src/cartridge/vrf.rs | 17 +++++++++------- .../rpc-server/src/middleware/cartridge.rs | 20 +++++++++++++++++-- crates/utils/src/node.rs | 1 - 9 files changed, 39 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 318aedf74..b02c69a37 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -1034,7 +1034,7 @@ explorer = true #[test] #[cfg(feature = "server")] fn parse_cors_origins() { - use katana_rpc_server::cors::HeaderValue; + use katana_rpc_server::middleware::cors::HeaderValue; let result = SequencerNodeArgs::parse_from([ "katana", diff --git a/crates/cli/src/options.rs b/crates/cli/src/options.rs index 65a74f604..47d5b4892 100644 --- a/crates/cli/src/options.rs +++ b/crates/cli/src/options.rs @@ -19,7 +19,7 @@ use katana_primitives::chain::ChainId; #[cfg(feature = "vrf")] use katana_primitives::ContractAddress; #[cfg(feature = "server")] -use katana_rpc_server::cors::HeaderValue; +use katana_rpc_server::middleware::cors::HeaderValue; use katana_sequencer_node::config::execution::{ DEFAULT_INVOCATION_MAX_STEPS, DEFAULT_VALIDATION_MAX_STEPS, }; diff --git a/crates/cli/src/sidecar.rs b/crates/cli/src/sidecar.rs index 2af068cc9..310319a1b 100644 --- a/crates/cli/src/sidecar.rs +++ b/crates/cli/src/sidecar.rs @@ -2,7 +2,7 @@ use std::net::SocketAddr; use anyhow::{anyhow, Result}; #[cfg(feature = "vrf")] -pub use cartridge::vrf::{ +pub use cartridge::vrf::server::{ get_vrf_account, VrfAccountCredentials, VrfBootstrapResult, VrfServer, VrfServerConfig, VrfServiceProcess, VRF_SERVER_PORT, }; @@ -62,7 +62,7 @@ pub async fn bootstrap_vrf( let rpc_url = local_rpc_url(&rpc_addr); let (account_address, pk) = prefunded_account(chain, 0)?; - let result = cartridge::vrf::bootstrap_vrf(rpc_url, account_address, pk).await?; + let result = cartridge::vrf::server::bootstrap_vrf(rpc_url, account_address, pk).await?; let mut vrf_service = VrfServer::new(VrfServerConfig { secret_key: result.secret_key, diff --git a/crates/cli/src/utils.rs b/crates/cli/src/utils.rs index b51db2000..5266f2951 100644 --- a/crates/cli/src/utils.rs +++ b/crates/cli/src/utils.rs @@ -15,7 +15,7 @@ use katana_primitives::cairo::ShortString; use katana_primitives::chain::ChainId; use katana_primitives::class::ClassHash; use katana_primitives::contract::ContractAddress; -use katana_rpc_server::cors::HeaderValue; +use katana_rpc_server::middleware::cors::HeaderValue; use katana_tracing::LogFormat; use serde::{Deserialize, Deserializer, Serializer}; use tracing::info; diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index e6a470720..94fdc148c 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -75,8 +75,8 @@ use crate::exit::NodeStoppedFuture; /// The concrete type of the RPC middleware stack used by the node. #[cfg(feature = "cartridge")] -type NodeRpcMiddleware = Stack< - Either, Identity>, +type NodeRpcMiddleware = Stack< + Either, PF>, Identity>, Stack>, >; @@ -84,7 +84,7 @@ type NodeRpcMiddleware = Stack< type NodeRpcMiddleware = Stack>; #[cfg(feature = "cartridge")] -pub type NodeRpcServer = RpcServer>; +pub type NodeRpcServer = RpcServer>; #[cfg(not(feature = "cartridge"))] pub type NodeRpcServer = RpcServer; @@ -105,7 +105,7 @@ where config: Arc, pool: TxPool, #[cfg(feature = "cartridge")] - rpc_server: NodeRpcServer, + rpc_server: NodeRpcServer

, #[cfg(not(feature = "cartridge"))] rpc_server: NodeRpcServer, #[cfg(feature = "grpc")] @@ -352,7 +352,7 @@ where Some(ControllerDeploymentLayer::new( starknet_api.clone(), cartridge_api_client, - HttpClient::builder().build(cfg.url)?, + HttpClient::builder().build(cfg.url.clone())?, cartridge_api_cfg.controller_deployer_address, SigningKey::from_secret_scalar( cartridge_api_cfg.controller_deployer_private_key, diff --git a/crates/rpc/rpc-server/src/cartridge/mod.rs b/crates/rpc/rpc-server/src/cartridge/mod.rs index 3d6a6dd57..1d4548cb7 100644 --- a/crates/rpc/rpc-server/src/cartridge/mod.rs +++ b/crates/rpc/rpc-server/src/cartridge/mod.rs @@ -38,7 +38,6 @@ use jsonrpsee::http_client::{HttpClient, HttpClientBuilder}; use katana_core::backend::Backend; use katana_core::service::block_producer::BlockProducer; use katana_genesis::constant::{DEFAULT_STRK_FEE_TOKEN_ADDRESS, DEFAULT_UDC_ADDRESS}; -use katana_pool::api::TransactionPool; use katana_pool::TxPool; use katana_primitives::chain::ChainId; use katana_primitives::contract::Nonce; @@ -46,7 +45,7 @@ use katana_primitives::execution::Call; use katana_primitives::fee::{AllResourceBoundsMapping, ResourceBoundsMapping}; use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV3}; use katana_primitives::{ContractAddress, Felt}; -use katana_provider::api::state::{StateFactoryProvider, StateProvider}; +use katana_provider::api::state::StateProvider; use katana_provider::{ProviderFactory, ProviderRO, ProviderRW}; use katana_rpc_api::cartridge::CartridgeApiServer; use katana_rpc_api::error::cartridge::CartridgeApiError; diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index 50448352d..98a017cb6 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -91,15 +91,17 @@ mod tests { #[test] fn request_random_call_finds_position() { let vrf_address = ContractAddress::from(felt!("0x123")); - let other_call = katana_rpc_types::outside_execution::Call { - to: vrf_address, - selector: selector!("transfer"), + + let other_call = Call { calldata: vec![Felt::ONE], + contract_address: vrf_address, + entry_point_selector: selector!("transfer"), }; - let vrf_call = katana_rpc_types::outside_execution::Call { - to: vrf_address, - selector: selector!("request_random"), + + let vrf_call = Call { calldata: vec![Felt::TWO], + contract_address: vrf_address, + entry_point_selector: selector!("request_random"), }; let outside_execution = OutsideExecution::V2(OutsideExecutionV2 { @@ -112,8 +114,9 @@ mod tests { let (call, position) = get_request_random_call(&outside_execution).expect("request_random found"); + assert_eq!(position, 1); - assert_eq!(call.selector, vrf_call.selector); + assert_eq!(call.entry_point_selector, vrf_call.entry_point_selector); assert_eq!(call.calldata, vrf_call.calldata); } } diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index d386df1b7..64320ec99 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -42,7 +42,6 @@ where Pool: TransactionPool + 'static, PP: PendingBlockProvider, PF: ProviderFactory, - ::Provider: ProviderRO, { starknet: StarknetApi, cartridge_api: CartridgeApiClient, @@ -57,7 +56,6 @@ where Pool: TransactionPool + 'static, PP: PendingBlockProvider, PF: ProviderFactory, - ::Provider: ProviderRO, { pub fn new( starknet: StarknetApi, @@ -407,6 +405,24 @@ where } } +impl Clone for ControllerDeploymentLayer +where + Pool: TransactionPool, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + fn clone(&self) -> Self { + Self { + starknet: self.starknet.clone(), + vrf_service: self.vrf_service.clone(), + cartridge_api: self.cartridge_api.clone(), + paymaster_client: self.paymaster_client.clone(), + deployer_address: self.deployer_address.clone(), + deployer_private_key: self.deployer_private_key.clone(), + } + } +} + impl Clone for ControllerDeploymentService where S: Clone, diff --git a/crates/utils/src/node.rs b/crates/utils/src/node.rs index d864961e7..c142f2b41 100644 --- a/crates/utils/src/node.rs +++ b/crates/utils/src/node.rs @@ -194,7 +194,6 @@ where } /// Returns the address of the node's gRPC server (if enabled). - #[cfg(feature = "grpc")] pub fn grpc_addr(&self) -> Option<&SocketAddr> { self.node.grpc().map(|h| h.addr()) } From ded3296d4a2e709b93102c170934f9548b7aae57 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 14:05:00 -0600 Subject: [PATCH 16/32] wip --- crates/rpc/rpc-server/src/middleware/cartridge.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 64320ec99..7888f9fcd 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -470,6 +470,7 @@ impl From for Error { } } +#[allow(dead_code)] #[derive(Deserialize)] struct AddExecuteOutsideParams { address: ContractAddress, From 6df8734ebd9eed4dc1751112395bb8e74e0d4e6a Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 14:26:50 -0600 Subject: [PATCH 17/32] wip --- crates/node/sequencer/src/lib.rs | 2 -- .../rpc/rpc-server/src/middleware/cartridge.rs | 17 ++--------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index 94fdc148c..8a82fb597 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -304,7 +304,6 @@ where if let Some(cartridge_api_cfg) = &cfg.cartridge_api { use anyhow::ensure; use katana_rpc_client::HttpClient; - use katana_rpc_server::cartridge::VrfService; use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; ensure!( @@ -357,7 +356,6 @@ where SigningKey::from_secret_scalar( cartridge_api_cfg.controller_deployer_private_key, ), - vrf.map(VrfService::new), )) } else { None diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 7888f9fcd..bfc346c60 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -29,7 +29,7 @@ use starknet::signers::{LocalWallet, Signer, SigningKey}; use tower::Layer; use tracing::{debug, trace}; -use crate::cartridge::{encode_calls, VrfService}; +use crate::cartridge::encode_calls; use crate::starknet::{PendingBlockProvider, StarknetApi}; const STARKNET_ESTIMATE_FEE: &str = "starknet_estimateFee"; @@ -48,7 +48,6 @@ where paymaster_client: HttpClient, deployer_address: ContractAddress, deployer_private_key: SigningKey, - vrf_service: Option, } impl ControllerDeploymentLayer @@ -63,16 +62,8 @@ where paymaster_client: HttpClient, deployer_address: ContractAddress, deployer_private_key: SigningKey, - vrf_service: Option, ) -> Self { - Self { - starknet, - cartridge_api, - paymaster_client, - deployer_address, - deployer_private_key, - vrf_service, - } + Self { starknet, cartridge_api, paymaster_client, deployer_address, deployer_private_key } } } @@ -92,7 +83,6 @@ where starknet: self.starknet.clone(), cartridge_api: self.cartridge_api.clone(), paymaster_client: self.paymaster_client.clone(), - vrf_service: self.vrf_service.clone(), deployer_address: self.deployer_address, deployer_private_key: self.deployer_private_key.clone(), } @@ -111,7 +101,6 @@ where paymaster_client: HttpClient, deployer_address: ContractAddress, deployer_private_key: SigningKey, - vrf_service: Option, service: S, } @@ -414,7 +403,6 @@ where fn clone(&self) -> Self { Self { starknet: self.starknet.clone(), - vrf_service: self.vrf_service.clone(), cartridge_api: self.cartridge_api.clone(), paymaster_client: self.paymaster_client.clone(), deployer_address: self.deployer_address.clone(), @@ -434,7 +422,6 @@ where Self { service: self.service.clone(), starknet: self.starknet.clone(), - vrf_service: self.vrf_service.clone(), cartridge_api: self.cartridge_api.clone(), paymaster_client: self.paymaster_client.clone(), deployer_address: self.deployer_address.clone(), From 14ebc363bea413488b0de11014d6a0aa7fb1f08b Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 15:50:06 -0600 Subject: [PATCH 18/32] add integration tests for ControllerDeployment middleware Cover all code paths in the middleware's starknet_estimateFee and cartridge_addExecuteFromOutside interception logic using a mock Cartridge API (axum), mock inner RPC service, and the real StarknetApi with an in-memory test provider. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/rpc/rpc-server/Cargo.toml | 1 + .../rpc-server/tests/controller_deployment.rs | 832 ++++++++++++++++++ 3 files changed, 834 insertions(+) create mode 100644 crates/rpc/rpc-server/tests/controller_deployment.rs diff --git a/Cargo.lock b/Cargo.lock index 0559b91dc..bc256dcc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6677,6 +6677,7 @@ dependencies = [ "anyhow", "assert_matches", "auto_impl", + "axum 0.7.9", "cainome", "cairo-lang-starknet-classes", "cartridge", diff --git a/crates/rpc/rpc-server/Cargo.toml b/crates/rpc/rpc-server/Cargo.toml index 24ff6d13d..e5fbe78e7 100644 --- a/crates/rpc/rpc-server/Cargo.toml +++ b/crates/rpc/rpc-server/Cargo.toml @@ -65,6 +65,7 @@ alloy-provider = { workspace = true, default-features = false, features = [ "anv alloy-sol-types.workspace = true assert_matches.workspace = true +axum.workspace = true cainome.workspace = true cairo-lang-starknet-classes.workspace = true indexmap.workspace = true diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs new file mode 100644 index 000000000..6dc94df7f --- /dev/null +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -0,0 +1,832 @@ +#![cfg(feature = "cartridge")] + +//! Integration tests for the `ControllerDeploymentService` middleware. +//! +//! These tests cover all code paths in the middleware's `starknet_estimateFee` and +//! `cartridge_addExecuteFromOutside` interception logic. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::{Arc, Mutex}; + +use axum::extract::State; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Router; +use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; +use jsonrpsee::types::Request; +use jsonrpsee::MethodResponse; +use katana_chain_spec::ChainSpec; +use katana_executor::ExecutionFlags; +use katana_gas_price_oracle::GasPriceOracle; +use katana_pool::api::TransactionPool; +use katana_pool::ordering::FiFo; +use katana_pool::pool::Pool; +use katana_pool::validation::NoopValidator; +use katana_primitives::transaction::ExecutableTxWithHash; +use katana_primitives::Felt; +use katana_provider::test_utils::test_provider; +use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; +use katana_rpc_server::starknet::{PendingBlockProvider, StarknetApi, StarknetApiConfig}; +use katana_rpc_types::*; +use katana_tasks::TaskManager; +use serde_json::json; +use starknet::signers::SigningKey; +use tokio::net::TcpListener; +use tower::Layer; +use url::Url; + +// --------------------------------------------------------------------------- +// Mock types +// --------------------------------------------------------------------------- + +type TestPool = Pool, FiFo>; + +/// A no-op pending block provider. All methods return `Ok(None)`, matching +/// instant-mining mode behaviour. +#[derive(Debug, Clone)] +struct NoPendingBlockProvider; + +impl PendingBlockProvider for NoPendingBlockProvider { + fn pending_state( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult< + Option>, + > { + Ok(None) + } + + fn get_pending_state_update( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_txs( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_receipts( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_tx_hashes( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_transaction( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_receipt( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_trace( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_transaction_by_index( + &self, + _index: katana_primitives::transaction::TxNumber, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// Mock Cartridge API HTTP server +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct MockCartridgeApiState { + /// Map from hex address (with "0x" prefix, lowercase) to the response JSON. + responses: Arc>, + /// Log of all requests received. + received_requests: Arc>>, +} + +async fn mock_cartridge_handler( + State(state): State, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + state.received_requests.lock().unwrap().push(body.clone()); + + let address = body.get("address").and_then(|v| v.as_str()).unwrap_or(""); + + if let Some(response) = state.responses.get(address) { + axum::Json(response.clone()).into_response() + } else { + "Address not found".into_response() + } +} + +/// Start a mock Cartridge API server. Returns (base URL, state handle, join handle). +async fn start_mock_cartridge_api( + responses: HashMap, +) -> (Url, MockCartridgeApiState) { + let state = MockCartridgeApiState { + responses: Arc::new(responses), + received_requests: Arc::new(Mutex::new(Vec::new())), + }; + + let app = + Router::new().route("/accounts/calldata", post(mock_cartridge_handler)).with_state(state.clone()); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let url = Url::parse(&format!("http://{addr}")).unwrap(); + (url, state) +} + +// --------------------------------------------------------------------------- +// Mock inner RPC service +// --------------------------------------------------------------------------- + +/// A recorded call to the mock RPC service. +#[derive(Clone, Debug)] +struct RecordedCall { + method: String, + /// For estimate_fee, how many transactions were in the params. + tx_count: Option, +} + +#[derive(Clone)] +struct MockRpcService { + /// Records all calls. + calls: Arc>>, + /// Pre-configured response JSON per method name. + responses: Arc>, +} + +impl MockRpcService { + fn new(responses: HashMap) -> Self { + Self { calls: Arc::new(Mutex::new(Vec::new())), responses: Arc::new(responses) } + } + + fn recorded_calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl RpcServiceT for MockRpcService { + type MethodResponse = MethodResponse; + type BatchResponse = MethodResponse; + type NotificationResponse = MethodResponse; + + fn call<'a>( + &self, + request: Request<'a>, + ) -> impl Future + Send + 'a { + let method = request.method_name().to_owned(); + + // Try to count transactions if this is an estimate_fee request. + let params = request.params(); + let tx_count = if method == "starknet_estimateFee" { + // Parse the first param (array of txs) from the sequence params. + let mut seq = params.sequence(); + let txs: Result, _> = seq.next(); + txs.ok().map(|v| v.len()) + } else { + None + }; + + self.calls.lock().unwrap().push(RecordedCall { method: method.clone(), tx_count }); + + let response = if let Some(resp) = self.responses.get(&method) { + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(resp.clone()), + usize::MAX, + ) + } else { + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + ) + }; + + std::future::ready(response) + } + + fn batch<'a>( + &self, + _requests: Batch<'a>, + ) -> impl Future + Send + 'a { + std::future::ready(MethodResponse::response( + jsonrpsee::types::Id::Null, + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + )) + } + + fn notification<'a>( + &self, + _n: Notification<'a>, + ) -> impl Future + Send + 'a { + std::future::ready(MethodResponse::response( + jsonrpsee::types::Id::Null, + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + )) + } +} + +// --------------------------------------------------------------------------- +// Test fixture helpers +// --------------------------------------------------------------------------- + +/// An undeployed address that the mock API will recognize as a Controller. +const CONTROLLER_ADDRESS: &str = "0xdead"; +/// An undeployed address that the mock API will NOT recognize as a Controller. +const NON_CONTROLLER_ADDRESS: &str = "0xbeef"; +/// The deployer address — matches the genesis account at 0x1 in test_provider. +const DEPLOYER_ADDRESS: &str = "0x1"; + +/// Builds a `serde_json::Value` response for the Cartridge API that represents +/// a valid Controller account with some dummy constructor calldata. +fn controller_calldata_response(address: &str) -> serde_json::Value { + json!({ + "address": address, + "username": "testuser", + "calldata": [ + "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", + "0x7465737475736572", + "0x0", + "0x2", + "0x1", + "0x2" + ] + }) +} + +/// Creates a valid V3 invoke transaction JSON for the given sender address. +fn make_invoke_tx_json(sender_address: &str) -> serde_json::Value { + json!({ + "type": "INVOKE", + "version": "0x3", + "sender_address": sender_address, + "calldata": ["0x1"], + "signature": ["0x0"], + "nonce": "0x0", + "resource_bounds": { + "l1_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, + "l2_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, + "l1_data_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" } + }, + "tip": "0x0", + "paymaster_data": [], + "account_deployment_data": [], + "nonce_data_availability_mode": "L1", + "fee_data_availability_mode": "L1" + }) +} + +/// Creates a JSON-RPC 2.0 request string and constructs the corresponding `Request<'_>`. +fn make_rpc_request_str(method: &str, params: &serde_json::Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params + }) + .to_string() +} + +/// A complete test setup context. +struct TestSetup { + service: as Layer>::Service, + mock_rpc: MockRpcService, + mock_api_state: MockCartridgeApiState, + pool: TestPool, +} + +async fn setup_test( + cartridge_api_responses: HashMap, + inner_rpc_responses: HashMap, +) -> TestSetup { + let (mock_url, mock_api_state) = start_mock_cartridge_api(cartridge_api_responses).await; + + let chain_spec = Arc::new(ChainSpec::dev()); + let pool = Pool::new(NoopValidator::new(), FiFo::new()); + let task_spawner = TaskManager::current().task_spawner(); + let gas_oracle = GasPriceOracle::create_for_testing(); + let storage = test_provider(); + + let config = StarknetApiConfig { + max_event_page_size: None, + max_proof_keys: None, + max_call_gas: None, + max_concurrent_estimate_fee_requests: None, + simulation_flags: ExecutionFlags::new().with_fee(false).with_account_validation(false), + versioned_constant_overrides: None, + }; + + let starknet_api = StarknetApi::new( + chain_spec, + pool.clone(), + task_spawner, + NoPendingBlockProvider, + gas_oracle, + config, + storage, + ); + + let cartridge_api = ::cartridge::CartridgeApiClient::new(mock_url); + + // Create a dummy paymaster HTTP client — pointed at a non-routable address. + // The tested code paths do not use the paymaster client. + let paymaster_client = jsonrpsee::http_client::HttpClientBuilder::default() + .build("http://127.0.0.1:1") + .unwrap(); + + let deployer_address = Felt::from(1u64).into(); + let deployer_private_key = SigningKey::from_secret_scalar(Felt::from(1u64)); + + let layer = ControllerDeploymentLayer::new( + starknet_api, + cartridge_api, + paymaster_client, + deployer_address, + deployer_private_key, + ); + + let mock_rpc = MockRpcService::new(inner_rpc_responses); + let service = layer.layer(mock_rpc.clone()); + + TestSetup { service, mock_rpc, mock_api_state, pool } +} + +// --------------------------------------------------------------------------- +// Group 1: starknet_estimateFee +// --------------------------------------------------------------------------- + +/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs returns empty +/// -> deploy_controller_txs.is_empty() == true -> forwards request as-is. +/// +/// The sender address 0x1 exists in genesis. The middleware queries the Cartridge API +/// for this address, which returns "Address not found" (not a Controller). Since no +/// Controllers need deployment, the request is forwarded unchanged to the inner service. +/// +/// Expected: inner service receives the exact same request; response is passed through. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_fee_forwards_when_no_controllers() { + let inner_responses = { + let mut m = HashMap::new(); + // Return a valid fee estimate response for 1 transaction. + m.insert( + "starknet_estimateFee".to_string(), + json!([{ + "l1_gas_consumed": "0x1", + "l1_gas_price": "0x2", + "l2_gas_consumed": "0x3", + "l2_gas_price": "0x4", + "l1_data_gas_consumed": "0x5", + "l1_data_gas_price": "0x6", + "overall_fee": "0x7" + }]), + ); + m + }; + + let setup = setup_test(HashMap::new(), inner_responses).await; + + let tx = make_invoke_tx_json(DEPLOYER_ADDRESS); + let params = json!([[tx], [], "latest"]); + let raw = make_rpc_request_str("starknet_estimateFee", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let response = setup.service.call(request).await; + + // The inner service should have been called exactly once. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1, "inner service should be called once"); + assert_eq!(calls[0].method, "starknet_estimateFee"); + + // The response should contain the fee estimate from the inner service (passed through). + let response_json: serde_json::Value = serde_json::from_str(response.as_json().get()).unwrap(); + let result = response_json.get("result").expect("response should have result"); + assert!(result.is_array()); + assert_eq!(result.as_array().unwrap().len(), 1); +} + +/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs returns 1 deploy tx +/// -> crafts new request with [deploy_tx, original_tx] -> calls inner service +/// -> strips deploy tx result from response -> returns zero-fee estimates +/// via build_no_fee_response. +/// +/// Address 0xDEAD is not deployed (not in genesis). The Cartridge API returns constructor +/// calldata for it, indicating it IS a Controller. The middleware creates a deploy tx signed +/// by the deployer (0x1), prepends it to the estimate fee request, and after the inner +/// service responds, returns zero-fee estimates for the original tx count only. +/// +/// Expected: inner service receives 2 txs; middleware response has 1 zero-fee estimate. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_fee_prepends_deploy_tx_for_controller() { + let cartridge_responses = { + let mut m = HashMap::new(); + m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); + m + }; + + let inner_responses = { + let mut m = HashMap::new(); + // The inner service will receive 2 txs (1 deploy + 1 original). + m.insert( + "starknet_estimateFee".to_string(), + json!([ + { + "l1_gas_consumed": "0xa", + "l1_gas_price": "0xb", + "l2_gas_consumed": "0xc", + "l2_gas_price": "0xd", + "l1_data_gas_consumed": "0xe", + "l1_data_gas_price": "0xf", + "overall_fee": "0x10" + }, + { + "l1_gas_consumed": "0x1", + "l1_gas_price": "0x2", + "l2_gas_consumed": "0x3", + "l2_gas_price": "0x4", + "l1_data_gas_consumed": "0x5", + "l1_data_gas_price": "0x6", + "overall_fee": "0x7" + } + ]), + ); + m + }; + + let setup = setup_test(cartridge_responses, inner_responses).await; + + let tx = make_invoke_tx_json(CONTROLLER_ADDRESS); + let params = json!([[tx], [], "latest"]); + let raw = make_rpc_request_str("starknet_estimateFee", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let response = setup.service.call(request).await; + + // Inner service should receive 2 txs: deploy tx + original tx. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1, "inner service should be called once"); + assert_eq!( + calls[0].tx_count, + Some(2), + "inner service should receive 2 transactions (deploy + original)" + ); + + // The middleware response should have 1 zero-fee estimate (for the original tx only). + let response_json: serde_json::Value = serde_json::from_str(response.as_json().get()).unwrap(); + let result = response_json.get("result").expect("response should have result"); + let estimates = result.as_array().unwrap(); + assert_eq!(estimates.len(), 1, "response should have 1 estimate for the original tx"); + + // All fee fields should be zero. + let est = &estimates[0]; + assert_eq!(est["overall_fee"], "0x0"); + assert_eq!(est["l1_gas_consumed"], "0x0"); + assert_eq!(est["l2_gas_consumed"], "0x0"); +} + +/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs +/// -> get_controller_deployment_tx calls CartridgeAPI which returns None +/// -> deploy list is empty -> forwards request as-is. +/// +/// Address 0xBEEF is not deployed and the Cartridge API returns "Address not found" +/// (not a Controller). Even though the address is undeployed, no deploy tx is created +/// because it's not a recognized Controller address. +/// +/// Expected: inner service receives the original request unchanged. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_fee_forwards_for_non_controller() { + let inner_responses = { + let mut m = HashMap::new(); + m.insert( + "starknet_estimateFee".to_string(), + json!([{ + "l1_gas_consumed": "0x1", + "l1_gas_price": "0x2", + "l2_gas_consumed": "0x3", + "l2_gas_price": "0x4", + "l1_data_gas_consumed": "0x5", + "l1_data_gas_price": "0x6", + "overall_fee": "0x7" + }]), + ); + m + }; + + let setup = setup_test(HashMap::new(), inner_responses).await; + + let tx = make_invoke_tx_json(NON_CONTROLLER_ADDRESS); + let params = json!([[tx], [], "latest"]); + let raw = make_rpc_request_str("starknet_estimateFee", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let response = setup.service.call(request).await; + + // Inner service receives the request unchanged (1 tx). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "starknet_estimateFee"); + + // Response is passed through. + let response_json: serde_json::Value = serde_json::from_str(response.as_json().get()).unwrap(); + let result = response_json.get("result").expect("response should have result"); + assert_eq!(result.as_array().unwrap().len(), 1); +} + +/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs +/// -> processes 3 txs from same address -> dedup via processed_addresses +/// -> only 1 deploy tx created -> new request has 4 txs total. +/// +/// Three invoke txs all from undeployed Controller address 0xDEAD. The middleware +/// deduplicates by tracking processed addresses, creating only one deploy tx despite +/// three txs from the same sender. +/// +/// Expected: inner service receives 4 txs (1 deploy + 3 original); +/// middleware returns 3 zero-fee estimates. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_fee_deduplicates_same_controller() { + let cartridge_responses = { + let mut m = HashMap::new(); + m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); + m + }; + + let inner_responses = { + let mut m = HashMap::new(); + // Inner service receives 4 txs (1 deploy + 3 original). + m.insert( + "starknet_estimateFee".to_string(), + json!([ + { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, + { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, + { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, + { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" } + ]), + ); + m + }; + + let setup = setup_test(cartridge_responses, inner_responses).await; + + let tx1 = make_invoke_tx_json(CONTROLLER_ADDRESS); + let tx2 = make_invoke_tx_json(CONTROLLER_ADDRESS); + let tx3 = make_invoke_tx_json(CONTROLLER_ADDRESS); + let params = json!([[tx1, tx2, tx3], [], "latest"]); + let raw = make_rpc_request_str("starknet_estimateFee", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let response = setup.service.call(request).await; + + // Inner service should receive 4 txs: 1 deploy + 3 original. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!( + calls[0].tx_count, + Some(4), + "inner service should receive 4 transactions (1 deploy + 3 original)" + ); + + // Middleware should return 3 zero-fee estimates (one per original tx). + let response_json: serde_json::Value = serde_json::from_str(response.as_json().get()).unwrap(); + let result = response_json.get("result").expect("response should have result"); + let estimates = result.as_array().unwrap(); + assert_eq!(estimates.len(), 3, "response should have 3 estimates for the 3 original txs"); + + for est in estimates { + assert_eq!(est["overall_fee"], "0x0"); + } +} + +// --------------------------------------------------------------------------- +// Group 2: cartridge_addExecuteFromOutside +// --------------------------------------------------------------------------- + +/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns Ok +/// -> is_deployed == true -> returns Ok(()) early -> forwards to inner service. +/// +/// Address 0x1 exists in genesis with a class hash. The middleware checks deployment +/// status via class_hash_at_address, finds it deployed, and short-circuits without +/// querying the Cartridge API or adding any deploy transaction to the pool. +/// +/// Expected: inner service receives request unchanged; pool remains empty; +/// Cartridge API receives no requests. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_skips_deploy_when_already_deployed() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + let params = make_execute_outside_params(DEPLOYER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // Pool should be empty — no deploy tx was added. + assert_eq!(setup.pool.size(), 0, "pool should be empty"); + + // Inner service should have been called (request forwarded). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); + + // Cartridge API should not have been queried. + let api_requests = setup.mock_api_state.received_requests.lock().unwrap(); + assert!(api_requests.is_empty(), "Cartridge API should not have been queried"); +} + +/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns +/// ContractNotFound -> is_deployed == false -> gets deployer nonce +/// -> get_controller_deployment_tx returns Some(tx) -> add_invoke_tx adds +/// deploy tx to pool -> forwards original request to inner service. +/// +/// Address 0xDEAD is not deployed. The Cartridge API returns constructor calldata. +/// The middleware creates a deploy invoke tx signed by the deployer, adds it to the +/// transaction pool, and then forwards the original request to the inner service. +/// +/// Expected: pool.size() == 1 (deploy tx was added); inner service receives request. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_deploys_controller() { + let cartridge_responses = { + let mut m = HashMap::new(); + m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); + m + }; + + let setup = setup_test(cartridge_responses, HashMap::new()).await; + + let params = make_execute_outside_params(CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // A deploy transaction should have been added to the pool. + assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); + + // Inner service should have been called (request forwarded). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); +} + +/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns +/// ContractNotFound -> is_deployed == false -> get_controller_deployment_tx +/// returns None (CartridgeAPI says not a Controller) -> forwards to inner service. +/// +/// Address 0xBEEF is not deployed and the Cartridge API returns "Address not found". +/// The middleware skips deployment since the address is not a Controller, and +/// forwards the original request unchanged. +/// +/// Expected: pool remains empty; inner service receives request. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_skips_deploy_for_non_controller() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + let params = make_execute_outside_params(NON_CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // Pool should be empty — no deploy tx was added. + assert_eq!(setup.pool.size(), 0, "pool should be empty"); + + // Inner service should have been called. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); +} + +/// Code path: Same as execute_outside_deploys_controller but verifies that the +/// alternate method name "cartridge_addExecuteOutsideTransaction" is also +/// intercepted. +/// +/// Expected: Same behavior as execute_outside_deploys_controller — deploy tx added +/// to pool and request forwarded. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_tx_method_variant() { + let cartridge_responses = { + let mut m = HashMap::new(); + m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); + m + }; + + let setup = setup_test(cartridge_responses, HashMap::new()).await; + + let params = make_execute_outside_params(CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteOutsideTransaction", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // A deploy transaction should have been added to the pool. + assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); + + // Inner service should have been called with the alternate method name. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteOutsideTransaction"); +} + +// --------------------------------------------------------------------------- +// Group 3: Passthrough +// --------------------------------------------------------------------------- + +/// Code path: RpcServiceT::call -> method name doesn't match any intercepted method +/// -> falls through to this.service.call(request). +/// +/// A request for "starknet_getBlockNumber" should be forwarded directly to the +/// inner service without any interception or Cartridge API calls. +/// +/// Expected: inner service receives request unchanged; no Cartridge API requests made. +#[tokio::test(flavor = "multi_thread")] +async fn passthrough_other_methods() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + let raw = make_rpc_request_str("starknet_getBlockNumber", &json!([])); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "starknet_getBlockNumber"); + + let api_requests = setup.mock_api_state.received_requests.lock().unwrap(); + assert!(api_requests.is_empty(), "Cartridge API should not have been queried"); +} + +/// Code path: RpcServiceT::call -> method matches STARKNET_ESTIMATE_FEE +/// -> parse_estimate_fee_params returns None (malformed params) +/// -> falls through to this.service.call(request). +/// +/// When starknet_estimateFee is called with params that cannot be deserialized, +/// the middleware gracefully falls through to the inner service rather than erroring. +/// +/// Expected: inner service receives request unchanged. +#[tokio::test(flavor = "multi_thread")] +async fn passthrough_malformed_estimate_fee() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + // Malformed params — not a valid array of transactions. + let raw = make_rpc_request_str("starknet_estimateFee", &json!(["not_valid"])); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // The inner service should have received the request (fallthrough). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "starknet_estimateFee"); +} + +// --------------------------------------------------------------------------- +// Helper to build execute outside params +// --------------------------------------------------------------------------- + +fn make_execute_outside_params(address: &str) -> serde_json::Value { + json!([ + address, + { + "caller": "0x414e595f43414c4c4552", + "nonce": "0x1", + "execute_after": "0x0", + "execute_before": "0xffffffffffffffff", + "calls": [{ + "to": "0x1", + "selector": "0x2", + "calldata": ["0x3"] + }] + }, + ["0x0", "0x0"], + null + ]) +} From b505daf73a7b0ec081a3adef8a70a59908a6402c Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 16:36:58 -0600 Subject: [PATCH 19/32] wip --- .../rpc-server/tests/controller_deployment.rs | 985 +++++++++--------- 1 file changed, 487 insertions(+), 498 deletions(-) diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs index 6dc94df7f..21fe4278f 100644 --- a/crates/rpc/rpc-server/tests/controller_deployment.rs +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -1,9 +1,6 @@ #![cfg(feature = "cartridge")] //! Integration tests for the `ControllerDeploymentService` middleware. -//! -//! These tests cover all code paths in the middleware's `starknet_estimateFee` and -//! `cartridge_addExecuteFromOutside` interception logic. use std::collections::HashMap; use std::future::Future; @@ -36,364 +33,18 @@ use tokio::net::TcpListener; use tower::Layer; use url::Url; -// --------------------------------------------------------------------------- -// Mock types -// --------------------------------------------------------------------------- - -type TestPool = Pool, FiFo>; - -/// A no-op pending block provider. All methods return `Ok(None)`, matching -/// instant-mining mode behaviour. -#[derive(Debug, Clone)] -struct NoPendingBlockProvider; - -impl PendingBlockProvider for NoPendingBlockProvider { - fn pending_state( - &self, - ) -> katana_rpc_server::starknet::StarknetApiResult< - Option>, - > { - Ok(None) - } - - fn get_pending_state_update( - &self, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_block_with_txs( - &self, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_block_with_receipts( - &self, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_block_with_tx_hashes( - &self, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_transaction( - &self, - _hash: katana_primitives::transaction::TxHash, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_receipt( - &self, - _hash: katana_primitives::transaction::TxHash, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_trace( - &self, - _hash: katana_primitives::transaction::TxHash, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } - - fn get_pending_transaction_by_index( - &self, - _index: katana_primitives::transaction::TxNumber, - ) -> katana_rpc_server::starknet::StarknetApiResult> { - Ok(None) - } -} - -// --------------------------------------------------------------------------- -// Mock Cartridge API HTTP server -// --------------------------------------------------------------------------- - -#[derive(Clone)] -struct MockCartridgeApiState { - /// Map from hex address (with "0x" prefix, lowercase) to the response JSON. - responses: Arc>, - /// Log of all requests received. - received_requests: Arc>>, -} - -async fn mock_cartridge_handler( - State(state): State, - axum::Json(body): axum::Json, -) -> impl IntoResponse { - state.received_requests.lock().unwrap().push(body.clone()); - - let address = body.get("address").and_then(|v| v.as_str()).unwrap_or(""); - - if let Some(response) = state.responses.get(address) { - axum::Json(response.clone()).into_response() - } else { - "Address not found".into_response() - } -} - -/// Start a mock Cartridge API server. Returns (base URL, state handle, join handle). -async fn start_mock_cartridge_api( - responses: HashMap, -) -> (Url, MockCartridgeApiState) { - let state = MockCartridgeApiState { - responses: Arc::new(responses), - received_requests: Arc::new(Mutex::new(Vec::new())), - }; - - let app = - Router::new().route("/accounts/calldata", post(mock_cartridge_handler)).with_state(state.clone()); - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let url = Url::parse(&format!("http://{addr}")).unwrap(); - (url, state) -} - -// --------------------------------------------------------------------------- -// Mock inner RPC service -// --------------------------------------------------------------------------- - -/// A recorded call to the mock RPC service. -#[derive(Clone, Debug)] -struct RecordedCall { - method: String, - /// For estimate_fee, how many transactions were in the params. - tx_count: Option, -} - -#[derive(Clone)] -struct MockRpcService { - /// Records all calls. - calls: Arc>>, - /// Pre-configured response JSON per method name. - responses: Arc>, -} - -impl MockRpcService { - fn new(responses: HashMap) -> Self { - Self { calls: Arc::new(Mutex::new(Vec::new())), responses: Arc::new(responses) } - } - - fn recorded_calls(&self) -> Vec { - self.calls.lock().unwrap().clone() - } -} - -impl RpcServiceT for MockRpcService { - type MethodResponse = MethodResponse; - type BatchResponse = MethodResponse; - type NotificationResponse = MethodResponse; - - fn call<'a>( - &self, - request: Request<'a>, - ) -> impl Future + Send + 'a { - let method = request.method_name().to_owned(); - - // Try to count transactions if this is an estimate_fee request. - let params = request.params(); - let tx_count = if method == "starknet_estimateFee" { - // Parse the first param (array of txs) from the sequence params. - let mut seq = params.sequence(); - let txs: Result, _> = seq.next(); - txs.ok().map(|v| v.len()) - } else { - None - }; - - self.calls.lock().unwrap().push(RecordedCall { method: method.clone(), tx_count }); - - let response = if let Some(resp) = self.responses.get(&method) { - MethodResponse::response( - request.id().clone(), - jsonrpsee::ResponsePayload::success(resp.clone()), - usize::MAX, - ) - } else { - MethodResponse::response( - request.id().clone(), - jsonrpsee::ResponsePayload::success(serde_json::Value::Null), - usize::MAX, - ) - }; - - std::future::ready(response) - } - - fn batch<'a>( - &self, - _requests: Batch<'a>, - ) -> impl Future + Send + 'a { - std::future::ready(MethodResponse::response( - jsonrpsee::types::Id::Null, - jsonrpsee::ResponsePayload::success(serde_json::Value::Null), - usize::MAX, - )) - } - - fn notification<'a>( - &self, - _n: Notification<'a>, - ) -> impl Future + Send + 'a { - std::future::ready(MethodResponse::response( - jsonrpsee::types::Id::Null, - jsonrpsee::ResponsePayload::success(serde_json::Value::Null), - usize::MAX, - )) - } -} - -// --------------------------------------------------------------------------- -// Test fixture helpers -// --------------------------------------------------------------------------- - -/// An undeployed address that the mock API will recognize as a Controller. -const CONTROLLER_ADDRESS: &str = "0xdead"; -/// An undeployed address that the mock API will NOT recognize as a Controller. -const NON_CONTROLLER_ADDRESS: &str = "0xbeef"; -/// The deployer address — matches the genesis account at 0x1 in test_provider. -const DEPLOYER_ADDRESS: &str = "0x1"; - -/// Builds a `serde_json::Value` response for the Cartridge API that represents -/// a valid Controller account with some dummy constructor calldata. -fn controller_calldata_response(address: &str) -> serde_json::Value { - json!({ - "address": address, - "username": "testuser", - "calldata": [ - "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", - "0x7465737475736572", - "0x0", - "0x2", - "0x1", - "0x2" - ] - }) -} - -/// Creates a valid V3 invoke transaction JSON for the given sender address. -fn make_invoke_tx_json(sender_address: &str) -> serde_json::Value { - json!({ - "type": "INVOKE", - "version": "0x3", - "sender_address": sender_address, - "calldata": ["0x1"], - "signature": ["0x0"], - "nonce": "0x0", - "resource_bounds": { - "l1_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, - "l2_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, - "l1_data_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" } - }, - "tip": "0x0", - "paymaster_data": [], - "account_deployment_data": [], - "nonce_data_availability_mode": "L1", - "fee_data_availability_mode": "L1" - }) -} - -/// Creates a JSON-RPC 2.0 request string and constructs the corresponding `Request<'_>`. -fn make_rpc_request_str(method: &str, params: &serde_json::Value) -> String { - json!({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params - }) - .to_string() -} - -/// A complete test setup context. -struct TestSetup { - service: as Layer>::Service, - mock_rpc: MockRpcService, - mock_api_state: MockCartridgeApiState, - pool: TestPool, -} - -async fn setup_test( - cartridge_api_responses: HashMap, - inner_rpc_responses: HashMap, -) -> TestSetup { - let (mock_url, mock_api_state) = start_mock_cartridge_api(cartridge_api_responses).await; - - let chain_spec = Arc::new(ChainSpec::dev()); - let pool = Pool::new(NoopValidator::new(), FiFo::new()); - let task_spawner = TaskManager::current().task_spawner(); - let gas_oracle = GasPriceOracle::create_for_testing(); - let storage = test_provider(); - - let config = StarknetApiConfig { - max_event_page_size: None, - max_proof_keys: None, - max_call_gas: None, - max_concurrent_estimate_fee_requests: None, - simulation_flags: ExecutionFlags::new().with_fee(false).with_account_validation(false), - versioned_constant_overrides: None, - }; - - let starknet_api = StarknetApi::new( - chain_spec, - pool.clone(), - task_spawner, - NoPendingBlockProvider, - gas_oracle, - config, - storage, - ); - - let cartridge_api = ::cartridge::CartridgeApiClient::new(mock_url); - - // Create a dummy paymaster HTTP client — pointed at a non-routable address. - // The tested code paths do not use the paymaster client. - let paymaster_client = jsonrpsee::http_client::HttpClientBuilder::default() - .build("http://127.0.0.1:1") - .unwrap(); - - let deployer_address = Felt::from(1u64).into(); - let deployer_private_key = SigningKey::from_secret_scalar(Felt::from(1u64)); - - let layer = ControllerDeploymentLayer::new( - starknet_api, - cartridge_api, - paymaster_client, - deployer_address, - deployer_private_key, - ); - - let mock_rpc = MockRpcService::new(inner_rpc_responses); - let service = layer.layer(mock_rpc.clone()); - - TestSetup { service, mock_rpc, mock_api_state, pool } -} - // --------------------------------------------------------------------------- // Group 1: starknet_estimateFee // --------------------------------------------------------------------------- -/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs returns empty -/// -> deploy_controller_txs.is_empty() == true -> forwards request as-is. +/// ## Case: +/// +/// The sender address 0x1 already exists and requires no extra deployment. /// -/// The sender address 0x1 exists in genesis. The middleware queries the Cartridge API -/// for this address, which returns "Address not found" (not a Controller). Since no -/// Controllers need deployment, the request is forwarded unchanged to the inner service. +/// ## Expected: /// -/// Expected: inner service receives the exact same request; response is passed through. +/// Since no Controllers need deployment, the request is forwarded unchanged +/// and the response is passed through. #[tokio::test(flavor = "multi_thread")] async fn estimate_fee_forwards_when_no_controllers() { let inner_responses = { @@ -435,17 +86,14 @@ async fn estimate_fee_forwards_when_no_controllers() { assert_eq!(result.as_array().unwrap().len(), 1); } -/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs returns 1 deploy tx -/// -> crafts new request with [deploy_tx, original_tx] -> calls inner service -/// -> strips deploy tx result from response -> returns zero-fee estimates -/// via build_no_fee_response. +/// ## Case: /// -/// Address 0xDEAD is not deployed (not in genesis). The Cartridge API returns constructor -/// calldata for it, indicating it IS a Controller. The middleware creates a deploy tx signed -/// by the deployer (0x1), prepends it to the estimate fee request, and after the inner -/// service responds, returns zero-fee estimates for the original tx count only. +/// Address 0xDEAD is not yet deployed and belongs to a Controller account. /// -/// Expected: inner service receives 2 txs; middleware response has 1 zero-fee estimate. +/// ## Expected: +/// +/// The middleware prepends a deploy transaction to the estimate fee +/// request and returns estimates for the original transactions only. #[tokio::test(flavor = "multi_thread")] async fn estimate_fee_prepends_deploy_tx_for_controller() { let cartridge_responses = { @@ -514,15 +162,15 @@ async fn estimate_fee_prepends_deploy_tx_for_controller() { assert_eq!(est["l2_gas_consumed"], "0x0"); } -/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs -/// -> get_controller_deployment_tx calls CartridgeAPI which returns None -/// -> deploy list is empty -> forwards request as-is. +/// ## Case: +/// +/// Address 0xBEEF is not deployed and the Cartridge API does not recognize it as a +/// Controller. /// -/// Address 0xBEEF is not deployed and the Cartridge API returns "Address not found" -/// (not a Controller). Even though the address is undeployed, no deploy tx is created -/// because it's not a recognized Controller address. +/// ## Expected: /// -/// Expected: inner service receives the original request unchanged. +/// Even though the address is undeployed, no deploy transaction is created and the original request +/// is forwarded unchanged. #[tokio::test(flavor = "multi_thread")] async fn estimate_fee_forwards_for_non_controller() { let inner_responses = { @@ -562,16 +210,16 @@ async fn estimate_fee_forwards_for_non_controller() { assert_eq!(result.as_array().unwrap().len(), 1); } -/// Code path: starknet_estimate_fee_inner -> get_controller_deployment_txs -/// -> processes 3 txs from same address -> dedup via processed_addresses -/// -> only 1 deploy tx created -> new request has 4 txs total. +/// ## Case: /// -/// Three invoke txs all from undeployed Controller address 0xDEAD. The middleware -/// deduplicates by tracking processed addresses, creating only one deploy tx despite -/// three txs from the same sender. +/// Three invoke transactions all from undeployed Controller address 0xDEAD. /// -/// Expected: inner service receives 4 txs (1 deploy + 3 original); -/// middleware returns 3 zero-fee estimates. +/// ## Expected: +/// +/// The middleware deduplicates by sender address, creating only one deploy transaction +/// despite three transactions from the same sender. +/// +/// Inner service receives 4 txs (1 deploy + 3 original); middleware returns 3 zero-fee estimates. #[tokio::test(flavor = "multi_thread")] async fn estimate_fee_deduplicates_same_controller() { let cartridge_responses = { @@ -630,15 +278,17 @@ async fn estimate_fee_deduplicates_same_controller() { // Group 2: cartridge_addExecuteFromOutside // --------------------------------------------------------------------------- -/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns Ok -/// -> is_deployed == true -> returns Ok(()) early -> forwards to inner service. +/// ## Case: +/// +/// The sender address (0x1) is already deployed. +/// +/// ## Expected: /// -/// Address 0x1 exists in genesis with a class hash. The middleware checks deployment -/// status via class_hash_at_address, finds it deployed, and short-circuits without -/// querying the Cartridge API or adding any deploy transaction to the pool. +/// The middleware detects this and skips Controller deployment, forwarding the +/// request unchanged without querying the Cartridge API. /// -/// Expected: inner service receives request unchanged; pool remains empty; -/// Cartridge API receives no requests. +/// Inner service receives request unchanged; pool remains empty; Cartridge API receives no +/// requests. #[tokio::test(flavor = "multi_thread")] async fn execute_outside_skips_deploy_when_already_deployed() { let setup = setup_test(HashMap::new(), HashMap::new()).await; @@ -662,16 +312,16 @@ async fn execute_outside_skips_deploy_when_already_deployed() { assert!(api_requests.is_empty(), "Cartridge API should not have been queried"); } -/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns -/// ContractNotFound -> is_deployed == false -> gets deployer nonce -/// -> get_controller_deployment_tx returns Some(tx) -> add_invoke_tx adds -/// deploy tx to pool -> forwards original request to inner service. +/// ## Case: /// -/// Address 0xDEAD is not deployed. The Cartridge API returns constructor calldata. -/// The middleware creates a deploy invoke tx signed by the deployer, adds it to the -/// transaction pool, and then forwards the original request to the inner service. +/// The sender address (0xDEAD) is not deployed and belongs to a Controller account. /// -/// Expected: pool.size() == 1 (deploy tx was added); inner service receives request. +/// ## Expected: +/// +/// The middleware creates a deploy transaction, adds it to the pool, and then forwards +/// the original request to the inner service. +/// +/// Pool contains 1 deploy transaction; inner service receives request. #[tokio::test(flavor = "multi_thread")] async fn execute_outside_deploys_controller() { let cartridge_responses = { @@ -680,138 +330,477 @@ async fn execute_outside_deploys_controller() { m }; - let setup = setup_test(cartridge_responses, HashMap::new()).await; + let setup = setup_test(cartridge_responses, HashMap::new()).await; + + let params = make_execute_outside_params(CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // A deploy transaction should have been added to the pool. + assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); + + // Inner service should have been called (request forwarded). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); +} + +/// ## Case: +/// +/// The sender address (0xBEEF) is not deployed and is not a Controller. +/// +/// ## Expected: +/// +/// The middleware skips deployment and forwards the request unchanged. +/// +/// Pool remains empty; inner service receives request. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_skips_deploy_for_non_controller() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + let params = make_execute_outside_params(NON_CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // Pool should be empty — no deploy tx was added. + assert_eq!(setup.pool.size(), 0, "pool should be empty"); + + // Inner service should have been called. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); +} + +/// ## Case: +/// +/// Same scenario as `execute_outside_deploys_controller` but uses the alternate +/// method name "cartridge_addExecuteOutsideTransaction" to verify both method +/// names are intercepted by the middleware. +/// +/// ## Expected: +/// +/// Deploy transaction added to pool and request forwarded. +#[tokio::test(flavor = "multi_thread")] +async fn execute_outside_tx_method_variant() { + let cartridge_responses = { + let mut m = HashMap::new(); + m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); + m + }; + + let setup = setup_test(cartridge_responses, HashMap::new()).await; + + let params = make_execute_outside_params(CONTROLLER_ADDRESS); + let raw = make_rpc_request_str("cartridge_addExecuteOutsideTransaction", ¶ms); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // A deploy transaction should have been added to the pool. + assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); + + // Inner service should have been called with the alternate method name. + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "cartridge_addExecuteOutsideTransaction"); +} + +// --------------------------------------------------------------------------- +// Group 3: Passthrough +// --------------------------------------------------------------------------- + +/// ## Case: +/// +/// A request for "starknet_getBlockNumber" is not intercepted by the middleware +/// and is forwarded directly to the inner service. +/// +/// ## Expected: +/// +/// inner service receives request unchanged; no Cartridge API requests made. +#[tokio::test(flavor = "multi_thread")] +async fn passthrough_other_methods() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + let raw = make_rpc_request_str("starknet_getBlockNumber", &json!([])); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "starknet_getBlockNumber"); + + let api_requests = setup.mock_api_state.received_requests.lock().unwrap(); + assert!(api_requests.is_empty(), "Cartridge API should not have been queried"); +} + +/// ## Case: +/// +/// When starknet_estimateFee is called with malformed params, the middleware +/// should gracefully falls through to the inner service rather than erroring. +/// +/// ## Expected: +/// +/// Inner service receives request unchanged. +#[tokio::test(flavor = "multi_thread")] +async fn passthrough_malformed_estimate_fee() { + let setup = setup_test(HashMap::new(), HashMap::new()).await; + + // Malformed params — not a valid array of transactions. + let raw = make_rpc_request_str("starknet_estimateFee", &json!(["not_valid"])); + + let request: Request<'_> = serde_json::from_str(&raw).unwrap(); + let _response = setup.service.call(request).await; + + // The inner service should have received the request (fallthrough). + let calls = setup.mock_rpc.recorded_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].method, "starknet_estimateFee"); +} + +// --------------------------------------------------------------------------- +// Test Fixtures +// --------------------------------------------------------------------------- + +type TestPool = + Pool, FiFo>; + +/// A no-op pending block provider. All methods return `Ok(None)`, matching +/// instant-mining mode behaviour. +#[derive(Debug, Clone)] +struct NoPendingBlockProvider; + +impl PendingBlockProvider for NoPendingBlockProvider { + fn pending_state( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult< + Option>, + > { + Ok(None) + } + + fn get_pending_state_update( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_txs( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_receipts( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_block_with_tx_hashes( + &self, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_transaction( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_receipt( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_trace( + &self, + _hash: katana_primitives::transaction::TxHash, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } + + fn get_pending_transaction_by_index( + &self, + _index: katana_primitives::transaction::TxNumber, + ) -> katana_rpc_server::starknet::StarknetApiResult> { + Ok(None) + } +} + +#[derive(Clone)] +struct MockCartridgeApiState { + /// Map from hex address (with "0x" prefix, lowercase) to the response JSON. + responses: Arc>, + /// Log of all requests received. + received_requests: Arc>>, +} + +async fn mock_cartridge_handler( + State(state): State, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + state.received_requests.lock().unwrap().push(body.clone()); + + let address = body.get("address").and_then(|v| v.as_str()).unwrap_or(""); + + if let Some(response) = state.responses.get(address) { + axum::Json(response.clone()).into_response() + } else { + "Address not found".into_response() + } +} + +/// Start a mock Cartridge API server. Returns (base URL, state handle, join handle). +async fn start_mock_cartridge_api( + responses: HashMap, +) -> (Url, MockCartridgeApiState) { + let state = MockCartridgeApiState { + responses: Arc::new(responses), + received_requests: Arc::new(Mutex::new(Vec::new())), + }; + + let app = Router::new() + .route("/accounts/calldata", post(mock_cartridge_handler)) + .with_state(state.clone()); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let url = Url::parse(&format!("http://{addr}")).unwrap(); + (url, state) +} + +// --------------------------------------------------------------------------- +// Mock inner RPC service +// --------------------------------------------------------------------------- + +/// A recorded call to the mock RPC service. +#[derive(Clone, Debug)] +struct RecordedCall { + method: String, + /// For estimate_fee, how many transactions were in the params. + tx_count: Option, +} + +#[derive(Clone)] +struct MockRpcService { + /// Records all calls. + calls: Arc>>, + /// Pre-configured response JSON per method name. + responses: Arc>, +} - let params = make_execute_outside_params(CONTROLLER_ADDRESS); - let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); +impl MockRpcService { + fn new(responses: HashMap) -> Self { + Self { calls: Arc::new(Mutex::new(Vec::new())), responses: Arc::new(responses) } + } - let request: Request<'_> = serde_json::from_str(&raw).unwrap(); - let _response = setup.service.call(request).await; + fn recorded_calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} - // A deploy transaction should have been added to the pool. - assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); +impl RpcServiceT for MockRpcService { + type MethodResponse = MethodResponse; + type BatchResponse = MethodResponse; + type NotificationResponse = MethodResponse; - // Inner service should have been called (request forwarded). - let calls = setup.mock_rpc.recorded_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); -} + fn call<'a>( + &self, + request: Request<'a>, + ) -> impl Future + Send + 'a { + let method = request.method_name().to_owned(); -/// Code path: cartridge_add_execute_from_outside_inner -> class_hash_at_address returns -/// ContractNotFound -> is_deployed == false -> get_controller_deployment_tx -/// returns None (CartridgeAPI says not a Controller) -> forwards to inner service. -/// -/// Address 0xBEEF is not deployed and the Cartridge API returns "Address not found". -/// The middleware skips deployment since the address is not a Controller, and -/// forwards the original request unchanged. -/// -/// Expected: pool remains empty; inner service receives request. -#[tokio::test(flavor = "multi_thread")] -async fn execute_outside_skips_deploy_for_non_controller() { - let setup = setup_test(HashMap::new(), HashMap::new()).await; + // Try to count transactions if this is an estimate_fee request. + let params = request.params(); + let tx_count = if method == "starknet_estimateFee" { + // Parse the first param (array of txs) from the sequence params. + let mut seq = params.sequence(); + let txs: Result, _> = seq.next(); + txs.ok().map(|v| v.len()) + } else { + None + }; - let params = make_execute_outside_params(NON_CONTROLLER_ADDRESS); - let raw = make_rpc_request_str("cartridge_addExecuteFromOutside", ¶ms); + self.calls.lock().unwrap().push(RecordedCall { method: method.clone(), tx_count }); - let request: Request<'_> = serde_json::from_str(&raw).unwrap(); - let _response = setup.service.call(request).await; + let response = if let Some(resp) = self.responses.get(&method) { + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(resp.clone()), + usize::MAX, + ) + } else { + MethodResponse::response( + request.id().clone(), + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + ) + }; - // Pool should be empty — no deploy tx was added. - assert_eq!(setup.pool.size(), 0, "pool should be empty"); + std::future::ready(response) + } - // Inner service should have been called. - let calls = setup.mock_rpc.recorded_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].method, "cartridge_addExecuteFromOutside"); -} + fn batch<'a>( + &self, + _requests: Batch<'a>, + ) -> impl Future + Send + 'a { + std::future::ready(MethodResponse::response( + jsonrpsee::types::Id::Null, + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + )) + } -/// Code path: Same as execute_outside_deploys_controller but verifies that the -/// alternate method name "cartridge_addExecuteOutsideTransaction" is also -/// intercepted. -/// -/// Expected: Same behavior as execute_outside_deploys_controller — deploy tx added -/// to pool and request forwarded. -#[tokio::test(flavor = "multi_thread")] -async fn execute_outside_tx_method_variant() { - let cartridge_responses = { - let mut m = HashMap::new(); - m.insert(CONTROLLER_ADDRESS.to_string(), controller_calldata_response(CONTROLLER_ADDRESS)); - m - }; + fn notification<'a>( + &self, + _n: Notification<'a>, + ) -> impl Future + Send + 'a { + std::future::ready(MethodResponse::response( + jsonrpsee::types::Id::Null, + jsonrpsee::ResponsePayload::success(serde_json::Value::Null), + usize::MAX, + )) + } +} - let setup = setup_test(cartridge_responses, HashMap::new()).await; +/// An undeployed address that the mock API will recognize as a Controller. +const CONTROLLER_ADDRESS: &str = "0xdead"; +/// An undeployed address that the mock API will NOT recognize as a Controller. +const NON_CONTROLLER_ADDRESS: &str = "0xbeef"; +/// The deployer address — matches the genesis account at 0x1 in test_provider. +const DEPLOYER_ADDRESS: &str = "0x1"; - let params = make_execute_outside_params(CONTROLLER_ADDRESS); - let raw = make_rpc_request_str("cartridge_addExecuteOutsideTransaction", ¶ms); +/// Builds a `serde_json::Value` response for the Cartridge API that represents +/// a valid Controller account with some dummy constructor calldata. +fn controller_calldata_response(address: &str) -> serde_json::Value { + json!({ + "address": address, + "username": "testuser", + "calldata": [ + "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", + "0x7465737475736572", + "0x0", + "0x2", + "0x1", + "0x2" + ] + }) +} - let request: Request<'_> = serde_json::from_str(&raw).unwrap(); - let _response = setup.service.call(request).await; +/// Creates a valid V3 invoke transaction JSON for the given sender address. +fn make_invoke_tx_json(sender_address: &str) -> serde_json::Value { + json!({ + "type": "INVOKE", + "version": "0x3", + "sender_address": sender_address, + "calldata": ["0x1"], + "signature": ["0x0"], + "nonce": "0x0", + "resource_bounds": { + "l1_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, + "l2_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" }, + "l1_data_gas": { "max_amount": "0x0", "max_price_per_unit": "0x0" } + }, + "tip": "0x0", + "paymaster_data": [], + "account_deployment_data": [], + "nonce_data_availability_mode": "L1", + "fee_data_availability_mode": "L1" + }) +} - // A deploy transaction should have been added to the pool. - assert_eq!(setup.pool.size(), 1, "pool should contain 1 deploy transaction"); +/// Creates a JSON-RPC 2.0 request string and constructs the corresponding `Request<'_>`. +fn make_rpc_request_str(method: &str, params: &serde_json::Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params + }) + .to_string() +} - // Inner service should have been called with the alternate method name. - let calls = setup.mock_rpc.recorded_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].method, "cartridge_addExecuteOutsideTransaction"); +/// A complete test setup context. +struct TestSetup { + service: as Layer>::Service, + mock_rpc: MockRpcService, + mock_api_state: MockCartridgeApiState, + pool: TestPool, } -// --------------------------------------------------------------------------- -// Group 3: Passthrough -// --------------------------------------------------------------------------- +async fn setup_test( + cartridge_api_responses: HashMap, + inner_rpc_responses: HashMap, +) -> TestSetup { + let (mock_url, mock_api_state) = start_mock_cartridge_api(cartridge_api_responses).await; -/// Code path: RpcServiceT::call -> method name doesn't match any intercepted method -/// -> falls through to this.service.call(request). -/// -/// A request for "starknet_getBlockNumber" should be forwarded directly to the -/// inner service without any interception or Cartridge API calls. -/// -/// Expected: inner service receives request unchanged; no Cartridge API requests made. -#[tokio::test(flavor = "multi_thread")] -async fn passthrough_other_methods() { - let setup = setup_test(HashMap::new(), HashMap::new()).await; + let chain_spec = Arc::new(ChainSpec::dev()); + let pool = Pool::new(NoopValidator::new(), FiFo::new()); + let task_spawner = TaskManager::current().task_spawner(); + let gas_oracle = GasPriceOracle::create_for_testing(); + let storage = test_provider(); - let raw = make_rpc_request_str("starknet_getBlockNumber", &json!([])); + let config = StarknetApiConfig { + max_event_page_size: None, + max_proof_keys: None, + max_call_gas: None, + max_concurrent_estimate_fee_requests: None, + simulation_flags: ExecutionFlags::new().with_fee(false).with_account_validation(false), + versioned_constant_overrides: None, + }; - let request: Request<'_> = serde_json::from_str(&raw).unwrap(); - let _response = setup.service.call(request).await; + let starknet_api = StarknetApi::new( + chain_spec, + pool.clone(), + task_spawner, + NoPendingBlockProvider, + gas_oracle, + config, + storage, + ); - let calls = setup.mock_rpc.recorded_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].method, "starknet_getBlockNumber"); + let cartridge_api = ::cartridge::CartridgeApiClient::new(mock_url); - let api_requests = setup.mock_api_state.received_requests.lock().unwrap(); - assert!(api_requests.is_empty(), "Cartridge API should not have been queried"); -} + // Create a dummy paymaster HTTP client — pointed at a non-routable address. + // The tested code paths do not use the paymaster client. + let paymaster_client = + jsonrpsee::http_client::HttpClientBuilder::default().build("http://127.0.0.1:1").unwrap(); -/// Code path: RpcServiceT::call -> method matches STARKNET_ESTIMATE_FEE -/// -> parse_estimate_fee_params returns None (malformed params) -/// -> falls through to this.service.call(request). -/// -/// When starknet_estimateFee is called with params that cannot be deserialized, -/// the middleware gracefully falls through to the inner service rather than erroring. -/// -/// Expected: inner service receives request unchanged. -#[tokio::test(flavor = "multi_thread")] -async fn passthrough_malformed_estimate_fee() { - let setup = setup_test(HashMap::new(), HashMap::new()).await; + let deployer_address = Felt::from(1u64).into(); + let deployer_private_key = SigningKey::from_secret_scalar(Felt::from(1u64)); - // Malformed params — not a valid array of transactions. - let raw = make_rpc_request_str("starknet_estimateFee", &json!(["not_valid"])); + let layer = ControllerDeploymentLayer::new( + starknet_api, + cartridge_api, + paymaster_client, + deployer_address, + deployer_private_key, + ); - let request: Request<'_> = serde_json::from_str(&raw).unwrap(); - let _response = setup.service.call(request).await; + let mock_rpc = MockRpcService::new(inner_rpc_responses); + let service = layer.layer(mock_rpc.clone()); - // The inner service should have received the request (fallthrough). - let calls = setup.mock_rpc.recorded_calls(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].method, "starknet_estimateFee"); + TestSetup { service, mock_rpc, mock_api_state, pool } } -// --------------------------------------------------------------------------- -// Helper to build execute outside params -// --------------------------------------------------------------------------- - fn make_execute_outside_params(address: &str) -> serde_json::Value { json!([ address, From cb4b1349443e7dffc72903c61fd92f52a94475dd Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 16:40:46 -0600 Subject: [PATCH 20/32] fmt --- crates/rpc/rpc-types/src/outside_execution.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 53983f4f5..1f24fc1d1 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -87,8 +87,8 @@ impl OutsideExecution { pub fn as_felts(&self) -> Vec { match self { - Self::V2(v) => OutsideExecutionV2::cairo_serialize(&v), - Self::V3(v) => OutsideExecutionV3::cairo_serialize(&v), + Self::V2(v) => OutsideExecutionV2::cairo_serialize(v), + Self::V3(v) => OutsideExecutionV3::cairo_serialize(v), } } @@ -100,6 +100,13 @@ impl OutsideExecution { } } + pub fn is_empty(&self) -> bool { + match self { + Self::V2(v) => v.calls.is_empty(), + Self::V3(v) => v.calls.is_empty(), + } + } + pub fn selector(&self) -> Felt { match self { Self::V2(_) => selector!("execute_from_outside_v2"), From 8145b265c3122bc5061d9f00e6a9b45d30fb3f9b Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 16:45:23 -0600 Subject: [PATCH 21/32] wip --- crates/rpc/rpc-server/src/cartridge/vrf.rs | 2 +- crates/rpc/rpc-server/src/middleware/cartridge.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/rpc/rpc-server/src/cartridge/vrf.rs b/crates/rpc/rpc-server/src/cartridge/vrf.rs index 98a017cb6..b217135b0 100644 --- a/crates/rpc/rpc-server/src/cartridge/vrf.rs +++ b/crates/rpc/rpc-server/src/cartridge/vrf.rs @@ -52,7 +52,7 @@ impl VrfService { }; let request = SignedOutsideExecution { - address: address.into(), + address, outside_execution: vrf_outside_execution, signature: signature.to_vec(), }; diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index bfc346c60..0172b6cdd 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -192,7 +192,7 @@ where let response = self.service.call(new_request).await; let res = response.as_json().get(); - let res = serde_json::from_str::>>(res).unwrap(); + let res = serde_json::from_str::>>(res).unwrap(); match res.payload { ResponsePayload::Success(mut estimates) => { @@ -405,7 +405,7 @@ where starknet: self.starknet.clone(), cartridge_api: self.cartridge_api.clone(), paymaster_client: self.paymaster_client.clone(), - deployer_address: self.deployer_address.clone(), + deployer_address: self.deployer_address, deployer_private_key: self.deployer_private_key.clone(), } } @@ -424,7 +424,7 @@ where starknet: self.starknet.clone(), cartridge_api: self.cartridge_api.clone(), paymaster_client: self.paymaster_client.clone(), - deployer_address: self.deployer_address.clone(), + deployer_address: self.deployer_address, deployer_private_key: self.deployer_private_key.clone(), } } From bb2f363a769cb00c8edc450e8c4ffdbdeb7627d4 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Mon, 23 Feb 2026 18:55:53 -0600 Subject: [PATCH 22/32] wip --- .../rpc-server/tests/controller_deployment.rs | 100 +++++++++--------- 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs index 21fe4278f..8143ee480 100644 --- a/crates/rpc/rpc-server/tests/controller_deployment.rs +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -21,7 +21,7 @@ use katana_pool::ordering::FiFo; use katana_pool::pool::Pool; use katana_pool::validation::NoopValidator; use katana_primitives::transaction::ExecutableTxWithHash; -use katana_primitives::Felt; +use katana_primitives::{felt, Felt}; use katana_provider::test_utils::test_provider; use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; use katana_rpc_server::starknet::{PendingBlockProvider, StarknetApi, StarknetApiConfig}; @@ -49,18 +49,17 @@ use url::Url; async fn estimate_fee_forwards_when_no_controllers() { let inner_responses = { let mut m = HashMap::new(); - // Return a valid fee estimate response for 1 transaction. m.insert( "starknet_estimateFee".to_string(), - json!([{ - "l1_gas_consumed": "0x1", - "l1_gas_price": "0x2", - "l2_gas_consumed": "0x3", - "l2_gas_price": "0x4", - "l1_data_gas_consumed": "0x5", - "l1_data_gas_price": "0x6", - "overall_fee": "0x7" - }]), + vec![FeeEstimate { + l1_gas_consumed: felt!("1"), + l1_gas_price: felt!("2"), + l2_gas_consumed: felt!("3"), + l2_gas_price: felt!("4"), + l1_data_gas_consumed: felt!("5"), + l1_data_gas_price: felt!("6"), + overall_fee: felt!("7"), + }], ); m }; @@ -107,26 +106,26 @@ async fn estimate_fee_prepends_deploy_tx_for_controller() { // The inner service will receive 2 txs (1 deploy + 1 original). m.insert( "starknet_estimateFee".to_string(), - json!([ - { - "l1_gas_consumed": "0xa", - "l1_gas_price": "0xb", - "l2_gas_consumed": "0xc", - "l2_gas_price": "0xd", - "l1_data_gas_consumed": "0xe", - "l1_data_gas_price": "0xf", - "overall_fee": "0x10" + vec![ + FeeEstimate { + l1_gas_consumed: felt!("0xa"), + l1_gas_price: felt!("0xb"), + l2_gas_consumed: felt!("0xc"), + l2_gas_price: felt!("0xd"), + l1_data_gas_consumed: felt!("0xe"), + l1_data_gas_price: felt!("0xf"), + overall_fee: felt!("0x10"), }, - { - "l1_gas_consumed": "0x1", - "l1_gas_price": "0x2", - "l2_gas_consumed": "0x3", - "l2_gas_price": "0x4", - "l1_data_gas_consumed": "0x5", - "l1_data_gas_price": "0x6", - "overall_fee": "0x7" - } - ]), + FeeEstimate { + l1_gas_consumed: felt!("1"), + l1_gas_price: felt!("2"), + l2_gas_consumed: felt!("3"), + l2_gas_price: felt!("4"), + l1_data_gas_consumed: felt!("5"), + l1_data_gas_price: felt!("6"), + overall_fee: felt!("7"), + }, + ], ); m }; @@ -177,15 +176,15 @@ async fn estimate_fee_forwards_for_non_controller() { let mut m = HashMap::new(); m.insert( "starknet_estimateFee".to_string(), - json!([{ - "l1_gas_consumed": "0x1", - "l1_gas_price": "0x2", - "l2_gas_consumed": "0x3", - "l2_gas_price": "0x4", - "l1_data_gas_consumed": "0x5", - "l1_data_gas_price": "0x6", - "overall_fee": "0x7" - }]), + vec![FeeEstimate { + l1_gas_consumed: felt!("1"), + l1_gas_price: felt!("2"), + l2_gas_consumed: felt!("3"), + l2_gas_price: felt!("4"), + l1_data_gas_consumed: felt!("5"), + l1_data_gas_price: felt!("6"), + overall_fee: felt!("7"), + }], ); m }; @@ -228,17 +227,22 @@ async fn estimate_fee_deduplicates_same_controller() { m }; + let zero_fee = FeeEstimate { + l1_gas_consumed: felt!("0"), + l1_gas_price: felt!("0"), + l2_gas_consumed: felt!("0"), + l2_gas_price: felt!("0"), + l1_data_gas_consumed: felt!("0"), + l1_data_gas_price: felt!("0"), + overall_fee: felt!("0"), + }; + let inner_responses = { let mut m = HashMap::new(); // Inner service receives 4 txs (1 deploy + 3 original). m.insert( "starknet_estimateFee".to_string(), - json!([ - { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, - { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, - { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" }, - { "l1_gas_consumed": "0x0", "l1_gas_price": "0x0", "l2_gas_consumed": "0x0", "l2_gas_price": "0x0", "l1_data_gas_consumed": "0x0", "l1_data_gas_price": "0x0", "overall_fee": "0x0" } - ]), + vec![zero_fee.clone(), zero_fee.clone(), zero_fee.clone(), zero_fee], ); m }; @@ -600,11 +604,11 @@ struct MockRpcService { /// Records all calls. calls: Arc>>, /// Pre-configured response JSON per method name. - responses: Arc>, + responses: Arc>>, } impl MockRpcService { - fn new(responses: HashMap) -> Self { + fn new(responses: HashMap>) -> Self { Self { calls: Arc::new(Mutex::new(Vec::new())), responses: Arc::new(responses) } } @@ -748,7 +752,7 @@ struct TestSetup { async fn setup_test( cartridge_api_responses: HashMap, - inner_rpc_responses: HashMap, + inner_rpc_responses: HashMap>, ) -> TestSetup { let (mock_url, mock_api_state) = start_mock_cartridge_api(cartridge_api_responses).await; From cb07222adb0ecb7f2021b9a8e91682dc4c0f672b Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Tue, 24 Feb 2026 09:54:24 -0600 Subject: [PATCH 23/32] wip --- .../rpc-server/tests/controller_deployment.rs | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs index 8143ee480..b5c40de60 100644 --- a/crates/rpc/rpc-server/tests/controller_deployment.rs +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -52,13 +52,13 @@ async fn estimate_fee_forwards_when_no_controllers() { m.insert( "starknet_estimateFee".to_string(), vec![FeeEstimate { - l1_gas_consumed: felt!("1"), - l1_gas_price: felt!("2"), - l2_gas_consumed: felt!("3"), - l2_gas_price: felt!("4"), - l1_data_gas_consumed: felt!("5"), - l1_data_gas_price: felt!("6"), - overall_fee: felt!("7"), + l1_gas_consumed: 1, + l1_gas_price: 2, + l2_gas_consumed: 3, + l2_gas_price: 4, + l1_data_gas_consumed: 5, + l1_data_gas_price: 6, + overall_fee: 7, }], ); m @@ -108,22 +108,22 @@ async fn estimate_fee_prepends_deploy_tx_for_controller() { "starknet_estimateFee".to_string(), vec![ FeeEstimate { - l1_gas_consumed: felt!("0xa"), - l1_gas_price: felt!("0xb"), - l2_gas_consumed: felt!("0xc"), - l2_gas_price: felt!("0xd"), - l1_data_gas_consumed: felt!("0xe"), - l1_data_gas_price: felt!("0xf"), - overall_fee: felt!("0x10"), + l1_gas_consumed: 0xa, + l1_gas_price: 0xb, + l2_gas_consumed: 0xc, + l2_gas_price: 0xd, + l1_data_gas_consumed: 0xe, + l1_data_gas_price: 0xf, + overall_fee: 0x10, }, FeeEstimate { - l1_gas_consumed: felt!("1"), - l1_gas_price: felt!("2"), - l2_gas_consumed: felt!("3"), - l2_gas_price: felt!("4"), - l1_data_gas_consumed: felt!("5"), - l1_data_gas_price: felt!("6"), - overall_fee: felt!("7"), + l1_gas_consumed: 1, + l1_gas_price: 2, + l2_gas_consumed: 3, + l2_gas_price: 4, + l1_data_gas_consumed: 5, + l1_data_gas_price: 6, + overall_fee: 7, }, ], ); @@ -177,13 +177,13 @@ async fn estimate_fee_forwards_for_non_controller() { m.insert( "starknet_estimateFee".to_string(), vec![FeeEstimate { - l1_gas_consumed: felt!("1"), - l1_gas_price: felt!("2"), - l2_gas_consumed: felt!("3"), - l2_gas_price: felt!("4"), - l1_data_gas_consumed: felt!("5"), - l1_data_gas_price: felt!("6"), - overall_fee: felt!("7"), + l1_gas_consumed: 1, + l1_gas_price: 2, + l2_gas_consumed: 3, + l2_gas_price: 4, + l1_data_gas_consumed: 5, + l1_data_gas_price: 6, + overall_fee: 7, }], ); m @@ -228,13 +228,13 @@ async fn estimate_fee_deduplicates_same_controller() { }; let zero_fee = FeeEstimate { - l1_gas_consumed: felt!("0"), - l1_gas_price: felt!("0"), - l2_gas_consumed: felt!("0"), - l2_gas_price: felt!("0"), - l1_data_gas_consumed: felt!("0"), - l1_data_gas_price: felt!("0"), - overall_fee: felt!("0"), + l1_gas_consumed: 0, + l1_gas_price: 0, + l2_gas_consumed: 0, + l2_gas_price: 0, + l1_data_gas_consumed: 0, + l1_data_gas_price: 0, + overall_fee: 0, }; let inner_responses = { From b700aeac5f1b1cdc5acf12d9bc3cb8c6cf75b580 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Tue, 24 Feb 2026 13:24:52 -0600 Subject: [PATCH 24/32] Add custom Call serde for outside execution with renamed fields Serialize Call's `contract_address` as `to` and `entry_point_selector` as `selector` in OutsideExecutionV2/V3 using a local serde module, keeping the primitive Call type unchanged. Co-Authored-By: Claude Opus 4.6 --- crates/primitives/src/execution.rs | 2 - crates/rpc/rpc-types/src/outside_execution.rs | 50 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/primitives/src/execution.rs b/crates/primitives/src/execution.rs index ac43c9e63..fc452a700 100644 --- a/crates/primitives/src/execution.rs +++ b/crates/primitives/src/execution.rs @@ -30,10 +30,8 @@ pub type EntryPointSelector = Felt; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Call { /// The address of the contract whose function you're calling. - #[serde(alias = "to")] pub contract_address: ContractAddress, /// The contract function selector. - #[serde(alias = "selector")] pub entry_point_selector: EntryPointSelector, /// The input to the function. pub calldata: Vec, diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 1f24fc1d1..670b11750 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -20,6 +20,54 @@ use katana_primitives::{ContractAddress, Felt}; use serde::{Deserialize, Serialize}; use starknet::macros::selector; +mod calls_serde { + use katana_primitives::execution::Call; + use katana_primitives::{ContractAddress, Felt}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize)] + struct CallRef<'a> { + #[serde(rename = "to")] + contract_address: &'a ContractAddress, + #[serde(rename = "selector")] + entry_point_selector: &'a Felt, + calldata: &'a Vec, + } + + #[derive(Deserialize)] + struct CallDe { + #[serde(rename = "to")] + contract_address: ContractAddress, + #[serde(rename = "selector")] + entry_point_selector: Felt, + calldata: Vec, + } + + pub fn serialize(calls: &Vec, serializer: S) -> Result { + let refs: Vec> = calls + .iter() + .map(|c| CallRef { + contract_address: &c.contract_address, + entry_point_selector: &c.entry_point_selector, + calldata: &c.calldata, + }) + .collect(); + refs.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let items = Vec::::deserialize(deserializer)?; + Ok(items + .into_iter() + .map(|c| Call { + contract_address: c.contract_address, + entry_point_selector: c.entry_point_selector, + calldata: c.calldata, + }) + .collect()) + } +} + /// Nonce channel #[derive(Clone, CairoSerde, PartialEq, Debug, Serialize, Deserialize)] pub struct NonceChannel( @@ -41,6 +89,7 @@ pub struct OutsideExecutionV2 { #[serde(serialize_with = "serialize_as_hex", deserialize_with = "deserialize_from_hex")] pub execute_before: u64, /// Calls to execute in order. + #[serde(with = "calls_serde")] pub calls: Vec, } @@ -58,6 +107,7 @@ pub struct OutsideExecutionV3 { #[serde(serialize_with = "serialize_as_hex", deserialize_with = "deserialize_from_hex")] pub execute_before: u64, /// Calls to execute in order. + #[serde(with = "calls_serde")] pub calls: Vec, } From 4eeb580ee4955cc7679986c8c18fbe9113857ef3 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Tue, 24 Feb 2026 13:27:02 -0600 Subject: [PATCH 25/32] wip --- crates/rpc/rpc-types/src/outside_execution.rs | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 670b11750..13f6745b1 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -20,54 +20,6 @@ use katana_primitives::{ContractAddress, Felt}; use serde::{Deserialize, Serialize}; use starknet::macros::selector; -mod calls_serde { - use katana_primitives::execution::Call; - use katana_primitives::{ContractAddress, Felt}; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - - #[derive(Serialize)] - struct CallRef<'a> { - #[serde(rename = "to")] - contract_address: &'a ContractAddress, - #[serde(rename = "selector")] - entry_point_selector: &'a Felt, - calldata: &'a Vec, - } - - #[derive(Deserialize)] - struct CallDe { - #[serde(rename = "to")] - contract_address: ContractAddress, - #[serde(rename = "selector")] - entry_point_selector: Felt, - calldata: Vec, - } - - pub fn serialize(calls: &Vec, serializer: S) -> Result { - let refs: Vec> = calls - .iter() - .map(|c| CallRef { - contract_address: &c.contract_address, - entry_point_selector: &c.entry_point_selector, - calldata: &c.calldata, - }) - .collect(); - refs.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let items = Vec::::deserialize(deserializer)?; - Ok(items - .into_iter() - .map(|c| Call { - contract_address: c.contract_address, - entry_point_selector: c.entry_point_selector, - calldata: c.calldata, - }) - .collect()) - } -} - /// Nonce channel #[derive(Clone, CairoSerde, PartialEq, Debug, Serialize, Deserialize)] pub struct NonceChannel( @@ -165,6 +117,54 @@ impl OutsideExecution { } } +mod calls_serde { + use katana_primitives::execution::Call; + use katana_primitives::{ContractAddress, Felt}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + #[derive(Serialize)] + struct CallRef<'a> { + #[serde(rename = "to")] + contract_address: &'a ContractAddress, + #[serde(rename = "selector")] + entry_point_selector: &'a Felt, + calldata: &'a Vec, + } + + #[derive(Deserialize)] + struct CallDe { + #[serde(rename = "to")] + contract_address: ContractAddress, + #[serde(rename = "selector")] + entry_point_selector: Felt, + calldata: Vec, + } + + pub fn serialize(calls: &Vec, serializer: S) -> Result { + let refs: Vec> = calls + .iter() + .map(|c| CallRef { + contract_address: &c.contract_address, + entry_point_selector: &c.entry_point_selector, + calldata: &c.calldata, + }) + .collect(); + refs.serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let items = Vec::::deserialize(deserializer)?; + Ok(items + .into_iter() + .map(|c| Call { + contract_address: c.contract_address, + entry_point_selector: c.entry_point_selector, + calldata: c.calldata, + }) + .collect()) + } +} + #[cfg(test)] mod tests { From 118ae02afde09ae0916ed29617023483ac211154 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Tue, 24 Feb 2026 13:34:07 -0600 Subject: [PATCH 26/32] wip --- crates/rpc/rpc-types/src/outside_execution.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 13f6745b1..71219d373 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -7,7 +7,7 @@ //! Based on [SNIP-9](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md). //! //! An important note is that the `execute_from_outside_[v2/v3]` functions are not expecting -//! the serialized enum [`OutsideExecution`] but instead the aQ„ERariant already serialized for the +//! the serialized enum [`OutsideExecution`] but instead the variant already serialized for the //! matching version. //! This is why [`OutsideExecution`] is not deriving `CairoSerde` directly. //! From 55251641a399d4cb1c177e2f3e3b99cd941196c2 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Tue, 24 Feb 2026 13:45:17 -0600 Subject: [PATCH 27/32] wip --- crates/rpc/rpc-types/src/outside_execution.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/rpc-types/src/outside_execution.rs b/crates/rpc/rpc-types/src/outside_execution.rs index 71219d373..ff2113db3 100644 --- a/crates/rpc/rpc-types/src/outside_execution.rs +++ b/crates/rpc/rpc-types/src/outside_execution.rs @@ -140,7 +140,7 @@ mod calls_serde { calldata: Vec, } - pub fn serialize(calls: &Vec, serializer: S) -> Result { + pub fn serialize(calls: &[Call], serializer: S) -> Result { let refs: Vec> = calls .iter() .map(|c| CallRef { From c9838b1bbdfe434e665bbd70e2d741a3d9c61494 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 25 Feb 2026 17:15:15 -0600 Subject: [PATCH 28/32] rpc: extract controller deployment middleware context --- .../rpc-server/src/middleware/cartridge.rs | 95 +++++++------------ 1 file changed, 34 insertions(+), 61 deletions(-) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 0172b6cdd..53dcc99b0 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -36,8 +36,8 @@ const STARKNET_ESTIMATE_FEE: &str = "starknet_estimateFee"; const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE: &str = "cartridge_addExecuteFromOutside"; const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX: &str = "cartridge_addExecuteOutsideTransaction"; -#[derive(Debug)] -pub struct ControllerDeploymentLayer +#[derive(Debug, Clone)] +struct ControllerDeploymentContext where Pool: TransactionPool + 'static, PP: PendingBlockProvider, @@ -50,6 +50,16 @@ where deployer_private_key: SigningKey, } +#[derive(Debug, Clone)] +pub struct ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + context: ControllerDeploymentContext, +} + impl ControllerDeploymentLayer where Pool: TransactionPool + 'static, @@ -63,7 +73,15 @@ where deployer_address: ContractAddress, deployer_private_key: SigningKey, ) -> Self { - Self { starknet, cartridge_api, paymaster_client, deployer_address, deployer_private_key } + let context = ControllerDeploymentContext { + starknet, + cartridge_api, + paymaster_client, + deployer_address, + deployer_private_key, + }; + + Self { context } } } @@ -78,29 +96,18 @@ where type Service = ControllerDeploymentService; fn layer(&self, inner: S) -> Self::Service { - ControllerDeploymentService { - service: inner, - starknet: self.starknet.clone(), - cartridge_api: self.cartridge_api.clone(), - paymaster_client: self.paymaster_client.clone(), - deployer_address: self.deployer_address, - deployer_private_key: self.deployer_private_key.clone(), - } + ControllerDeploymentService { context: self.context.clone(), service: inner } } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ControllerDeploymentService where Pool: TransactionPool, PP: PendingBlockProvider, PF: ProviderFactory, { - starknet: StarknetApi, - cartridge_api: CartridgeApiClient, - paymaster_client: HttpClient, - deployer_address: ContractAddress, - deployer_private_key: SigningKey, + context: ControllerDeploymentContext, service: S, } @@ -163,7 +170,8 @@ where undeployed_addresses.push(address); } - let deployer_nonce = self.starknet.nonce_at(block_id, self.deployer_address).await.unwrap(); + let deployer_nonce = + self.context.starknet.nonce_at(block_id, self.context.deployer_address).await.unwrap(); let deploy_controller_txs = self.get_controller_deployment_txs(undeployed_addresses, deployer_nonce).await.unwrap(); @@ -213,7 +221,7 @@ where let block_id = BlockIdOrTag::PreConfirmed; // check if the address has already been deployed. - let is_deployed = match self.starknet.class_hash_at_address(block_id, address).await { + let is_deployed = match self.context.starknet.class_hash_at_address(block_id, address).await { Ok(..) => true, Err(StarknetApiError::ContractNotFound) => false, @@ -228,7 +236,7 @@ where return Ok(()); } - let result = self.starknet.nonce_at(block_id, self.deployer_address).await; + let result = self.context.starknet.nonce_at(block_id, self.context.deployer_address).await; let nonce = match result { Ok(nonce) => nonce, Err(e) => { @@ -248,7 +256,7 @@ where // None means the address is not of a Controller if let Some(tx) = deploy_tx { - if let Err(e) = self.starknet.add_invoke_tx(tx).await { + if let Err(e) = self.context.starknet.add_invoke_tx(tx).await { return Err(CartridgeApiError::ControllerDeployment { reason: format!("failed to submit deployment tx: {e}"), }); @@ -292,7 +300,8 @@ where address: ContractAddress, paymaster_nonce: Nonce, ) -> Result, Error> { - let Some(ctor_calldata) = self.cartridge_api.get_account_calldata(address).await? else { + let Some(ctor_calldata) = self.context.cartridge_api.get_account_calldata(address).await? + else { // this means no controller with the given address return Ok(None); }; @@ -304,7 +313,7 @@ where }; let mut tx = BroadcastedInvokeTx { - sender_address: self.deployer_address, + sender_address: self.context.deployer_address, calldata: encode_calls(vec![call]), signature: Vec::new(), nonce: paymaster_nonce, @@ -318,11 +327,11 @@ where }; let signature = { - let chain = self.starknet.chain_id(); + let chain = self.context.starknet.chain_id(); let tx = BroadcastedTx::Invoke(tx.clone()); let tx = BroadcastedTxWithChainId { tx, chain: chain.into() }; - let signer = LocalWallet::from(self.deployer_private_key.clone()); + let signer = LocalWallet::from(self.context.deployer_private_key.clone()); let tx_hash = tx.calculate_hash(); signer.sign_hash(&tx_hash).await.map_err(Error::SigningError)? @@ -394,42 +403,6 @@ where } } -impl Clone for ControllerDeploymentLayer -where - Pool: TransactionPool, - PP: PendingBlockProvider, - PF: ProviderFactory, -{ - fn clone(&self) -> Self { - Self { - starknet: self.starknet.clone(), - cartridge_api: self.cartridge_api.clone(), - paymaster_client: self.paymaster_client.clone(), - deployer_address: self.deployer_address, - deployer_private_key: self.deployer_private_key.clone(), - } - } -} - -impl Clone for ControllerDeploymentService -where - S: Clone, - Pool: TransactionPool, - PP: PendingBlockProvider, - PF: ProviderFactory, -{ - fn clone(&self) -> Self { - Self { - service: self.service.clone(), - starknet: self.starknet.clone(), - cartridge_api: self.cartridge_api.clone(), - paymaster_client: self.paymaster_client.clone(), - deployer_address: self.deployer_address, - deployer_private_key: self.deployer_private_key.clone(), - } - } -} - #[derive(Debug, thiserror::Error)] pub enum Error { #[error("cartridge api error: {0}")] From 6a3843918e8f24b5754296c5e7d92f845d120d7b Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 25 Feb 2026 17:16:07 -0600 Subject: [PATCH 29/32] rpc: remove unused controller deployment paymaster plumbing --- crates/node/sequencer/src/lib.rs | 2 -- .../rpc-server/src/middleware/cartridge.rs | 27 ++----------------- .../rpc-server/tests/controller_deployment.rs | 6 ----- 3 files changed, 2 insertions(+), 33 deletions(-) diff --git a/crates/node/sequencer/src/lib.rs b/crates/node/sequencer/src/lib.rs index f9921d694..d49f152a9 100755 --- a/crates/node/sequencer/src/lib.rs +++ b/crates/node/sequencer/src/lib.rs @@ -308,7 +308,6 @@ where let controller_deployment_layer = if let Some(cfg) = &config.paymaster { if let Some(cartridge_api_cfg) = &cfg.cartridge_api { use anyhow::ensure; - use katana_rpc_client::HttpClient; use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; ensure!( @@ -356,7 +355,6 @@ where Some(ControllerDeploymentLayer::new( starknet_api.clone(), cartridge_api_client, - HttpClient::builder().build(cfg.url.clone())?, cartridge_api_cfg.controller_deployer_address, SigningKey::from_secret_scalar( cartridge_api_cfg.controller_deployer_private_key, diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 53dcc99b0..07f3b3809 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -1,15 +1,13 @@ use std::borrow::Cow; use std::future::Future; -use cartridge::vrf::VrfClientError; use cartridge::CartridgeApiClient; use jsonrpsee::core::middleware::{Batch, Notification, RpcServiceT}; use jsonrpsee::core::traits::ToRpcParams; -use jsonrpsee::http_client::HttpClient; use jsonrpsee::types::{ErrorObjectOwned, Request, Response, ResponsePayload}; use jsonrpsee::{rpc_params, MethodResponse}; use katana_genesis::constant::DEFAULT_UDC_ADDRESS; -use katana_pool::api::{PoolError, TransactionPool}; +use katana_pool::api::TransactionPool; use katana_primitives::block::BlockIdOrTag; use katana_primitives::contract::Nonce; use katana_primitives::da::DataAvailabilityMode; @@ -45,7 +43,6 @@ where { starknet: StarknetApi, cartridge_api: CartridgeApiClient, - paymaster_client: HttpClient, deployer_address: ContractAddress, deployer_private_key: SigningKey, } @@ -69,14 +66,12 @@ where pub fn new( starknet: StarknetApi, cartridge_api: CartridgeApiClient, - paymaster_client: HttpClient, deployer_address: ContractAddress, deployer_private_key: SigningKey, ) -> Self { let context = ControllerDeploymentContext { starknet, cartridge_api, - paymaster_client, deployer_address, deployer_private_key, }; @@ -408,26 +403,8 @@ pub enum Error { #[error("cartridge api error: {0}")] Client(#[from] cartridge::api::Error), - #[error("provider error: {0}")] - Provider(#[from] katana_provider::api::ProviderError), - - #[error("paymaster not found")] - PaymasterNotFound(ContractAddress), - - #[error("VRF error: {0}")] - Vrf(String), - - #[error("failed to sign with paymaster: {0}")] + #[error("failed to sign deploy transaction: {0}")] SigningError(SignError), - - #[error("failed to add deploy controller transaction to the pool: {0}")] - FailedToAddTransaction(#[from] PoolError), -} - -impl From for Error { - fn from(e: VrfClientError) -> Self { - Error::Vrf(e.to_string()) - } } #[allow(dead_code)] diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs index b5c40de60..04b00abdc 100644 --- a/crates/rpc/rpc-server/tests/controller_deployment.rs +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -783,18 +783,12 @@ async fn setup_test( let cartridge_api = ::cartridge::CartridgeApiClient::new(mock_url); - // Create a dummy paymaster HTTP client — pointed at a non-routable address. - // The tested code paths do not use the paymaster client. - let paymaster_client = - jsonrpsee::http_client::HttpClientBuilder::default().build("http://127.0.0.1:1").unwrap(); - let deployer_address = Felt::from(1u64).into(); let deployer_private_key = SigningKey::from_secret_scalar(Felt::from(1u64)); let layer = ControllerDeploymentLayer::new( starknet_api, cartridge_api, - paymaster_client, deployer_address, deployer_private_key, ); From e24a554784cf71fb18260886f249ddfd9f7497a7 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Wed, 25 Feb 2026 17:34:33 -0600 Subject: [PATCH 30/32] rpc: refactor controller deployment middleware flow --- .../rpc-server/src/middleware/cartridge.rs | 271 ++++++++++-------- 1 file changed, 151 insertions(+), 120 deletions(-) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 07f3b3809..7cd374d82 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashSet; use std::future::Future; use cartridge::CartridgeApiClient; @@ -19,6 +20,7 @@ use katana_rpc_api::error::cartridge::CartridgeApiError; use katana_rpc_api::error::starknet::StarknetApiError; use katana_rpc_types::broadcasted::{BroadcastedTx, BroadcastedTxWithChainId}; use katana_rpc_types::{BroadcastedInvokeTx, FeeEstimate, FeeSource, OutsideExecution}; +use serde::de::DeserializeOwned; use serde::Deserialize; use starknet::core::types::SimulationFlagForEstimateFee; use starknet::macros::selector; @@ -116,11 +118,41 @@ where PF: ProviderFactory, ::Provider: ProviderRO, { - // if `handle_estimate_fees` has added some new transactions at the - // beginning of updated_txs, we have to remove - // extras results from estimate_fees to be - // sure to return the same number of result than the number - // of transactions in the request. + fn controller_deployment_error(reason: impl Into) -> CartridgeApiError { + CartridgeApiError::ControllerDeployment { reason: reason.into() } + } + + fn estimate_fee_candidate_addresses(transactions: &[BroadcastedTx]) -> Vec { + transactions + .iter() + .filter_map(|tx| match tx { + BroadcastedTx::Invoke(tx) => Some(tx.sender_address), + BroadcastedTx::Declare(tx) => Some(tx.sender_address), + _ => None, + }) + .collect() + } + + fn build_estimate_fee_request<'a>( + request: &Request<'a>, + transactions: Vec, + simulation_flags: Vec, + block_id: BlockIdOrTag, + ) -> Result, CartridgeApiError> { + let params = rpc_params!(transactions, simulation_flags, block_id); + let params = params.to_rpc_params().map_err(|err| { + Self::controller_deployment_error(format!( + "failed to serialize augmented estimateFee params: {err}" + )) + })?; + + let mut new_request = request.clone(); + new_request.params = params.map(Cow::Owned); + + Ok(new_request) + } + + // If deployment txs are added, return the no-fee estimates for the original requests only. async fn starknet_estimate_fee<'a>( &self, params: EstimateFeeParams, @@ -151,24 +183,20 @@ where request: Request<'a>, ) -> Result { let EstimateFeeParams { block_id, simulation_flags, transactions } = params; - - let mut undeployed_addresses: Vec = Vec::new(); - - // iterate thru all txs and deploy any undeployed contract (if they are a Controller) - for tx in &transactions { - let address = match tx { - BroadcastedTx::Invoke(tx) => tx.sender_address, - BroadcastedTx::Declare(tx) => tx.sender_address, - _ => continue, - }; - - undeployed_addresses.push(address); - } - - let deployer_nonce = - self.context.starknet.nonce_at(block_id, self.context.deployer_address).await.unwrap(); - let deploy_controller_txs = - self.get_controller_deployment_txs(undeployed_addresses, deployer_nonce).await.unwrap(); + let candidate_addresses = Self::estimate_fee_candidate_addresses(&transactions); + + let deployer_nonce = self + .context + .starknet + .nonce_at(block_id, self.context.deployer_address) + .await + .map_err(|err| { + Self::controller_deployment_error(format!("failed to get deployer nonce: {err}")) + })?; + let deploy_controller_txs = self + .get_controller_deployment_txs(candidate_addresses, deployer_nonce) + .await + .map_err(|err| Self::controller_deployment_error(err.to_string()))?; // no Controller to deploy, simply forward the request if deploy_controller_txs.is_empty() { @@ -176,31 +204,30 @@ where } let original_txs_count = transactions.len(); - let deploy_controller_txs_count = deploy_controller_txs.len(); - let new_txs = [deploy_controller_txs, transactions].concat(); let new_txs_count = new_txs.len(); - - // craft a new estimate fee request with the deploy Controller txs included - let new_request = { - let params = rpc_params!(new_txs, simulation_flags, block_id); - let params = params.to_rpc_params().unwrap(); - - let mut new_request = request.clone(); - new_request.params = params.map(Cow::Owned); - - new_request - }; + let new_request = + Self::build_estimate_fee_request(&request, new_txs, simulation_flags, block_id)?; let response = self.service.call(new_request).await; - - let res = response.as_json().get(); - let res = serde_json::from_str::>>(res).unwrap(); + let response_body = response.as_json().get(); + let res = serde_json::from_str::>>(response_body).map_err( + |err| { + Self::controller_deployment_error(format!( + "failed to parse estimateFee response: {err}" + )) + }, + )?; match res.payload { - ResponsePayload::Success(mut estimates) => { - assert_eq!(estimates.len(), new_txs_count); - estimates.to_mut().drain(0..deploy_controller_txs_count); + ResponsePayload::Success(estimates) => { + if estimates.len() != new_txs_count { + return Err(Self::controller_deployment_error(format!( + "unexpected estimateFee response length: expected {new_txs_count}, got {}", + estimates.len() + ))); + } + Ok(build_no_fee_response(&request, original_txs_count)) } @@ -216,7 +243,8 @@ where let block_id = BlockIdOrTag::PreConfirmed; // check if the address has already been deployed. - let is_deployed = match self.context.starknet.class_hash_at_address(block_id, address).await { + let is_deployed = match self.context.starknet.class_hash_at_address(block_id, address).await + { Ok(..) => true, Err(StarknetApiError::ContractNotFound) => false, @@ -231,23 +259,18 @@ where return Ok(()); } - let result = self.context.starknet.nonce_at(block_id, self.context.deployer_address).await; - let nonce = match result { - Ok(nonce) => nonce, - Err(e) => { - return Err(CartridgeApiError::ControllerDeployment { - reason: format!("failed to get deployer nonce: {e}"), - }); - } - }; - - let result = self.get_controller_deployment_tx(address, nonce).await; - let deploy_tx = match result { - Ok(tx) => tx, - Err(e) => { - return Err(CartridgeApiError::ControllerDeployment { reason: e.to_string() }); - } - }; + let nonce = self + .context + .starknet + .nonce_at(block_id, self.context.deployer_address) + .await + .map_err(|err| { + Self::controller_deployment_error(format!("failed to get deployer nonce: {err}")) + })?; + let deploy_tx = self + .get_controller_deployment_tx(address, nonce) + .await + .map_err(|err| Self::controller_deployment_error(err.to_string()))?; // None means the address is not of a Controller if let Some(tx) = deploy_tx { @@ -263,15 +286,15 @@ where async fn get_controller_deployment_txs( &self, - controller_addreses: Vec, + controller_addresses: Vec, initial_nonce: Nonce, ) -> Result, Error> { let mut deploy_transactions: Vec = Vec::new(); - let mut processed_addresses: Vec = Vec::new(); + let mut processed_addresses: HashSet = HashSet::new(); let mut deployer_nonce = initial_nonce; - for address in controller_addreses { + for address in controller_addresses { // If the address has already been processed in this txs batch, just skip. if processed_addresses.contains(&address) { continue; @@ -282,7 +305,7 @@ where // None means the address is not a Controller if let Some(tx) = deploy_tx { deployer_nonce += Nonce::ONE; - processed_addresses.push(address); + processed_addresses.insert(address); deploy_transactions.push(BroadcastedTx::Invoke(tx)); } } @@ -426,73 +449,81 @@ struct EstimateFeeParams { block_id: BlockIdOrTag, } -fn parse_execute_outside_params(request: &Request<'_>) -> Option { - let params = request.params(); - - if params.is_object() { - match params.parse() { - Ok(p) => Some(p), - Err(..) => { - debug!(target: "cartridge", "Failed to parse execute outside params."); - None - } - } - } else { - let mut seq = params.sequence(); - - let address: Result = seq.next(); - let outside_execution: Result = seq.next(); - let signature: Result, _> = seq.next(); - let fee_source: Result, _> = seq.next(); - - match (address, outside_execution, signature) { - (Ok(address), Ok(outside_execution), Ok(signature)) => Some(AddExecuteOutsideParams { - address, - outside_execution, - signature, - fee_source: fee_source.ok().flatten(), - }), - _ => { - debug!(target: "cartridge", "Failed to parse execute outside params."); - None - } +#[derive(Deserialize)] +struct AddExecuteOutsidePositionalParams( + ContractAddress, + OutsideExecution, + Vec, + #[serde(default)] Option, +); + +#[derive(Deserialize)] +#[serde(untagged)] +enum AddExecuteOutsideRequestParams { + Named(AddExecuteOutsideParams), + Positional(AddExecuteOutsidePositionalParams), +} + +impl From for AddExecuteOutsideParams { + fn from(value: AddExecuteOutsideRequestParams) -> Self { + match value { + AddExecuteOutsideRequestParams::Named(params) => params, + AddExecuteOutsideRequestParams::Positional(params) => Self { + address: params.0, + outside_execution: params.1, + signature: params.2, + fee_source: params.3, + }, } } } -/// Extract estimate_fee parameters from the request. -fn parse_estimate_fee_params(request: &Request<'_>) -> Option { - let params = request.params(); - - if params.is_object() { - match params.parse() { - Ok(p) => Some(p), - Err(..) => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None - } - } - } else { - let mut seq = params.sequence(); +#[derive(Deserialize)] +struct EstimateFeePositionalParams( + Vec, + Vec, + BlockIdOrTag, +); - let txs_result: Result, _> = seq.next(); - let simulation_flags_result: Result, _> = seq.next(); - let block_id_result: Result = seq.next(); +#[derive(Deserialize)] +#[serde(untagged)] +enum EstimateFeeRequestParams { + Named(EstimateFeeParams), + Positional(EstimateFeePositionalParams), +} - match (txs_result, simulation_flags_result, block_id_result) { - (Ok(txs), Ok(simulation_flags), Ok(block_id)) => { - Some(EstimateFeeParams { transactions: txs, simulation_flags, block_id }) - } - _ => { - debug!(target: "cartridge", "Failed to parse estimate fee params."); - None +impl From for EstimateFeeParams { + fn from(value: EstimateFeeRequestParams) -> Self { + match value { + EstimateFeeRequestParams::Named(params) => params, + EstimateFeeRequestParams::Positional(params) => { + Self { transactions: params.0, simulation_flags: params.1, block_id: params.2 } } } } } -// <--- TODO: this function should be removed once estimateFee will return 0 fees -// when --dev.no-fee is used. +fn parse_params(request: &Request<'_>, method: &str) -> Option { + match request.params().parse() { + Ok(params) => Some(params), + Err(..) => { + debug!(target: "cartridge", "Failed to parse {method} params."); + None + } + } +} + +fn parse_execute_outside_params(request: &Request<'_>) -> Option { + parse_params::(request, "execute outside").map(Into::into) +} + +/// Extract estimate_fee parameters from the request. +fn parse_estimate_fee_params(request: &Request<'_>) -> Option { + parse_params::(request, "estimate fee").map(Into::into) +} + +// Temporary shim for --dev.no-fee when deployment txs are prepended for controllers. +// Remove once starknet_estimateFee natively returns zeroed fees in this scenario. fn build_no_fee_response(request: &Request<'_>, count: usize) -> MethodResponse { let estimate_fees = vec![ FeeEstimate { From 5b162f88aa175cb1855757cdce9f32628712ebe5 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 26 Feb 2026 12:46:47 -0600 Subject: [PATCH 31/32] rpc-server: fix clone bounds and txpool trait imports --- .../rpc-server/src/middleware/cartridge.rs | 49 +++++++++++++++++-- crates/rpc/rpc-server/src/txpool.rs | 2 +- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/rpc/rpc-server/src/middleware/cartridge.rs b/crates/rpc/rpc-server/src/middleware/cartridge.rs index 7cd374d82..23361379b 100644 --- a/crates/rpc/rpc-server/src/middleware/cartridge.rs +++ b/crates/rpc/rpc-server/src/middleware/cartridge.rs @@ -36,7 +36,7 @@ const STARKNET_ESTIMATE_FEE: &str = "starknet_estimateFee"; const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE: &str = "cartridge_addExecuteFromOutside"; const CARTRIDGE_ADD_EXECUTE_FROM_OUTSIDE_TX: &str = "cartridge_addExecuteOutsideTransaction"; -#[derive(Debug, Clone)] +#[derive(Debug)] struct ControllerDeploymentContext where Pool: TransactionPool + 'static, @@ -49,7 +49,23 @@ where deployer_private_key: SigningKey, } -#[derive(Debug, Clone)] +impl Clone for ControllerDeploymentContext +where + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + fn clone(&self) -> Self { + Self { + starknet: self.starknet.clone(), + cartridge_api: self.cartridge_api.clone(), + deployer_address: self.deployer_address, + deployer_private_key: self.deployer_private_key.clone(), + } + } +} + +#[derive(Debug)] pub struct ControllerDeploymentLayer where Pool: TransactionPool + 'static, @@ -97,10 +113,10 @@ where } } -#[derive(Debug, Clone)] +#[derive(Debug)] pub struct ControllerDeploymentService where - Pool: TransactionPool, + Pool: TransactionPool + 'static, PP: PendingBlockProvider, PF: ProviderFactory, { @@ -379,7 +395,7 @@ where &self, request: Request<'a>, ) -> impl Future + Send + 'a { - let this = self.clone(); + let this = (*self).clone(); async move { let method = request.method_name(); @@ -421,6 +437,29 @@ where } } +impl Clone for ControllerDeploymentLayer +where + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + fn clone(&self) -> Self { + Self { context: self.context.clone() } + } +} + +impl Clone for ControllerDeploymentService +where + S: Clone, + Pool: TransactionPool + 'static, + PP: PendingBlockProvider, + PF: ProviderFactory, +{ + fn clone(&self) -> Self { + Self { context: self.context.clone(), service: self.service.clone() } + } +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("cartridge api error: {0}")] diff --git a/crates/rpc/rpc-server/src/txpool.rs b/crates/rpc/rpc-server/src/txpool.rs index 324789668..9abb55c0b 100644 --- a/crates/rpc/rpc-server/src/txpool.rs +++ b/crates/rpc/rpc-server/src/txpool.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use jsonrpsee::core::{async_trait, RpcResult}; -use katana_pool::{PoolTransaction, TransactionPool}; +use katana_pool::api::{PoolTransaction, TransactionPool}; use katana_primitives::ContractAddress; use katana_rpc_api::txpool::TxPoolApiServer; use katana_rpc_types::txpool::{TxPoolContent, TxPoolInspect, TxPoolStatus, TxPoolTransaction}; From 712e1739cb08fdb4cd78f79c5654b81710d75ef5 Mon Sep 17 00:00:00 2001 From: Ammar Arif Date: Thu, 26 Feb 2026 14:21:07 -0600 Subject: [PATCH 32/32] rpc-server tests: fix pool trait imports and clean unused import --- crates/rpc/rpc-server/tests/controller_deployment.rs | 2 +- crates/rpc/rpc-server/tests/txpool.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rpc/rpc-server/tests/controller_deployment.rs b/crates/rpc/rpc-server/tests/controller_deployment.rs index 04b00abdc..256ce2c05 100644 --- a/crates/rpc/rpc-server/tests/controller_deployment.rs +++ b/crates/rpc/rpc-server/tests/controller_deployment.rs @@ -21,7 +21,7 @@ use katana_pool::ordering::FiFo; use katana_pool::pool::Pool; use katana_pool::validation::NoopValidator; use katana_primitives::transaction::ExecutableTxWithHash; -use katana_primitives::{felt, Felt}; +use katana_primitives::Felt; use katana_provider::test_utils::test_provider; use katana_rpc_server::middleware::cartridge::ControllerDeploymentLayer; use katana_rpc_server::starknet::{PendingBlockProvider, StarknetApi, StarknetApiConfig}; diff --git a/crates/rpc/rpc-server/tests/txpool.rs b/crates/rpc/rpc-server/tests/txpool.rs index de4f17093..2aa41b773 100644 --- a/crates/rpc/rpc-server/tests/txpool.rs +++ b/crates/rpc/rpc-server/tests/txpool.rs @@ -1,7 +1,7 @@ +use katana_pool::api::{PoolTransaction, TransactionPool}; use katana_pool::ordering::FiFo; use katana_pool::pool::Pool; use katana_pool::validation::NoopValidator; -use katana_pool::{PoolTransaction, TransactionPool}; use katana_primitives::contract::{ContractAddress, Nonce}; use katana_primitives::transaction::TxHash; use katana_primitives::Felt;