From 658fd6c5dd32a686fb3eea64a3790638ae4e2cfd Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Fri, 30 Jan 2026 15:01:58 -0500 Subject: [PATCH 1/7] Use simulation transfer events for gas funding validation --- crates/paymaster-execution/src/error.rs | 3 + .../src/execution/execute.rs | 251 +++++++++++++++++- crates/paymaster-starknet/src/lib.rs | 18 +- 3 files changed, 257 insertions(+), 15 deletions(-) diff --git a/crates/paymaster-execution/src/error.rs b/crates/paymaster-execution/src/error.rs index 3ad150fa..e0fc2e7a 100644 --- a/crates/paymaster-execution/src/error.rs +++ b/crates/paymaster-execution/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { #[error("invalid typed data")] InvalidTypedData, + #[error("missing gas fee transfer event")] + MissingGasFeeTransferEvent, + #[error("max amount of gas token too low. Expected at least {0}")] MaxAmountTooLow(String), diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index fd4c8356..069ad19d 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -1,7 +1,10 @@ use paymaster_prices::math::convert_strk_to_token; -use paymaster_starknet::transaction::{CalldataBuilder, Calls, EstimatedCalls, ExecuteFromOutsideMessage, SequentialCalldataDecoder, TokenTransfer}; +use paymaster_starknet::transaction::{ + CalldataBuilder, Calls, EstimatedCalls, ExecuteFromOutsideMessage, SequentialCalldataDecoder, TokenTransfer, TransactionGasEstimate, +}; use paymaster_starknet::Signature; -use starknet::core::types::{Call, Felt, InvokeTransactionResult, TypedData}; +use starknet::accounts::Account; +use starknet::core::types::{Call, ExecuteInvocation, Felt, FunctionInvocation, InvokeTransactionResult, SimulatedTransaction, TransactionTrace, TypedData}; use starknet::macros::selector; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -205,35 +208,46 @@ impl ExecutableTransaction { } pub async fn estimate_transaction(self, client: &Client) -> Result { - let transfer = match &self.transaction { - ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, - ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, - ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, + match &self.transaction { + ExecutableTransactionParameters::Invoke { .. } + | ExecutableTransactionParameters::DeployAndInvoke { .. } + | ExecutableTransactionParameters::DirectInvoke { .. } => {}, _ => return Err(Error::InvalidTypedData), }; - let calls = self.build_calls(transfer); + let gas_token = self.parameters.gas_token(); - let estimated_calls = client.estimate(&calls, self.parameters.tip()).await?; - let fee_estimate = estimated_calls.estimate(); + let tip = client.get_tip(self.parameters.tip()).await?; + let estimate_account = client.estimate_account.address(); + let nonce = client.starknet.fetch_nonce(estimate_account).await?; + + let probe_transfer = TokenTransfer::new(gas_token, self.gas_tank_address, Felt::ZERO); + let probe_calls = self.build_calls(probe_transfer); + let probe_tx = probe_calls.as_transaction(estimate_account, nonce, tip); + + let simulated = client.starknet.simulate_transaction(&probe_tx).await?; + let funded_amount = extract_gas_token_transfer_from_simulation(&simulated, gas_token, self.forwarder)?; + + let fee_estimate = TransactionGasEstimate::new(simulated.fee_estimation, tip); let paid_fee_in_strk = self.compute_paid_fee(client, Felt::from(fee_estimate.overall_fee)).await?; let final_fee_estimate = fee_estimate.update_overall_fee(paid_fee_in_strk); - let token_price = client.price.fetch_token(transfer.token()).await?; + let token_price = client.price.fetch_token(gas_token).await?; let paid_fee_in_token = convert_strk_to_token(&token_price, paid_fee_in_strk, true)?; - if paid_fee_in_token > transfer.amount() { + if paid_fee_in_token > funded_amount { return Err(Error::MaxAmountTooLow(paid_fee_in_token.to_hex_string())); } - let fee_transfer = TokenTransfer::new(transfer.token(), self.gas_tank_address, paid_fee_in_token); + let fee_transfer = TokenTransfer::new(gas_token, self.gas_tank_address, paid_fee_in_token); let final_calls = self.build_calls(fee_transfer); let estimated_final_calls = final_calls.with_estimate(final_fee_estimate); Ok(EstimatedExecutableTransaction(estimated_final_calls)) } + async fn compute_paid_fee(&self, client: &Client, base_estimate: Felt) -> Result { match &self.transaction { ExecutableTransactionParameters::Deploy { .. } => Ok(client.compute_paid_fee_in_strk(base_estimate)), @@ -310,6 +324,92 @@ impl ExecutableTransaction { } } +#[derive(Debug, Clone)] +struct EmittedEvent { + from_address: Felt, + keys: Vec, + data: Vec, +} + +fn extract_gas_token_transfer_from_simulation(simulated: &SimulatedTransaction, gas_token: Felt, forwarder: Felt) -> Result { + let mut events = Vec::new(); + collect_events_from_trace(&simulated.transaction_trace, &mut events)?; + + let mut total = Felt::ZERO; + let mut found = false; + + for event in events { + if !is_gas_transfer_event(&event, gas_token, forwarder) { + continue; + } + + total += event.data[0]; + found = true; + } + + if !found { + return Err(Error::MissingGasFeeTransferEvent); + } + + Ok(total) +} + +fn collect_events_from_trace(trace: &TransactionTrace, out: &mut Vec) -> Result<(), Error> { + match trace { + TransactionTrace::Invoke(invoke_trace) => { + if let Some(invocation) = &invoke_trace.validate_invocation { + collect_events_from_invocation(invocation, out); + } + + match &invoke_trace.execute_invocation { + ExecuteInvocation::Success(invocation) => collect_events_from_invocation(invocation, out), + ExecuteInvocation::Reverted(reverted) => return Err(Error::Execution(reverted.revert_reason.clone())), + } + + if let Some(invocation) = &invoke_trace.fee_transfer_invocation { + collect_events_from_invocation(invocation, out); + } + }, + _ => {}, + } + + Ok(()) +} + +fn collect_events_from_invocation(invocation: &FunctionInvocation, out: &mut Vec) { + for event in &invocation.events { + out.push(EmittedEvent { + from_address: invocation.contract_address, + keys: event.keys.clone(), + data: event.data.clone(), + }); + } + + for call in &invocation.calls { + collect_events_from_invocation(call, out); + } +} + +fn is_gas_transfer_event(event: &EmittedEvent, gas_token: Felt, forwarder: Felt) -> bool { + if event.from_address != gas_token { + return false; + } + + if event.keys.len() < 3 || event.data.len() < 2 { + return false; + } + + if event.keys[0] != selector!("Transfer") { + return false; + } + + if event.keys[2] != forwarder { + return false; + } + + true +} + /// Paymaster executable transaction that can be sent to Starknet #[derive(Debug)] pub struct EstimatedExecutableTransaction(EstimatedCalls); @@ -331,13 +431,64 @@ mod tests { use crate::testing::transaction::{an_eth_approve, an_eth_transfer}; use crate::testing::{StarknetTestEnvironment, TestEnvironment}; use crate::ExecutableDirectInvokeParameters; + use crate::Error; use paymaster_starknet::transaction::{Calls, TokenTransfer}; use rand::Rng; use starknet::accounts::{Account, AccountFactory}; - use starknet::core::types::{Call, Felt}; + use starknet::core::types::{ + Call, CallType, EntryPointType, ExecuteInvocation, ExecutionResources, FeeEstimate, Felt, FunctionInvocation, InnerCallExecutionResources, + InvokeTransactionTrace, OrderedEvent, SimulatedTransaction, TransactionTrace, + }; use starknet::macros::{felt, selector}; use starknet::signers::SigningKey; + fn dummy_fee_estimate() -> FeeEstimate { + 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, + } + } + + fn make_invocation(contract_address: Felt, events: Vec, calls: Vec) -> FunctionInvocation { + FunctionInvocation { + contract_address, + entry_point_selector: Felt::ZERO, + calldata: vec![], + caller_address: Felt::ZERO, + class_hash: Felt::ZERO, + entry_point_type: EntryPointType::External, + call_type: CallType::Call, + result: vec![], + calls, + events, + messages: vec![], + execution_resources: InnerCallExecutionResources { l1_gas: 0, l2_gas: 0 }, + is_reverted: false, + } + } + + fn make_simulated_transaction(root_invocation: FunctionInvocation) -> SimulatedTransaction { + SimulatedTransaction { + transaction_trace: TransactionTrace::Invoke(InvokeTransactionTrace { + validate_invocation: None, + execute_invocation: ExecuteInvocation::Success(root_invocation), + fee_transfer_invocation: None, + state_diff: None, + execution_resources: ExecutionResources { + l1_gas: 0, + l1_data_gas: 0, + l2_gas: 0, + }, + }), + fee_estimation: dummy_fee_estimate(), + } + } + #[test] fn extract_gas_transfer_from_raw_call_works() { let forwarder = felt!("0x123"); @@ -560,6 +711,80 @@ mod tests { assert!(result.is_err()); } + #[test] + fn transfer_event_sum_matches() { + let forwarder = felt!("0x123"); + let gas_token = felt!("0x456"); + let other_token = felt!("0x789"); + + let transfer_event_1 = OrderedEvent { + order: 0, + keys: vec![selector!("Transfer"), felt!("0x1"), forwarder], + data: vec![Felt::from(10u8), Felt::ZERO], + }; + + let transfer_event_2 = OrderedEvent { + order: 1, + keys: vec![selector!("Transfer"), felt!("0x2"), forwarder], + data: vec![Felt::from(7u8), Felt::ZERO], + }; + + let ignored_event = OrderedEvent { + order: 2, + keys: vec![selector!("Transfer"), felt!("0x3"), forwarder], + data: vec![Felt::from(4u8), Felt::ZERO], + }; + + let token_invocation = make_invocation(gas_token, vec![transfer_event_1, transfer_event_2], vec![]); + let other_invocation = make_invocation(other_token, vec![ignored_event], vec![]); + let root_invocation = make_invocation(Felt::ZERO, vec![], vec![token_invocation, other_invocation]); + + let simulated = make_simulated_transaction(root_invocation); + let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder).unwrap(); + + assert_eq!(result, Felt::from(17u8)); + } + + #[test] + fn transfer_event_ignores_non_matching() { + let forwarder = felt!("0x123"); + let gas_token = felt!("0x456"); + + let wrong_recipient_event = OrderedEvent { + order: 0, + keys: vec![selector!("Transfer"), felt!("0x1"), felt!("0x999")], + data: vec![Felt::from(5u8), Felt::ZERO], + }; + + let invocation = make_invocation(gas_token, vec![wrong_recipient_event], vec![]); + let root_invocation = make_invocation(Felt::ZERO, vec![], vec![invocation]); + + let simulated = make_simulated_transaction(root_invocation); + let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder); + + assert!(matches!(result, Err(Error::MissingGasFeeTransferEvent))); + } + + #[test] + fn transfer_event_missing_errors() { + let forwarder = felt!("0x123"); + let gas_token = felt!("0x456"); + + let non_transfer_event = OrderedEvent { + order: 0, + keys: vec![selector!("Approval"), felt!("0x1"), forwarder], + data: vec![Felt::from(5u8), Felt::ZERO], + }; + + let invocation = make_invocation(gas_token, vec![non_transfer_event], vec![]); + let root_invocation = make_invocation(Felt::ZERO, vec![], vec![invocation]); + + let simulated = make_simulated_transaction(root_invocation); + let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder); + + assert!(matches!(result, Err(Error::MissingGasFeeTransferEvent))); + } + // TODO: enable when we can fix starknet image #[ignore] #[tokio::test] diff --git a/crates/paymaster-starknet/src/lib.rs b/crates/paymaster-starknet/src/lib.rs index c0b60d3e..5661d081 100644 --- a/crates/paymaster-starknet/src/lib.rs +++ b/crates/paymaster-starknet/src/lib.rs @@ -7,8 +7,8 @@ use starknet::core::serde::unsigned_field_element::UfeHex; use starknet::core::types::typed_data::TypedDataError; use starknet::core::types::SimulationFlagForEstimateFee::SkipValidate; use starknet::core::types::{ - BlockId, BlockTag, BroadcastedTransaction, ContractExecutionError, FeeEstimate, Felt, FunctionCall, MaybePreConfirmedBlockWithTxs, StarknetError, Transaction, - TransactionReceiptWithBlockInfo, TransactionStatus, + BlockId, BlockTag, BroadcastedTransaction, ContractExecutionError, FeeEstimate, Felt, FunctionCall, MaybePreConfirmedBlockWithTxs, SimulatedTransaction, + SimulationFlag, StarknetError, Transaction, TransactionReceiptWithBlockInfo, TransactionStatus, }; use starknet::macros::selector; use starknet::providers::{Provider, ProviderError}; @@ -287,6 +287,20 @@ impl Client { Ok(result?) } + /// Simulates the `transaction` and returns its execution trace and fee estimation. + #[instrument(name = "simulate_transaction", skip(self))] + pub async fn simulate_transaction(&self, transaction: &BroadcastedTransaction) -> Result { + let block = BlockId::Tag(BlockTag::PreConfirmed); + + let (result, duration) = + measure_duration!(log_if_error!(self.inner.simulate_transaction(block, transaction, vec![SimulationFlag::SkipValidate]).await)); + + metric!(histogram[starknet_rpc] = duration.as_millis(), method = "simulate_transaction"); + metric!(on error result => counter [ starknet_rpc_error ] = 1, method = "simulate_transaction"); + + Ok(result?) + } + /// Returns the receipt of the transaction with `hash` #[instrument(name = "fetch_class", skip(self))] pub async fn fetch_class(&self, class_hash: Felt) -> Result { From a47a8175da9c83077ad715ffd335b1c0b9e3dc85 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Fri, 30 Jan 2026 15:13:10 -0500 Subject: [PATCH 2/7] Fix simulation tests scope and formatting --- crates/paymaster-execution/src/execution/execute.rs | 4 ++-- crates/paymaster-starknet/src/lib.rs | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index 069ad19d..e681583c 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -247,7 +247,6 @@ impl ExecutableTransaction { Ok(EstimatedExecutableTransaction(estimated_final_calls)) } - async fn compute_paid_fee(&self, client: &Client, base_estimate: Felt) -> Result { match &self.transaction { ExecutableTransactionParameters::Deploy { .. } => Ok(client.compute_paid_fee_in_strk(base_estimate)), @@ -424,14 +423,15 @@ impl EstimatedExecutableTransaction { #[cfg(test)] mod tests { + use super::extract_gas_token_transfer_from_simulation; use crate::execution::build::{InvokeParameters, Transaction, TransactionParameters}; use crate::execution::deploy::DeploymentParameters; use crate::execution::execute::{ExecutableInvokeParameters, ExecutableTransaction, ExecutableTransactionParameters}; use crate::execution::{ExecutionParameters, FeeMode, TipPriority}; use crate::testing::transaction::{an_eth_approve, an_eth_transfer}; use crate::testing::{StarknetTestEnvironment, TestEnvironment}; - use crate::ExecutableDirectInvokeParameters; use crate::Error; + use crate::ExecutableDirectInvokeParameters; use paymaster_starknet::transaction::{Calls, TokenTransfer}; use rand::Rng; use starknet::accounts::{Account, AccountFactory}; diff --git a/crates/paymaster-starknet/src/lib.rs b/crates/paymaster-starknet/src/lib.rs index 5661d081..f07d93cb 100644 --- a/crates/paymaster-starknet/src/lib.rs +++ b/crates/paymaster-starknet/src/lib.rs @@ -292,8 +292,11 @@ impl Client { pub async fn simulate_transaction(&self, transaction: &BroadcastedTransaction) -> Result { let block = BlockId::Tag(BlockTag::PreConfirmed); - let (result, duration) = - measure_duration!(log_if_error!(self.inner.simulate_transaction(block, transaction, vec![SimulationFlag::SkipValidate]).await)); + let (result, duration) = measure_duration!(log_if_error!( + self.inner + .simulate_transaction(block, transaction, vec![SimulationFlag::SkipValidate]) + .await + )); metric!(histogram[starknet_rpc] = duration.as_millis(), method = "simulate_transaction"); metric!(on error result => counter [ starknet_rpc_error ] = 1, method = "simulate_transaction"); From 1dc6049d5d7e42269b9fa1063398f1c1024acc62 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Fri, 30 Jan 2026 16:15:30 -0500 Subject: [PATCH 3/7] Revert to calldata parsing for gas transfer validation --- crates/paymaster-execution/src/error.rs | 3 - .../src/execution/execute.rs | 338 ++++-------------- crates/paymaster-starknet/src/lib.rs | 21 +- 3 files changed, 80 insertions(+), 282 deletions(-) diff --git a/crates/paymaster-execution/src/error.rs b/crates/paymaster-execution/src/error.rs index e0fc2e7a..3ad150fa 100644 --- a/crates/paymaster-execution/src/error.rs +++ b/crates/paymaster-execution/src/error.rs @@ -20,9 +20,6 @@ pub enum Error { #[error("invalid typed data")] InvalidTypedData, - #[error("missing gas fee transfer event")] - MissingGasFeeTransferEvent, - #[error("max amount of gas token too low. Expected at least {0}")] MaxAmountTooLow(String), diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index e681583c..930a4054 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -1,10 +1,7 @@ use paymaster_prices::math::convert_strk_to_token; -use paymaster_starknet::transaction::{ - CalldataBuilder, Calls, EstimatedCalls, ExecuteFromOutsideMessage, SequentialCalldataDecoder, TokenTransfer, TransactionGasEstimate, -}; +use paymaster_starknet::transaction::{CalldataBuilder, Calls, EstimatedCalls, ExecuteFromOutsideMessage, SequentialCalldataDecoder, TokenTransfer}; use paymaster_starknet::Signature; -use starknet::accounts::Account; -use starknet::core::types::{Call, ExecuteInvocation, Felt, FunctionInvocation, InvokeTransactionResult, SimulatedTransaction, TransactionTrace, TypedData}; +use starknet::core::types::{Call, Felt, InvokeTransactionResult, TypedData}; use starknet::macros::selector; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -59,21 +56,7 @@ impl ExecutableInvokeParameters { } fn find_gas_token_transfer(&self, forwarder: Felt) -> Result { - let last_call = self.message.calls().last().ok_or(Error::InvalidTypedData)?; - if last_call.selector != selector!("transfer") { - return Err(Error::InvalidTypedData); - } - - let transfer_recipient = last_call.calldata.first().ok_or(Error::InvalidTypedData)?; - if *transfer_recipient != forwarder { - return Err(Error::InvalidTypedData); - } - - Ok(TokenTransfer::new( - last_call.to, - *transfer_recipient, - *last_call.calldata.get(1).ok_or(Error::InvalidTypedData)?, - )) + find_gas_token_transfer_from_calls(self.message.calls().iter(), forwarder).ok_or(Error::InvalidTypedData) } pub fn get_unique_identifier(&self) -> u64 { @@ -105,7 +88,7 @@ impl ExecutableDirectInvokeParameters { /// [caller, nonce..., execute_after, execute_before, calls_len, ...calls, sig_len, sig...] /// where each call is [to, selector, calldata_len, ...calldata] and the nonce may be one or two felts. /// - /// For non-sponsored transactions, the last call should be a transfer of gas token to the forwarder. + /// For non-sponsored transactions, the calls should include a transfer of gas token to the forwarder. fn find_gas_token_transfer(&self, forwarder: Felt) -> Result { fn extract_calls_segment<'a>(calldata: &'a [Felt], calls_len_index: usize) -> Option<&'a [Felt]> { let calls_len_felt = calldata.get(calls_len_index)?; @@ -144,32 +127,15 @@ impl ExecutableDirectInvokeParameters { let Ok(decoder) = SequentialCalldataDecoder::new(calls) else { continue; }; - let Some(last_call) = decoder.last() else { - continue; - }; - - // Validate the last call is a transfer to the forwarder. - if last_call.selector != selector!("transfer") { - continue; + let mut found = None; + for call in decoder.iter() { + if let Some(transfer) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder) { + found = Some(transfer); + } } - - if last_call.calldata.len() != 3 { - continue; + if let Some(transfer) = found { + return Ok(transfer); } - - let Some(recipient) = last_call.calldata.first() else { - continue; - }; - - if *recipient != forwarder { - continue; - } - - let Some(amount) = last_call.calldata.get(1) else { - continue; - }; - - return Ok(TokenTransfer::new(last_call.to, forwarder, *amount)); } Err(Error::InvalidTypedData) @@ -208,39 +174,29 @@ impl ExecutableTransaction { } pub async fn estimate_transaction(self, client: &Client) -> Result { - match &self.transaction { - ExecutableTransactionParameters::Invoke { .. } - | ExecutableTransactionParameters::DeployAndInvoke { .. } - | ExecutableTransactionParameters::DirectInvoke { .. } => {}, + let transfer = match &self.transaction { + ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, + ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, + ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, _ => return Err(Error::InvalidTypedData), }; - let gas_token = self.parameters.gas_token(); + let calls = self.build_calls(transfer); - let tip = client.get_tip(self.parameters.tip()).await?; - let estimate_account = client.estimate_account.address(); - let nonce = client.starknet.fetch_nonce(estimate_account).await?; - - let probe_transfer = TokenTransfer::new(gas_token, self.gas_tank_address, Felt::ZERO); - let probe_calls = self.build_calls(probe_transfer); - let probe_tx = probe_calls.as_transaction(estimate_account, nonce, tip); - - let simulated = client.starknet.simulate_transaction(&probe_tx).await?; - let funded_amount = extract_gas_token_transfer_from_simulation(&simulated, gas_token, self.forwarder)?; - - let fee_estimate = TransactionGasEstimate::new(simulated.fee_estimation, tip); + let estimated_calls = client.estimate(&calls, self.parameters.tip()).await?; + let fee_estimate = estimated_calls.estimate(); let paid_fee_in_strk = self.compute_paid_fee(client, Felt::from(fee_estimate.overall_fee)).await?; let final_fee_estimate = fee_estimate.update_overall_fee(paid_fee_in_strk); - let token_price = client.price.fetch_token(gas_token).await?; + let token_price = client.price.fetch_token(transfer.token()).await?; let paid_fee_in_token = convert_strk_to_token(&token_price, paid_fee_in_strk, true)?; - if paid_fee_in_token > funded_amount { + if paid_fee_in_token > transfer.amount() { return Err(Error::MaxAmountTooLow(paid_fee_in_token.to_hex_string())); } - let fee_transfer = TokenTransfer::new(gas_token, self.gas_tank_address, paid_fee_in_token); + let fee_transfer = TokenTransfer::new(transfer.token(), self.gas_tank_address, paid_fee_in_token); let final_calls = self.build_calls(fee_transfer); let estimated_final_calls = final_calls.with_estimate(final_fee_estimate); @@ -323,90 +279,31 @@ impl ExecutableTransaction { } } -#[derive(Debug, Clone)] -struct EmittedEvent { - from_address: Felt, - keys: Vec, - data: Vec, -} - -fn extract_gas_token_transfer_from_simulation(simulated: &SimulatedTransaction, gas_token: Felt, forwarder: Felt) -> Result { - let mut events = Vec::new(); - collect_events_from_trace(&simulated.transaction_trace, &mut events)?; - - let mut total = Felt::ZERO; - let mut found = false; - - for event in events { - if !is_gas_transfer_event(&event, gas_token, forwarder) { - continue; +fn find_gas_token_transfer_from_calls<'a, I>(calls: I, forwarder: Felt) -> Option +where + I: IntoIterator, +{ + let mut found = None; + for call in calls { + if let Some(transfer) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder) { + found = Some(transfer); } - - total += event.data[0]; - found = true; - } - - if !found { - return Err(Error::MissingGasFeeTransferEvent); } - - Ok(total) + found } -fn collect_events_from_trace(trace: &TransactionTrace, out: &mut Vec) -> Result<(), Error> { - match trace { - TransactionTrace::Invoke(invoke_trace) => { - if let Some(invocation) = &invoke_trace.validate_invocation { - collect_events_from_invocation(invocation, out); - } - - match &invoke_trace.execute_invocation { - ExecuteInvocation::Success(invocation) => collect_events_from_invocation(invocation, out), - ExecuteInvocation::Reverted(reverted) => return Err(Error::Execution(reverted.revert_reason.clone())), - } - - if let Some(invocation) = &invoke_trace.fee_transfer_invocation { - collect_events_from_invocation(invocation, out); - } - }, - _ => {}, +fn match_transfer_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt) -> Option { + if selector != selector!("transfer") { + return None; } - Ok(()) -} - -fn collect_events_from_invocation(invocation: &FunctionInvocation, out: &mut Vec) { - for event in &invocation.events { - out.push(EmittedEvent { - from_address: invocation.contract_address, - keys: event.keys.clone(), - data: event.data.clone(), - }); + let recipient = calldata.first()?; + if *recipient != forwarder { + return None; } - for call in &invocation.calls { - collect_events_from_invocation(call, out); - } -} - -fn is_gas_transfer_event(event: &EmittedEvent, gas_token: Felt, forwarder: Felt) -> bool { - if event.from_address != gas_token { - return false; - } - - if event.keys.len() < 3 || event.data.len() < 2 { - return false; - } - - if event.keys[0] != selector!("Transfer") { - return false; - } - - if event.keys[2] != forwarder { - return false; - } - - true + let amount = calldata.get(1)?; + Some(TokenTransfer::new(token, forwarder, *amount)) } /// Paymaster executable transaction that can be sent to Starknet @@ -423,72 +320,20 @@ impl EstimatedExecutableTransaction { #[cfg(test)] mod tests { - use super::extract_gas_token_transfer_from_simulation; use crate::execution::build::{InvokeParameters, Transaction, TransactionParameters}; use crate::execution::deploy::DeploymentParameters; use crate::execution::execute::{ExecutableInvokeParameters, ExecutableTransaction, ExecutableTransactionParameters}; use crate::execution::{ExecutionParameters, FeeMode, TipPriority}; use crate::testing::transaction::{an_eth_approve, an_eth_transfer}; use crate::testing::{StarknetTestEnvironment, TestEnvironment}; - use crate::Error; use crate::ExecutableDirectInvokeParameters; use paymaster_starknet::transaction::{Calls, TokenTransfer}; use rand::Rng; use starknet::accounts::{Account, AccountFactory}; - use starknet::core::types::{ - Call, CallType, EntryPointType, ExecuteInvocation, ExecutionResources, FeeEstimate, Felt, FunctionInvocation, InnerCallExecutionResources, - InvokeTransactionTrace, OrderedEvent, SimulatedTransaction, TransactionTrace, - }; + use starknet::core::types::{Call, Felt}; use starknet::macros::{felt, selector}; use starknet::signers::SigningKey; - fn dummy_fee_estimate() -> FeeEstimate { - 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, - } - } - - fn make_invocation(contract_address: Felt, events: Vec, calls: Vec) -> FunctionInvocation { - FunctionInvocation { - contract_address, - entry_point_selector: Felt::ZERO, - calldata: vec![], - caller_address: Felt::ZERO, - class_hash: Felt::ZERO, - entry_point_type: EntryPointType::External, - call_type: CallType::Call, - result: vec![], - calls, - events, - messages: vec![], - execution_resources: InnerCallExecutionResources { l1_gas: 0, l2_gas: 0 }, - is_reverted: false, - } - } - - fn make_simulated_transaction(root_invocation: FunctionInvocation) -> SimulatedTransaction { - SimulatedTransaction { - transaction_trace: TransactionTrace::Invoke(InvokeTransactionTrace { - validate_invocation: None, - execute_invocation: ExecuteInvocation::Success(root_invocation), - fee_transfer_invocation: None, - state_diff: None, - execution_resources: ExecutionResources { - l1_gas: 0, - l1_data_gas: 0, - l2_gas: 0, - }, - }), - fee_estimation: dummy_fee_estimate(), - } - } - #[test] fn extract_gas_transfer_from_raw_call_works() { let forwarder = felt!("0x123"); @@ -592,7 +437,7 @@ mod tests { } #[test] - fn extract_gas_transfer_fails_when_last_call_not_transfer() { + fn extract_gas_transfer_fails_when_no_transfer_call() { let forwarder = felt!("0x123"); let calldata = vec![ @@ -712,77 +557,50 @@ mod tests { } #[test] - fn transfer_event_sum_matches() { + fn extract_gas_transfer_from_raw_call_works_when_transfer_not_last() { let forwarder = felt!("0x123"); - let gas_token = felt!("0x456"); - let other_token = felt!("0x789"); - - let transfer_event_1 = OrderedEvent { - order: 0, - keys: vec![selector!("Transfer"), felt!("0x1"), forwarder], - data: vec![Felt::from(10u8), Felt::ZERO], - }; - - let transfer_event_2 = OrderedEvent { - order: 1, - keys: vec![selector!("Transfer"), felt!("0x2"), forwarder], - data: vec![Felt::from(7u8), Felt::ZERO], - }; - - let ignored_event = OrderedEvent { - order: 2, - keys: vec![selector!("Transfer"), felt!("0x3"), forwarder], - data: vec![Felt::from(4u8), Felt::ZERO], - }; - - let token_invocation = make_invocation(gas_token, vec![transfer_event_1, transfer_event_2], vec![]); - let other_invocation = make_invocation(other_token, vec![ignored_event], vec![]); - let root_invocation = make_invocation(Felt::ZERO, vec![], vec![token_invocation, other_invocation]); - - let simulated = make_simulated_transaction(root_invocation); - let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder).unwrap(); - - assert_eq!(result, Felt::from(17u8)); - } - - #[test] - fn transfer_event_ignores_non_matching() { - let forwarder = felt!("0x123"); - let gas_token = felt!("0x456"); - - let wrong_recipient_event = OrderedEvent { - order: 0, - keys: vec![selector!("Transfer"), felt!("0x1"), felt!("0x999")], - data: vec![Felt::from(5u8), Felt::ZERO], - }; - - let invocation = make_invocation(gas_token, vec![wrong_recipient_event], vec![]); - let root_invocation = make_invocation(Felt::ZERO, vec![], vec![invocation]); - - let simulated = make_simulated_transaction(root_invocation); - let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder); - - assert!(matches!(result, Err(Error::MissingGasFeeTransferEvent))); - } + let token = felt!("0x456"); + let amount = felt!("0x789"); - #[test] - fn transfer_event_missing_errors() { - let forwarder = felt!("0x123"); - let gas_token = felt!("0x456"); + let calldata = vec![ + felt!("0x1"), // caller + felt!("0x2"), // nonce + felt!("0x3"), // execute_after + felt!("0x4"), // execute_before + Felt::TWO, // num_calls = 2 + // First call (gas transfer to forwarder) + token, // to (token address) + selector!("transfer"), // selector + Felt::THREE, // calldata_len + forwarder, // recipient (forwarder) + amount, // amount_low + Felt::ZERO, // amount_high + // Second call (user's approve) + felt!("0xAAA"), // to + selector!("approve"), // selector + Felt::ONE, // calldata_len + felt!("0xBBB"), // spender + Felt::TWO, // signature length + felt!("0xDEAD"), // signature part 1 + felt!("0xBEEF"), // signature part 2 + ]; - let non_transfer_event = OrderedEvent { - order: 0, - keys: vec![selector!("Approval"), felt!("0x1"), forwarder], - data: vec![Felt::from(5u8), Felt::ZERO], + let parameters = ExecutableDirectInvokeParameters { + user: Felt::ZERO, + execute_from_outside_call: Call { + to: felt!("0x999"), + selector: selector!("execute_from_outside"), + calldata, + }, }; - let invocation = make_invocation(gas_token, vec![non_transfer_event], vec![]); - let root_invocation = make_invocation(Felt::ZERO, vec![], vec![invocation]); - - let simulated = make_simulated_transaction(root_invocation); - let result = extract_gas_token_transfer_from_simulation(&simulated, gas_token, forwarder); + let result = parameters.find_gas_token_transfer(forwarder); + assert!(result.is_ok()); - assert!(matches!(result, Err(Error::MissingGasFeeTransferEvent))); + let transfer = result.unwrap(); + assert_eq!(transfer.token(), token); + assert_eq!(transfer.recipient(), forwarder); + assert_eq!(transfer.amount(), amount); } // TODO: enable when we can fix starknet image diff --git a/crates/paymaster-starknet/src/lib.rs b/crates/paymaster-starknet/src/lib.rs index f07d93cb..c0b60d3e 100644 --- a/crates/paymaster-starknet/src/lib.rs +++ b/crates/paymaster-starknet/src/lib.rs @@ -7,8 +7,8 @@ use starknet::core::serde::unsigned_field_element::UfeHex; use starknet::core::types::typed_data::TypedDataError; use starknet::core::types::SimulationFlagForEstimateFee::SkipValidate; use starknet::core::types::{ - BlockId, BlockTag, BroadcastedTransaction, ContractExecutionError, FeeEstimate, Felt, FunctionCall, MaybePreConfirmedBlockWithTxs, SimulatedTransaction, - SimulationFlag, StarknetError, Transaction, TransactionReceiptWithBlockInfo, TransactionStatus, + BlockId, BlockTag, BroadcastedTransaction, ContractExecutionError, FeeEstimate, Felt, FunctionCall, MaybePreConfirmedBlockWithTxs, StarknetError, Transaction, + TransactionReceiptWithBlockInfo, TransactionStatus, }; use starknet::macros::selector; use starknet::providers::{Provider, ProviderError}; @@ -287,23 +287,6 @@ impl Client { Ok(result?) } - /// Simulates the `transaction` and returns its execution trace and fee estimation. - #[instrument(name = "simulate_transaction", skip(self))] - pub async fn simulate_transaction(&self, transaction: &BroadcastedTransaction) -> Result { - let block = BlockId::Tag(BlockTag::PreConfirmed); - - let (result, duration) = measure_duration!(log_if_error!( - self.inner - .simulate_transaction(block, transaction, vec![SimulationFlag::SkipValidate]) - .await - )); - - metric!(histogram[starknet_rpc] = duration.as_millis(), method = "simulate_transaction"); - metric!(on error result => counter [ starknet_rpc_error ] = 1, method = "simulate_transaction"); - - Ok(result?) - } - /// Returns the receipt of the transaction with `hash` #[instrument(name = "fetch_class", skip(self))] pub async fn fetch_class(&self, class_hash: Felt) -> Result { From ec2393df71808a5dc84aa7429f3e7b4af90752df Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Mon, 2 Feb 2026 13:02:34 -0500 Subject: [PATCH 4/7] Aggregate gas transfers when parsing calldata --- .../src/execution/execute.rs | 268 ++++++++++++++++-- 1 file changed, 240 insertions(+), 28 deletions(-) diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index 930a4054..b4f88d0e 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -55,8 +55,8 @@ impl ExecutableInvokeParameters { }) } - fn find_gas_token_transfer(&self, forwarder: Felt) -> Result { - find_gas_token_transfer_from_calls(self.message.calls().iter(), forwarder).ok_or(Error::InvalidTypedData) + fn find_gas_token_transfer(&self, forwarder: Felt, gas_token: Felt) -> Result { + find_gas_token_transfer_from_calls(self.message.calls().iter(), forwarder, gas_token).ok_or(Error::InvalidTypedData) } pub fn get_unique_identifier(&self) -> u64 { @@ -89,7 +89,7 @@ impl ExecutableDirectInvokeParameters { /// where each call is [to, selector, calldata_len, ...calldata] and the nonce may be one or two felts. /// /// For non-sponsored transactions, the calls should include a transfer of gas token to the forwarder. - fn find_gas_token_transfer(&self, forwarder: Felt) -> Result { + fn find_gas_token_transfer(&self, forwarder: Felt, gas_token: Felt) -> Result { fn extract_calls_segment<'a>(calldata: &'a [Felt], calls_len_index: usize) -> Option<&'a [Felt]> { let calls_len_felt = calldata.get(calls_len_index)?; let calls_len: usize = (*calls_len_felt).try_into().ok()?; @@ -127,14 +127,9 @@ impl ExecutableDirectInvokeParameters { let Ok(decoder) = SequentialCalldataDecoder::new(calls) else { continue; }; - let mut found = None; - for call in decoder.iter() { - if let Some(transfer) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder) { - found = Some(transfer); - } - } - if let Some(transfer) = found { - return Ok(transfer); + let total = aggregate_transfer_amount_from_decoded_calls(&decoder, forwarder, gas_token); + if total != Felt::ZERO { + return Ok(TokenTransfer::new(gas_token, forwarder, total)); } } @@ -174,10 +169,11 @@ impl ExecutableTransaction { } pub async fn estimate_transaction(self, client: &Client) -> Result { + let gas_token = self.parameters.gas_token(); let transfer = match &self.transaction { - ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, - ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, - ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder)?, + ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, + ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, + ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, _ => return Err(Error::InvalidTypedData), }; @@ -279,31 +275,118 @@ impl ExecutableTransaction { } } -fn find_gas_token_transfer_from_calls<'a, I>(calls: I, forwarder: Felt) -> Option +fn find_gas_token_transfer_from_calls<'a, I>(calls: I, forwarder: Felt, gas_token: Felt) -> Option where I: IntoIterator, { - let mut found = None; + let total = aggregate_transfer_amount_from_calls(calls, forwarder, gas_token); + if total == Felt::ZERO { + return None; + } + + Some(TokenTransfer::new(gas_token, forwarder, total)) +} + +fn aggregate_transfer_amount_from_calls<'a, I>(calls: I, forwarder: Felt, gas_token: Felt) -> Felt +where + I: IntoIterator, +{ + let mut total = Felt::ZERO; for call in calls { - if let Some(transfer) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder) { - found = Some(transfer); + if let Some(amount) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder, gas_token) { + total += amount; + continue; + } + + if is_execute_from_outside_selector(call.selector) { + total += aggregate_transfer_amount_from_execute_from_outside_calldata(&call.calldata, forwarder, gas_token); + } + } + total +} + +fn aggregate_transfer_amount_from_decoded_calls(decoder: &SequentialCalldataDecoder, forwarder: Felt, gas_token: Felt) -> Felt { + let mut total = Felt::ZERO; + for call in decoder.iter() { + if let Some(amount) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder, gas_token) { + total += amount; + continue; + } + + if is_execute_from_outside_selector(call.selector) { + total += aggregate_transfer_amount_from_execute_from_outside_calldata(&call.calldata, forwarder, gas_token); + } + } + total +} + +fn aggregate_transfer_amount_from_execute_from_outside_calldata(calldata: &[Felt], forwarder: Felt, gas_token: Felt) -> Felt { + fn extract_calls_segment<'a>(calldata: &'a [Felt], calls_len_index: usize) -> Option<&'a [Felt]> { + let calls_len_felt = calldata.get(calls_len_index)?; + let calls_len: usize = (*calls_len_felt).try_into().ok()?; + if calls_len == 0 { + return None; + } + + let mut offset = calls_len_index + 1; + for _ in 0..calls_len { + let length_index = offset.checked_add(2)?; + let length_felt = calldata.get(length_index)?; + let length: usize = (*length_felt).try_into().ok()?; + let next_offset = offset.checked_add(3)?.checked_add(length)?; + if calldata.len() < next_offset { + return None; + } + offset = next_offset; + } + + let sig_len_felt = calldata.get(offset)?; + let sig_len: usize = (*sig_len_felt).try_into().ok()?; + let expected_end = offset.checked_add(1)?.checked_add(sig_len)?; + if expected_end != calldata.len() { + return None; } + + calldata.get((calls_len_index + 1)..offset) } - found + + for calls_len_index in [4usize, 5] { + let Some(calls) = extract_calls_segment(calldata, calls_len_index) else { + continue; + }; + let Ok(decoder) = SequentialCalldataDecoder::new(calls) else { + continue; + }; + + let total = aggregate_transfer_amount_from_decoded_calls(&decoder, forwarder, gas_token); + if total != Felt::ZERO { + return total; + } + } + + Felt::ZERO +} + +fn is_execute_from_outside_selector(selector: Felt) -> bool { + selector == selector!("execute_from_outside") || selector == selector!("execute_from_outside_v3") } -fn match_transfer_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt) -> Option { +fn match_transfer_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt, gas_token: Felt) -> Option { if selector != selector!("transfer") { return None; } + if token != gas_token { + return None; + } + let recipient = calldata.first()?; if *recipient != forwarder { return None; } let amount = calldata.get(1)?; - Some(TokenTransfer::new(token, forwarder, *amount)) + Some(*amount) } /// Paymaster executable transaction that can be sent to Starknet @@ -376,7 +459,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, token); assert!(result.is_ok()); let transfer = result.unwrap(); @@ -427,7 +510,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, token); assert!(result.is_ok()); let transfer = result.unwrap(); @@ -467,7 +550,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); assert!(result.is_err()); } @@ -503,7 +586,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, felt!("0x789")); assert!(result.is_err()); } @@ -528,7 +611,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); assert!(result.is_err()); } @@ -552,7 +635,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); assert!(result.is_err()); } @@ -594,7 +677,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder); + let result = parameters.find_gas_token_transfer(forwarder, token); assert!(result.is_ok()); let transfer = result.unwrap(); @@ -603,6 +686,135 @@ mod tests { assert_eq!(transfer.amount(), amount); } + #[test] + fn extract_gas_transfer_from_raw_call_aggregates_transfers() { + let forwarder = felt!("0x123"); + let token = felt!("0x456"); + let amount_one = felt!("0x5"); + let amount_two = felt!("0x7"); + + let calldata = vec![ + felt!("0x1"), // caller + felt!("0x2"), // nonce + felt!("0x3"), // execute_after + felt!("0x4"), // execute_before + Felt::THREE, // num_calls = 3 + // First transfer + token, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_one, + Felt::ZERO, + // Second transfer + token, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_two, + Felt::ZERO, + // User call + felt!("0xAAA"), + selector!("approve"), + Felt::ONE, + felt!("0xBBB"), + Felt::ZERO, // signature length + ]; + + let parameters = ExecutableDirectInvokeParameters { + user: Felt::ZERO, + execute_from_outside_call: Call { + to: felt!("0x999"), + selector: selector!("execute_from_outside"), + calldata, + }, + }; + + let result = parameters.find_gas_token_transfer(forwarder, token); + assert!(result.is_ok()); + + let transfer = result.unwrap(); + assert_eq!(transfer.token(), token); + assert_eq!(transfer.recipient(), forwarder); + assert_eq!(transfer.amount(), amount_one + amount_two); + } + + #[test] + fn extract_gas_transfer_from_raw_call_works_with_nested_execute_from_outside() { + let forwarder = felt!("0x123"); + let token = felt!("0x456"); + let amount_one = felt!("0x9"); + let amount_two = felt!("0xA"); + + let nested_calldata_one = vec![ + felt!("0x1"), // caller + felt!("0x2"), // nonce + felt!("0x3"), // execute_after + felt!("0x4"), // execute_before + Felt::ONE, // num_calls = 1 + token, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_one, + Felt::ZERO, + Felt::ZERO, // signature length + ]; + + let nested_calldata_two = vec![ + felt!("0x5"), // caller + felt!("0x6"), // nonce + felt!("0x7"), // execute_after + felt!("0x8"), // execute_before + Felt::ONE, // num_calls = 1 + token, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_two, + Felt::ZERO, + Felt::ZERO, // signature length + ]; + + let mut outer_calldata = vec![ + felt!("0x9"), // caller + felt!("0xA"), // nonce + felt!("0xB"), // execute_after + felt!("0xC"), // execute_before + Felt::TWO, // num_calls = 2 + // Nested call one + felt!("0x111"), + selector!("execute_from_outside"), + Felt::from(nested_calldata_one.len() as u64), + ]; + outer_calldata.extend(nested_calldata_one.iter().cloned()); + outer_calldata.extend([ + // Nested call two + felt!("0x222"), + selector!("execute_from_outside"), + Felt::from(nested_calldata_two.len() as u64), + ]); + outer_calldata.extend(nested_calldata_two.iter().cloned()); + outer_calldata.push(Felt::ZERO); // signature length + + let parameters = ExecutableDirectInvokeParameters { + user: Felt::ZERO, + execute_from_outside_call: Call { + to: felt!("0x999"), + selector: selector!("execute_from_outside"), + calldata: outer_calldata, + }, + }; + + let result = parameters.find_gas_token_transfer(forwarder, token); + assert!(result.is_ok()); + + let transfer = result.unwrap(); + assert_eq!(transfer.token(), token); + assert_eq!(transfer.recipient(), forwarder); + assert_eq!(transfer.amount(), amount_one + amount_two); + } + // TODO: enable when we can fix starknet image #[ignore] #[tokio::test] From 5c704454e6f5779cf283042f018955276c7ff7e2 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Mon, 2 Feb 2026 13:40:42 -0500 Subject: [PATCH 5/7] Add nested outer transfer aggregation test --- crates/paymaster-execution/src/execution/execute.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index b4f88d0e..f9723cb6 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -743,6 +743,7 @@ mod tests { fn extract_gas_transfer_from_raw_call_works_with_nested_execute_from_outside() { let forwarder = felt!("0x123"); let token = felt!("0x456"); + let amount_outer = felt!("0x3"); let amount_one = felt!("0x9"); let amount_two = felt!("0xA"); @@ -781,7 +782,14 @@ mod tests { felt!("0xA"), // nonce felt!("0xB"), // execute_after felt!("0xC"), // execute_before - Felt::TWO, // num_calls = 2 + Felt::THREE, // num_calls = 3 + // Outer transfer + token, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_outer, + Felt::ZERO, // Nested call one felt!("0x111"), selector!("execute_from_outside"), @@ -812,7 +820,7 @@ mod tests { let transfer = result.unwrap(); assert_eq!(transfer.token(), token); assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount_one + amount_two); + assert_eq!(transfer.amount(), amount_outer + amount_one + amount_two); } // TODO: enable when we can fix starknet image From 5df17d019dc870c5a6931f6ba1d902cf42c8f905 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Mon, 2 Feb 2026 14:22:06 -0500 Subject: [PATCH 6/7] Aggregate calldata transfers for gas funding --- .../src/execution/execute.rs | 232 +++++++++++------- 1 file changed, 138 insertions(+), 94 deletions(-) diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index f9723cb6..8478c3ca 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -1,8 +1,10 @@ use paymaster_prices::math::convert_strk_to_token; +use paymaster_starknet::constants::Token; use paymaster_starknet::transaction::{CalldataBuilder, Calls, EstimatedCalls, ExecuteFromOutsideMessage, SequentialCalldataDecoder, TokenTransfer}; use paymaster_starknet::Signature; use starknet::core::types::{Call, Felt, InvokeTransactionResult, TypedData}; use starknet::macros::selector; +use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use crate::execution::deploy::DeploymentParameters; @@ -55,8 +57,13 @@ impl ExecutableInvokeParameters { }) } - fn find_gas_token_transfer(&self, forwarder: Felt, gas_token: Felt) -> Result { - find_gas_token_transfer_from_calls(self.message.calls().iter(), forwarder, gas_token).ok_or(Error::InvalidTypedData) + fn find_gas_token_transfers(&self, forwarder: Felt) -> Result, Error> { + let transfers = collect_gas_transfers_from_calls(self.message.calls().iter(), forwarder); + if transfers.is_empty() { + return Err(Error::InvalidTypedData); + } + + Ok(transfers) } pub fn get_unique_identifier(&self) -> u64 { @@ -88,8 +95,8 @@ impl ExecutableDirectInvokeParameters { /// [caller, nonce..., execute_after, execute_before, calls_len, ...calls, sig_len, sig...] /// where each call is [to, selector, calldata_len, ...calldata] and the nonce may be one or two felts. /// - /// For non-sponsored transactions, the calls should include a transfer of gas token to the forwarder. - fn find_gas_token_transfer(&self, forwarder: Felt, gas_token: Felt) -> Result { + /// For non-sponsored transactions, the calls should include gas funding transfers to the forwarder. + fn find_gas_token_transfers(&self, forwarder: Felt) -> Result, Error> { fn extract_calls_segment<'a>(calldata: &'a [Felt], calls_len_index: usize) -> Option<&'a [Felt]> { let calls_len_felt = calldata.get(calls_len_index)?; let calls_len: usize = (*calls_len_felt).try_into().ok()?; @@ -127,12 +134,13 @@ impl ExecutableDirectInvokeParameters { let Ok(decoder) = SequentialCalldataDecoder::new(calls) else { continue; }; - let total = aggregate_transfer_amount_from_decoded_calls(&decoder, forwarder, gas_token); - if total != Felt::ZERO { - return Ok(TokenTransfer::new(gas_token, forwarder, total)); + + let mut transfers = HashMap::new(); + collect_gas_transfers_from_decoded_calls(&decoder, forwarder, &mut transfers); + if !transfers.is_empty() { + return Ok(transfers); } } - Err(Error::InvalidTypedData) } } @@ -169,15 +177,16 @@ impl ExecutableTransaction { } pub async fn estimate_transaction(self, client: &Client) -> Result { - let gas_token = self.parameters.gas_token(); - let transfer = match &self.transaction { - ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, - ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, - ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfer(self.forwarder, gas_token)?, + let transfers = match &self.transaction { + ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfers(self.forwarder)?, + ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfers(self.forwarder)?, + ExecutableTransactionParameters::DirectInvoke { invoke, .. } => invoke.find_gas_token_transfers(self.forwarder)?, _ => return Err(Error::InvalidTypedData), }; - let calls = self.build_calls(transfer); + let gas_token = self.parameters.gas_token(); + let gas_token_amount = transfers.get(&gas_token).cloned().unwrap_or(Felt::ZERO); + let calls = self.build_calls(TokenTransfer::new(gas_token, self.gas_tank_address, gas_token_amount)); let estimated_calls = client.estimate(&calls, self.parameters.tip()).await?; let fee_estimate = estimated_calls.estimate(); @@ -185,14 +194,15 @@ impl ExecutableTransaction { let paid_fee_in_strk = self.compute_paid_fee(client, Felt::from(fee_estimate.overall_fee)).await?; let final_fee_estimate = fee_estimate.update_overall_fee(paid_fee_in_strk); - let token_price = client.price.fetch_token(transfer.token()).await?; - let paid_fee_in_token = convert_strk_to_token(&token_price, paid_fee_in_strk, true)?; - - if paid_fee_in_token > transfer.amount() { - return Err(Error::MaxAmountTooLow(paid_fee_in_token.to_hex_string())); + let total_funding_in_strk = aggregate_transfers_in_strk(client, &transfers).await?; + if total_funding_in_strk < paid_fee_in_strk { + return Err(Error::MaxAmountTooLow(paid_fee_in_strk.to_hex_string())); } - let fee_transfer = TokenTransfer::new(transfer.token(), self.gas_tank_address, paid_fee_in_token); + let token_price = client.price.fetch_token(gas_token).await?; + let paid_fee_in_token = convert_strk_to_token(&token_price, paid_fee_in_strk, true)?; + + let fee_transfer = TokenTransfer::new(gas_token, self.gas_tank_address, paid_fee_in_token); let final_calls = self.build_calls(fee_transfer); let estimated_final_calls = final_calls.with_estimate(final_fee_estimate); @@ -275,52 +285,36 @@ impl ExecutableTransaction { } } -fn find_gas_token_transfer_from_calls<'a, I>(calls: I, forwarder: Felt, gas_token: Felt) -> Option +fn collect_gas_transfers_from_calls<'a, I>(calls: I, forwarder: Felt) -> HashMap where I: IntoIterator, { - let total = aggregate_transfer_amount_from_calls(calls, forwarder, gas_token); - if total == Felt::ZERO { - return None; + let mut transfers = HashMap::new(); + for call in calls { + collect_gas_transfers_from_call(call.to, call.selector, &call.calldata, forwarder, &mut transfers); } - - Some(TokenTransfer::new(gas_token, forwarder, total)) + transfers } -fn aggregate_transfer_amount_from_calls<'a, I>(calls: I, forwarder: Felt, gas_token: Felt) -> Felt -where - I: IntoIterator, -{ - let mut total = Felt::ZERO; - for call in calls { - if let Some(amount) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder, gas_token) { - total += amount; - continue; - } - - if is_execute_from_outside_selector(call.selector) { - total += aggregate_transfer_amount_from_execute_from_outside_calldata(&call.calldata, forwarder, gas_token); - } +fn collect_gas_transfers_from_decoded_calls(decoder: &SequentialCalldataDecoder, forwarder: Felt, transfers: &mut HashMap) { + for call in decoder.iter() { + collect_gas_transfers_from_call(call.to, call.selector, &call.calldata, forwarder, transfers); } - total } -fn aggregate_transfer_amount_from_decoded_calls(decoder: &SequentialCalldataDecoder, forwarder: Felt, gas_token: Felt) -> Felt { - let mut total = Felt::ZERO; - for call in decoder.iter() { - if let Some(amount) = match_transfer_call(call.to, call.selector, &call.calldata, forwarder, gas_token) { - total += amount; - continue; - } +fn collect_gas_transfers_from_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt, transfers: &mut HashMap) { + if let Some((transfer_token, amount)) = match_transfer_call(token, selector, calldata, forwarder) { + let entry = transfers.entry(transfer_token).or_insert(Felt::ZERO); + *entry += amount; + return; + } - if is_execute_from_outside_selector(call.selector) { - total += aggregate_transfer_amount_from_execute_from_outside_calldata(&call.calldata, forwarder, gas_token); - } + if is_execute_from_outside_selector(selector) { + collect_gas_transfers_from_execute_from_outside_calldata(calldata, forwarder, transfers); } - total } -fn aggregate_transfer_amount_from_execute_from_outside_calldata(calldata: &[Felt], forwarder: Felt, gas_token: Felt) -> Felt { +fn collect_gas_transfers_from_execute_from_outside_calldata(calldata: &[Felt], forwarder: Felt, transfers: &mut HashMap) { fn extract_calls_segment<'a>(calldata: &'a [Felt], calls_len_index: usize) -> Option<&'a [Felt]> { let calls_len_felt = calldata.get(calls_len_index)?; let calls_len: usize = (*calls_len_felt).try_into().ok()?; @@ -357,36 +351,48 @@ fn aggregate_transfer_amount_from_execute_from_outside_calldata(calldata: &[Felt let Ok(decoder) = SequentialCalldataDecoder::new(calls) else { continue; }; - - let total = aggregate_transfer_amount_from_decoded_calls(&decoder, forwarder, gas_token); - if total != Felt::ZERO { - return total; + let mut nested_transfers = HashMap::new(); + collect_gas_transfers_from_decoded_calls(&decoder, forwarder, &mut nested_transfers); + if !nested_transfers.is_empty() { + for (token, amount) in nested_transfers { + let entry = transfers.entry(token).or_insert(Felt::ZERO); + *entry += amount; + } + return; } } - - Felt::ZERO } fn is_execute_from_outside_selector(selector: Felt) -> bool { selector == selector!("execute_from_outside") || selector == selector!("execute_from_outside_v3") } -fn match_transfer_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt, gas_token: Felt) -> Option { +fn match_transfer_call(token: Felt, selector: Felt, calldata: &[Felt], forwarder: Felt) -> Option<(Felt, Felt)> { if selector != selector!("transfer") { return None; } - if token != gas_token { - return None; - } - let recipient = calldata.first()?; if *recipient != forwarder { return None; } let amount = calldata.get(1)?; - Some(*amount) + Some((token, *amount)) +} + +async fn aggregate_transfers_in_strk(client: &Client, transfers: &HashMap) -> Result { + let mut total = Felt::ZERO; + for (token, amount) in transfers { + let amount_in_strk = if *token == Token::STRK_ADDRESS { + *amount + } else { + client.price.convert_token_to_strk(*token, *amount).await? + }; + total += amount_in_strk; + } + + Ok(total) } /// Paymaster executable transaction that can be sent to Starknet @@ -459,13 +465,11 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, token); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_ok()); - let transfer = result.unwrap(); - assert_eq!(transfer.token(), token); - assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount); + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token), Some(&amount)); } #[test] @@ -510,13 +514,11 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, token); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_ok()); - let transfer = result.unwrap(); - assert_eq!(transfer.token(), token); - assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount); + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token), Some(&amount)); } #[test] @@ -550,7 +552,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_err()); } @@ -586,7 +588,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, felt!("0x789")); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_err()); } @@ -611,7 +613,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_err()); } @@ -635,7 +637,7 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, felt!("0x456")); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_err()); } @@ -677,13 +679,11 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, token); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_ok()); - let transfer = result.unwrap(); - assert_eq!(transfer.token(), token); - assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount); + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token), Some(&amount)); } #[test] @@ -730,13 +730,59 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, token); + let result = parameters.find_gas_token_transfers(forwarder); + assert!(result.is_ok()); + + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token), Some(&(amount_one + amount_two))); + } + + #[test] + fn extract_gas_transfer_from_raw_call_collects_multiple_tokens() { + let forwarder = felt!("0x123"); + let token_one = felt!("0x456"); + let token_two = felt!("0x789"); + let amount_one = felt!("0x2"); + let amount_two = felt!("0x4"); + + let calldata = vec![ + felt!("0x1"), // caller + felt!("0x2"), // nonce + felt!("0x3"), // execute_after + felt!("0x4"), // execute_before + Felt::TWO, // num_calls = 2 + // Transfer token one + token_one, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_one, + Felt::ZERO, + // Transfer token two + token_two, + selector!("transfer"), + Felt::THREE, + forwarder, + amount_two, + Felt::ZERO, + Felt::ZERO, // signature length + ]; + + let parameters = ExecutableDirectInvokeParameters { + user: Felt::ZERO, + execute_from_outside_call: Call { + to: felt!("0x999"), + selector: selector!("execute_from_outside"), + calldata, + }, + }; + + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_ok()); - let transfer = result.unwrap(); - assert_eq!(transfer.token(), token); - assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount_one + amount_two); + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token_one), Some(&amount_one)); + assert_eq!(transfers.get(&token_two), Some(&amount_two)); } #[test] @@ -814,13 +860,11 @@ mod tests { }, }; - let result = parameters.find_gas_token_transfer(forwarder, token); + let result = parameters.find_gas_token_transfers(forwarder); assert!(result.is_ok()); - let transfer = result.unwrap(); - assert_eq!(transfer.token(), token); - assert_eq!(transfer.recipient(), forwarder); - assert_eq!(transfer.amount(), amount_outer + amount_one + amount_two); + let transfers = result.unwrap(); + assert_eq!(transfers.get(&token), Some(&(amount_outer + amount_one + amount_two))); } // TODO: enable when we can fix starknet image From 519bb71aeb29a341b6f7b790d6aa8d316c2988b7 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Mon, 2 Feb 2026 14:55:17 -0500 Subject: [PATCH 7/7] Document estimate transaction steps --- crates/paymaster-execution/src/execution/execute.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/paymaster-execution/src/execution/execute.rs b/crates/paymaster-execution/src/execution/execute.rs index 8478c3ca..2414f230 100644 --- a/crates/paymaster-execution/src/execution/execute.rs +++ b/crates/paymaster-execution/src/execution/execute.rs @@ -177,6 +177,7 @@ impl ExecutableTransaction { } pub async fn estimate_transaction(self, client: &Client) -> Result { + // Parse calldata to collect all transfer funding to the forwarder (including nested execute_from_outside). let transfers = match &self.transaction { ExecutableTransactionParameters::Invoke { invoke, .. } => invoke.find_gas_token_transfers(self.forwarder)?, ExecutableTransactionParameters::DeployAndInvoke { invoke, .. } => invoke.find_gas_token_transfers(self.forwarder)?, @@ -184,6 +185,7 @@ impl ExecutableTransaction { _ => return Err(Error::InvalidTypedData), }; + // Build an initial call set using the configured gas token amount (if present) for estimation. let gas_token = self.parameters.gas_token(); let gas_token_amount = transfers.get(&gas_token).cloned().unwrap_or(Felt::ZERO); let calls = self.build_calls(TokenTransfer::new(gas_token, self.gas_tank_address, gas_token_amount)); @@ -191,14 +193,17 @@ impl ExecutableTransaction { let estimated_calls = client.estimate(&calls, self.parameters.tip()).await?; let fee_estimate = estimated_calls.estimate(); + // Compute the actual paid fee in STRK (including overhead) for validation. let paid_fee_in_strk = self.compute_paid_fee(client, Felt::from(fee_estimate.overall_fee)).await?; let final_fee_estimate = fee_estimate.update_overall_fee(paid_fee_in_strk); + // Validate total funding across all tokens by converting to STRK. let total_funding_in_strk = aggregate_transfers_in_strk(client, &transfers).await?; if total_funding_in_strk < paid_fee_in_strk { return Err(Error::MaxAmountTooLow(paid_fee_in_strk.to_hex_string())); } + // Convert the paid fee back into the configured gas token for the final paymaster execute call. let token_price = client.price.fetch_token(gas_token).await?; let paid_fee_in_token = convert_strk_to_token(&token_price, paid_fee_in_strk, true)?;