diff --git a/crates/context/Cargo.toml b/crates/context/Cargo.toml index deea0ab991..99ecacb848 100644 --- a/crates/context/Cargo.toml +++ b/crates/context/Cargo.toml @@ -76,4 +76,5 @@ optional_eip3541 = [] optional_eip3607 = [] optional_no_base_fee = [] optional_priority_fee_check = [] +optional_gasless = [] optional_fee_charge = [] diff --git a/crates/context/interface/Cargo.toml b/crates/context/interface/Cargo.toml index d8f6e7f137..b6a10825e0 100644 --- a/crates/context/interface/Cargo.toml +++ b/crates/context/interface/Cargo.toml @@ -58,3 +58,4 @@ serde = [ # Deprecated, please use `serde` feature instead. serde-json = ["serde"] +optional_gasless = [] diff --git a/crates/context/interface/src/cfg.rs b/crates/context/interface/src/cfg.rs index 6454557e92..e2edb88230 100644 --- a/crates/context/interface/src/cfg.rs +++ b/crates/context/interface/src/cfg.rs @@ -59,6 +59,11 @@ pub trait Cfg { /// Returns whether the priority fee check is disabled. fn is_priority_fee_check_disabled(&self) -> bool; + /// Returns whether gasless transactions are allowed. + #[cfg(feature = "optional_gasless")] + fn is_gasless_allowed(&self) -> bool { + false + } /// Returns whether the fee charge is disabled. fn is_fee_charge_disabled(&self) -> bool; } diff --git a/crates/context/interface/src/transaction.rs b/crates/context/interface/src/transaction.rs index 474d3e6ff7..7f9fdeb898 100644 --- a/crates/context/interface/src/transaction.rs +++ b/crates/context/interface/src/transaction.rs @@ -211,3 +211,18 @@ pub trait Transaction { Ok(effective_balance_spending) } } + +/// Returns true if the transaction is a zero-fee transaction, independent of any config. +/// +/// Rules: +/// - Legacy: gas_price == 0 +/// - EIP-1559: max_fee_per_gas == 0 && max_priority_fee_per_gas == 0 +pub fn is_gasless(tx: &T) -> bool { + match TransactionType::from(tx.tx_type()) { + TransactionType::Legacy => tx.gas_price() == 0, + TransactionType::Eip1559 => { + tx.max_fee_per_gas() == 0 && tx.max_priority_fee_per_gas().unwrap_or_default() == 0 + } + _ => false, + } +} diff --git a/crates/context/src/cfg.rs b/crates/context/src/cfg.rs index 5df0b66446..93ae0595ba 100644 --- a/crates/context/src/cfg.rs +++ b/crates/context/src/cfg.rs @@ -104,6 +104,11 @@ pub struct CfgEnv { /// By default, it is set to `false`. #[cfg(feature = "optional_priority_fee_check")] pub disable_priority_fee_check: bool, + /// Allow gasless transactions + /// + /// By default, it is set to `false`. + #[cfg(feature = "optional_gasless")] + pub allow_gasless: bool, /// Disables fee charging for transactions. /// This is useful when executing `eth_call` for example, on OP-chains where setting the base fee /// to 0 isn't sufficient. @@ -165,6 +170,8 @@ impl CfgEnv { disable_base_fee: false, #[cfg(feature = "optional_priority_fee_check")] disable_priority_fee_check: false, + #[cfg(feature = "optional_gasless")] + allow_gasless: false, #[cfg(feature = "optional_fee_charge")] disable_fee_charge: false, } @@ -214,6 +221,8 @@ impl CfgEnv { disable_base_fee: self.disable_base_fee, #[cfg(feature = "optional_priority_fee_check")] disable_priority_fee_check: self.disable_priority_fee_check, + #[cfg(feature = "optional_gasless")] + allow_gasless: self.allow_gasless, #[cfg(feature = "optional_fee_charge")] disable_fee_charge: self.disable_fee_charge, } @@ -283,6 +292,12 @@ impl + Copy> Cfg for CfgEnv { self.max_blobs_per_tx } + #[inline] + #[cfg(feature = "optional_gasless")] + fn is_gasless_allowed(&self) -> bool { + self.allow_gasless + } + fn max_code_size(&self) -> usize { self.limit_contract_code_size .unwrap_or(eip170::MAX_CODE_SIZE) diff --git a/crates/handler/Cargo.toml b/crates/handler/Cargo.toml index 5110a6f009..5150474b38 100644 --- a/crates/handler/Cargo.toml +++ b/crates/handler/Cargo.toml @@ -75,3 +75,9 @@ serde = [ # Deprecated, please use `serde` feature instead. serde-json = ["serde"] + +# lightlink features +optional_gasless = [ + "context/optional_gasless", + "context-interface/optional_gasless", +] \ No newline at end of file diff --git a/crates/handler/src/gasless.rs b/crates/handler/src/gasless.rs new file mode 100644 index 0000000000..6494863837 --- /dev/null +++ b/crates/handler/src/gasless.rs @@ -0,0 +1,185 @@ +use crate::EvmTr; +#[cfg(feature = "optional_gasless")] +use context_interface::Cfg; +use context_interface::ContextTr; +use context_interface::JournalTr; +use context_interface::Transaction; +use primitives::{address, b256, keccak256, Address, TxKind, B256, U256}; + +/// Predeploy local for GasStation by default +pub const GAS_STATION_PREDEPLOY: Address = address!("0x4300000000000000000000000000000000000001"); + +/// Result of keccak256(abi.encode(uint256(keccak256("gasstation.main")) - 1)) & ~bytes32(uint256(0xff)); +pub const GAS_STATION_STORAGE_LOCATION: B256 = + b256!("0x64d1d9a8a451551a9514a2c08ad4e1552ed316d7dd2778a4b9494de741d8e000"); + +/// Storage slots used by the GasStation contract for a given `to` (recipient) +/// and optional `from`. +#[derive(Clone, Debug)] +pub struct GasStationStorageSlots { + /// Struct base slot (has registered/active packed). + pub registered_slot: B256, + /// TODO REMOVE THIS. + pub active_slot: B256, + /// Slot for `credits` balance. + pub credits_slot: B256, + /// Base slot for nested `whitelist` mapping. + pub nested_whitelist_map_base_slot: B256, + /// Slot for `whitelistEnabled` flag. + pub whitelist_enabled_slot: B256, + /// Slot for `singleUseEnabled` flag. + pub single_use_enabled_slot: B256, + /// Base slot for nested `usedAddresses` mapping. + pub used_addresses_map_base_slot: B256, +} + +/// Calculates the storage slot hashes for a specific registered contract within the GasStation's `contracts` mapping. +/// it returns the base slot for the struct (holding packed fields), the slot for credits, +/// the slot for whitelistEnabled, and the base slot for the nested whitelist mapping. +pub fn calculate_gas_station_slots(registered_contract_address: Address) -> GasStationStorageSlots { + // The 'contracts' mapping is at offset 1 from the storage location + // (dao is at offset 0, contracts is at offset 1) + let contracts_map_slot = U256::from_be_bytes(GAS_STATION_STORAGE_LOCATION.0) + U256::from(1); + + // Calculate the base slot for the struct entry in the mapping + // - left pad the address to 32 bytes + let mut key_padded = [0u8; 32]; + key_padded[12..].copy_from_slice(registered_contract_address.as_slice()); // Left-pad 20-byte address to 32 bytes + // - I expect this is left padded because big endian etc + let map_slot_padded = contracts_map_slot.to_be_bytes::<32>(); + // - keccak256(append(keyPadded, mapSlotPadded...)) + let combined = [key_padded, map_slot_padded].concat(); + let struct_base_slot_hash = keccak256(combined); + + // Calculate subsequent slots by adding offsets to the base slot hash + // New struct layout: bool registered, bool active, address admin (all packed in slot 0) + // uint256 credits (slot 1), bool whitelistEnabled (slot 2), mapping whitelist (slot 3) + // bool singleUseEnabled (slot 4), mapping usedAddresses (slot 5) + let struct_base_slot_u256 = U256::from_be_bytes(struct_base_slot_hash.0); + + // Slot for 'credits' (offset 1 from base - after the packed bools and address) + let credits_slot_u256 = struct_base_slot_u256 + U256::from(1); + let credit_slot_hash = B256::from_slice(&credits_slot_u256.to_be_bytes::<32>()); + + // Slot for 'whitelistEnabled' (offset 2 from base) + let whitelist_enabled_slot_u256 = struct_base_slot_u256 + U256::from(2); + let whitelist_enabled_slot_hash = + B256::from_slice(&whitelist_enabled_slot_u256.to_be_bytes::<32>()); + + // Base slot for the nested 'whitelist' mapping (offset 3 from base) + let nested_whitelist_map_base_slot_u256 = struct_base_slot_u256 + U256::from(3); + let nested_whitelist_map_base_slot_hash = + B256::from_slice(&nested_whitelist_map_base_slot_u256.to_be_bytes::<32>()); + + // Slot for 'singleUseEnabled' (offset 4 from base) + let single_use_enabled_slot_u256 = struct_base_slot_u256 + U256::from(4); + let single_use_enabled_slot_hash = + B256::from_slice(&single_use_enabled_slot_u256.to_be_bytes::<32>()); + + // Base slot for the nested 'usedAddresses' mapping (offset 5 from base) + let used_addresses_map_base_slot_u256 = struct_base_slot_u256 + U256::from(5); + let used_addresses_map_base_slot_hash = + B256::from_slice(&used_addresses_map_base_slot_u256.to_be_bytes::<32>()); + + GasStationStorageSlots { + registered_slot: struct_base_slot_hash, + active_slot: struct_base_slot_hash, + credits_slot: credit_slot_hash, + whitelist_enabled_slot: whitelist_enabled_slot_hash, + single_use_enabled_slot: single_use_enabled_slot_hash, + nested_whitelist_map_base_slot: nested_whitelist_map_base_slot_hash, + used_addresses_map_base_slot: used_addresses_map_base_slot_hash, + } +} + +/// Computes the storage slot hash for a nested mapping +pub fn calculate_nested_mapping_slot(key: Address, base_slot: B256) -> B256 { + // Left-pad the address to 32 bytes + let mut key_padded = [0u8; 32]; + key_padded[12..].copy_from_slice(key.as_slice()); // Left-pad 20-byte address to 32 bytes + + // The base_slot is already 32 bytes (B256) + let map_base_slot_padded = base_slot.0; + + // Combine: key first, then base slot + let combined = [key_padded, map_base_slot_padded].concat(); + keccak256(combined) +} + +/// Applies gasless accounting after execution if the transaction is marked gasless and is a call. +/// Updates credits and single-use flag as needed. Returns an error string on failure. +pub fn apply_gasless_post_execution( + evm: &mut EVM, + gas_used: u64, +) -> Result<(), String> { + if !is_gasless_effective(evm.ctx_ref()) { + return Ok(()); + } + + if let TxKind::Call(target_address) = evm.ctx().tx().kind() { + let gas_station_storage_slots = calculate_gas_station_slots(target_address); + let (tx, journal) = evm.ctx().tx_journal_mut(); + let caller = tx.caller(); + + // Load gas station account and mark as touched + if journal.load_account(GAS_STATION_PREDEPLOY).is_err() { + return Err("Failed to load gas station account".to_string()); + } + journal.touch_account(GAS_STATION_PREDEPLOY); + + // Load available credits and update them based on gas used + let credits_slot = gas_station_storage_slots.credits_slot.into(); + let available_credits = journal + .sload(GAS_STATION_PREDEPLOY, credits_slot) + .unwrap_or_default() + .data; + let gas_used_u256 = U256::from(gas_used); + let new_credits = available_credits.saturating_sub(gas_used_u256); + + if journal + .sstore(GAS_STATION_PREDEPLOY, credits_slot, new_credits) + .is_err() + { + return Err("Failed to update credits slot".to_string()); + } + + // Check if the contract is single-use and mark caller as used if so + let single_use_slot = gas_station_storage_slots.single_use_enabled_slot.into(); + let is_single_use = !journal + .sload(GAS_STATION_PREDEPLOY, single_use_slot) + .unwrap_or_default() + .data + .is_zero(); + + if is_single_use { + // Mark caller as used in the usedAddresses mapping + let used_slot_b256 = calculate_nested_mapping_slot( + caller, + gas_station_storage_slots.used_addresses_map_base_slot, + ); + let used_slot = used_slot_b256.into(); + if journal + .sstore(GAS_STATION_PREDEPLOY, used_slot, U256::ONE) + .is_err() + { + return Err("Failed to update usedAddresses slot".to_string()); + } + } + } + + Ok(()) +} + +#[inline] +/// Returns true if gasless transactions are allowed by config and the tx is zero-fee (gasless) +pub fn is_gasless_effective(ctx: &C) -> bool { + #[cfg(feature = "optional_gasless")] + { + ctx.cfg().is_gasless_allowed() && context_interface::transaction::is_gasless(ctx.tx()) + } + #[cfg(not(feature = "optional_gasless"))] + { + let _ = ctx; + false + } +} diff --git a/crates/handler/src/handler.rs b/crates/handler/src/handler.rs index ad0c73ba96..4585cd91a7 100644 --- a/crates/handler/src/handler.rs +++ b/crates/handler/src/handler.rs @@ -1,10 +1,11 @@ use crate::{ - evm::FrameTr, execution, post_execution, pre_execution, validation, EvmTr, FrameResult, - ItemOrResult, + evm::FrameTr, execution, gasless, post_execution, pre_execution, validation, EvmTr, + FrameResult, ItemOrResult, }; use context::result::{ExecutionResult, FromStringError}; use context::LocalContextTr; use context_interface::context::ContextError; + use context_interface::ContextTr; use context_interface::{ result::{HaltReasonTr, InvalidHeader, InvalidTransaction}, @@ -228,6 +229,12 @@ pub trait Handler { self.reimburse_caller(evm, exec_result)?; // Pay transaction fees to beneficiary self.reward_beneficiary(evm, exec_result)?; + + // Apply gasless accounting if applicable + if let Err(e) = gasless::apply_gasless_post_execution(evm, exec_result.gas().used()) { + return Err(Self::Error::from_string(e)); + } + Ok(()) } diff --git a/crates/handler/src/lib.rs b/crates/handler/src/lib.rs index a220439545..b766819058 100644 --- a/crates/handler/src/lib.rs +++ b/crates/handler/src/lib.rs @@ -15,6 +15,8 @@ pub mod evm; pub mod execution; mod frame; mod frame_data; +/// Gasless execution utilities and types. +pub mod gasless; /// Handler implementation for orchestrating EVM execution. pub mod handler; /// EVM instruction set implementations and tables. @@ -36,6 +38,10 @@ pub use api::{ExecuteCommitEvm, ExecuteEvm}; pub use evm::{EvmTr, FrameTr}; pub use frame::{return_create, ContextTrDbError, EthFrame}; pub use frame_data::{CallFrame, CreateFrame, FrameData, FrameResult}; +pub use gasless::{ + calculate_gas_station_slots, calculate_nested_mapping_slot, GasStationStorageSlots, + GAS_STATION_PREDEPLOY, GAS_STATION_STORAGE_LOCATION, +}; pub use handler::{EvmTrError, Handler}; pub use item_or_result::{FrameInitOrResult, ItemOrResult}; pub use mainnet_builder::{MainBuilder, MainContext, MainnetContext, MainnetEvm}; diff --git a/crates/handler/src/validation.rs b/crates/handler/src/validation.rs index 241022bf9f..7e5ae44de1 100644 --- a/crates/handler/src/validation.rs +++ b/crates/handler/src/validation.rs @@ -92,12 +92,17 @@ pub fn validate_tx_env( let tx_type = context.tx().tx_type(); let tx = context.tx(); - let base_fee = if context.cfg().is_base_fee_check_disabled() { + #[cfg_attr(not(feature = "optional_gasless"), allow(unused_mut))] + let mut base_fee = if context.cfg().is_base_fee_check_disabled() { None } else { Some(context.block().basefee() as u128) }; + if crate::gasless::is_gasless_effective(&context) { + base_fee = None; + } + let tx_type = TransactionType::from(tx_type); // Check chain_id if config is enabled. @@ -546,4 +551,66 @@ mod tests { _ => panic!("execution result is not Success"), } } + + #[cfg(feature = "optional_gasless")] + #[test] + fn test_optional_gasless_eip1559_zero_fees_allowed() { + let caller = address!("0x0000000000000000000000000000000000100001"); + let to = address!("0x0000000000000000000000000000000000200002"); + + let ctx = Context::mainnet() + .modify_block_chained(|b| { + b.basefee = 100; + }) + .modify_cfg_chained(|c| { + c.allow_gasless = true; + }) + .with_db(CacheDB::::default()); + + let result = ctx.build_mainnet().transact_commit( + TxEnv::builder() + .tx_type(Some(2)) // EIP-1559 + .caller(caller) + .kind(TxKind::Call(to)) + .gas_limit(21_000) + .gas_price(0) // max_fee_per_gas = 0 + .gas_priority_fee(Some(0)) + .build() + .unwrap(), + ); + + assert!(matches!(result, Ok(ExecutionResult::Success { .. }))); + } + + #[cfg(feature = "optional_gasless")] + #[test] + fn test_optional_gasless_eip1559_zero_fees_disallowed() { + let caller = address!("0x0000000000000000000000000000000000300003"); + let to = address!("0x0000000000000000000000000000000000400004"); + + let ctx = Context::mainnet() + .modify_block_chained(|b| { + b.basefee = 100; + }) + .with_db(CacheDB::::default()); + + let result = ctx.build_mainnet().transact_commit( + TxEnv::builder() + .tx_type(Some(2)) // EIP-1559 + .caller(caller) + .kind(TxKind::Call(to)) + .gas_limit(21_000) + .gas_price(0) // max_fee_per_gas = 0 + .gas_priority_fee(Some(0)) + .build() + .unwrap(), + ); + + assert!(matches!( + result, + Err(EVMError::Transaction( + InvalidTransaction::GasPriceLessThanBasefee + )) + )); + } } diff --git a/crates/op-revm/Cargo.toml b/crates/op-revm/Cargo.toml index c2abb9f6af..aa9ca1417e 100644 --- a/crates/op-revm/Cargo.toml +++ b/crates/op-revm/Cargo.toml @@ -46,6 +46,7 @@ std = [ hashbrown = ["revm/hashbrown"] serde = ["dep:serde", "revm/serde", "alloy-primitives/serde"] portable = ["revm/portable"] +optional_gasless = ["revm/optional_gasless"] dev = [ "memory_limit", diff --git a/crates/op-revm/src/handler.rs b/crates/op-revm/src/handler.rs index 732be00847..86338a4245 100644 --- a/crates/op-revm/src/handler.rs +++ b/crates/op-revm/src/handler.rs @@ -14,6 +14,7 @@ use revm::{ }, handler::{ evm::FrameTr, + gasless::is_gasless_effective, handler::EvmTrError, post_execution::{self, reimburse_caller}, pre_execution::validate_account_nonce_and_code, @@ -118,7 +119,7 @@ where let mut additional_cost = U256::ZERO; // The L1-cost fee is only computed for Optimism non-deposit transactions. - if !is_deposit && !ctx.cfg().is_fee_charge_disabled() { + if !is_deposit && !ctx.cfg().is_fee_charge_disabled() && !is_gasless_effective(ctx) { // L1 block info is stored in the context for later use. // and it will be reloaded from the database if it is not for the current block. if ctx.chain().l2_block != block_number { @@ -290,7 +291,10 @@ where ) -> Result<(), Self::Error> { let mut additional_refund = U256::ZERO; - if evm.ctx().tx().tx_type() != DEPOSIT_TRANSACTION_TYPE { + // If not a deposit transaction and not a gasless transaction, reimburse the operator fee. + if evm.ctx().tx().tx_type() != DEPOSIT_TRANSACTION_TYPE + && !is_gasless_effective(evm.ctx_ref()) + { let spec = evm.ctx().cfg().spec(); additional_refund = evm .ctx() @@ -314,7 +318,7 @@ where // Prior to Regolith, deposit transactions did not receive gas refunds. let is_gas_refund_disabled = is_deposit && !is_regolith; - if !is_gas_refund_disabled { + if !is_gas_refund_disabled && !is_gasless_effective(evm.ctx_ref()) { frame_result.gas_mut().set_final_refund( evm.ctx() .cfg() @@ -333,7 +337,8 @@ where let is_deposit = evm.ctx().tx().tx_type() == DEPOSIT_TRANSACTION_TYPE; // Transfer fee to coinbase/beneficiary. - if is_deposit { + // When optional_gasless is enabled, also treat gasless transactions as free. + if is_deposit || is_gasless_effective(evm.ctx_ref()) { return Ok(()); } diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 4a63fe8e4a..ca9e2a3fa8 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -92,6 +92,7 @@ optional_block_gas_limit = ["context/optional_block_gas_limit"] optional_eip3541 = ["context/optional_eip3541"] optional_eip3607 = ["context/optional_eip3607"] optional_no_base_fee = ["context/optional_no_base_fee"] +optional_gasless = ["context/optional_gasless", "handler/optional_gasless"] # Precompiles features: Please look at the comments in `precompile` crate for more information.