From 1f8406e1f84c3e742f1a18a930f50088e74ffdff Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:09:06 +0200 Subject: [PATCH 1/3] feat(consensus): activate native multisig accounts --- Cargo.lock | 1 + crates/contracts/src/precompiles/mod.rs | 1 + crates/evm/src/block.rs | 117 +++- ...transaction__gas_estimation_snapshots.snap | 142 ++--- crates/payload/builder/Cargo.toml | 1 + crates/payload/builder/src/prewarming.rs | 94 ++- .../precompiles/src/account_keychain/mod.rs | 108 ++++ crates/precompiles/src/lib.rs | 25 +- .../precompiles/src/native_multisig/auth.rs | 4 +- .../src/native_multisig/dispatch.rs | 2 +- crates/precompiles/src/native_multisig/mod.rs | 6 +- crates/primitives/src/transaction/multisig.rs | 9 - crates/revm/Cargo.toml | 2 +- crates/revm/src/error.rs | 52 +- crates/revm/src/evm.rs | 96 ++- crates/revm/src/handler.rs | 156 ++++- crates/revm/src/handler/tests.rs | 602 ++++++++++++++++-- crates/revm/src/lib.rs | 1 + crates/revm/src/native_multisig.rs | 273 ++++++++ crates/revm/src/signature_gas.rs | 80 ++- 20 files changed, 1588 insertions(+), 184 deletions(-) create mode 100644 crates/revm/src/native_multisig.rs diff --git a/Cargo.lock b/Cargo.lock index 0056297da6..1aa9eab2ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13808,6 +13808,7 @@ dependencies = [ "alloy-eips", "alloy-primitives", "alloy-rlp", + "alloy-sol-types", "crossbeam-channel", "metrics", "proptest", diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 34dbaf354d..8335dc85f4 100644 --- a/crates/contracts/src/precompiles/mod.rs +++ b/crates/contracts/src/precompiles/mod.rs @@ -81,4 +81,5 @@ pub const SYSTEM_PRECOMPILES: &[(Address, TempoHardfork)] = &[ (STORAGE_CREDITS_ADDRESS, TempoHardfork::T7), (CURRENT_COMMITTEE_ADDRESS, TempoHardfork::T8), (ZONE_FACTORY_ADDRESS, TempoHardfork::T10), + (NATIVE_MULTISIG_ADDRESS, TempoHardfork::T12), ]; diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 36a214810f..6f629d6b09 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -29,13 +29,13 @@ use std::collections::{HashMap, HashSet}; use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks}; use tempo_contracts::precompiles::{ ADDRESS_REGISTRY_ADDRESS, CURRENT_COMMITTEE_ADDRESS, ICurrentCommittee, INITIAL_FACTORY_OWNER, - InitialZoneFactoryAccount, RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, - STORAGE_CREDITS_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, - initial_zone_factory_state, t12_zone_factory_state, + InitialZoneFactoryAccount, NATIVE_MULTISIG_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, + SIGNATURE_VERIFIER_ADDRESS, STORAGE_CREDITS_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, initial_zone_factory_state, t12_zone_factory_state, }; use tempo_primitives::{ SubBlock, SubBlockMetadata, TempoReceipt, TempoTxEnvelope, TempoTxType, - subblock::PartialValidatorKey, + subblock::PartialValidatorKey, transaction::MULTISIG_RECOVERY_FACTORY, }; use tempo_revm::{TempoHaltReason, evm::TempoContext}; use tracing::trace; @@ -249,6 +249,30 @@ where Ok(()) } + /// Reserves the recovery-factory address without installing its EVM runtime. + /// Keeps recovery cross-chain-only by preventing the canonical factory from being deployed on Tempo. + /// Replaces code, raises the nonce to one, and preserves balance, storage, and higher nonces. + fn reserve_multisig_recovery_factory_at_boundary(&mut self) -> Result<(), BlockExecutionError> { + let db = self.inner.evm.db_mut(); + let info = db + .basic(MULTISIG_RECOVERY_FACTORY) + .map_err(BlockExecutionError::other)? + .unwrap_or_default(); + let marker = Bytecode::new_legacy([0xef].into()); + let marker_hash = marker.hash_slow(); + if info.code_hash == marker_hash && info.nonce >= 1 { + return Ok(()); + } + + let mut account = Account::from(info); + account.info.code_hash = marker_hash; + account.info.code = Some(marker); + account.info.nonce = account.info.nonce.max(1); + account.mark_touch(); + db.commit(EvmState::from_iter([(MULTISIG_RECOVERY_FACTORY, account)])); + Ok(()) + } + /// Installs and initializes the complete TIP-1091 state when T10 first becomes active. fn deploy_zone_factory_at_boundary(&mut self) -> Result<(), BlockExecutionError> { let [factory, portal, verifier, messenger] = @@ -647,6 +671,10 @@ where if self.inner.spec.is_t8_active_at_timestamp(timestamp) { self.deploy_precompile_at_boundary(CURRENT_COMMITTEE_ADDRESS, &[])?; } + if self.inner.spec.is_t12_active_at_timestamp(timestamp) { + self.reserve_multisig_recovery_factory_at_boundary()?; + self.deploy_precompile_at_boundary(NATIVE_MULTISIG_ADDRESS, &[])?; + } if self.inner.spec.is_t10_active_at_timestamp(timestamp) { self.deploy_zone_factory_at_boundary()?; } @@ -867,6 +895,7 @@ mod tests { use reth_chainspec::EthChainSpec; use reth_revm::{State, state::AccountInfo}; use revm::{ + Database, context::result::{ExecutionResult, ResultGas}, database::EmptyDB, }; @@ -1989,22 +2018,96 @@ mod tests { } #[test] - fn test_apply_pre_execution_deploys_guard_code() { - // Dev chainspec has t6Time: 0, so T6 is active at any timestamp. - let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); + fn test_apply_pre_execution_pre_t12_does_not_deploy_native_multisig_code() { + let chainspec = test_chainspec(); let mut db = State::builder().with_bundle_update().build(); let mut executor = TestExecutorBuilder::default() .with_parent_beacon_block_root(B256::ZERO) .build(&mut db, &chainspec); + executor.evm_mut().ctx_mut().block.inner.timestamp = U256::from(u64::MAX); executor.apply_pre_execution_changes().unwrap(); drop(executor); + let acc = db.load_cache_account(NATIVE_MULTISIG_ADDRESS).unwrap(); + let info = acc.account_info(); + assert!( + info.is_none() || info.unwrap().is_empty_code_hash(), + "NativeMultisig code should not be deployed before T12" + ); + let factory = db.load_cache_account(MULTISIG_RECOVERY_FACTORY).unwrap(); + let info = factory.account_info(); + assert!( + info.is_none() || info.unwrap().is_empty_code_hash(), + "recovery factory should not be reserved before T12" + ); + let acc = db.load_cache_account(RECEIVE_POLICY_GUARD_ADDRESS).unwrap(); let info = acc.account_info().unwrap(); assert!(!info.is_empty_code_hash()); } + #[test] + fn test_apply_pre_execution_deploys_t12_native_multisig_code_and_reserves_factory() { + let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); + let mut db = State::builder().with_bundle_update().build(); + let mut executor = TestExecutorBuilder::default() + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + + executor.apply_pre_execution_changes().unwrap(); + drop(executor); + + let acc = db.load_cache_account(NATIVE_MULTISIG_ADDRESS).unwrap(); + let info = acc.account_info().unwrap(); + assert!(!info.is_empty_code_hash()); + + let factory = db.load_cache_account(MULTISIG_RECOVERY_FACTORY).unwrap(); + let info = factory.account_info().unwrap(); + assert_eq!(info.nonce, 1); + assert_eq!( + info.code.as_ref().unwrap().original_byte_slice(), + [0xef], + "Tempo reserves the address with only the marker, not the recovery runtime" + ); + } + + #[test] + fn test_t12_recovery_factory_reservation_preserves_account_state() { + let chainspec = Arc::new(TempoChainSpec::from_genesis(DEV.genesis().clone())); + let mut db = State::builder().with_bundle_update().build(); + let storage_slot = U256::from(7); + let storage_value = U256::from(11); + let old_code = Bytecode::new_legacy([0x60, 0x00].into()); + db.insert_account_with_storage( + MULTISIG_RECOVERY_FACTORY, + AccountInfo { + balance: U256::from(42), + nonce: 7, + code_hash: old_code.hash_slow(), + code: Some(old_code), + ..Default::default() + }, + [(storage_slot, storage_value)].into_iter().collect(), + ); + + let mut executor = TestExecutorBuilder::default() + .with_parent_beacon_block_root(B256::ZERO) + .build(&mut db, &chainspec); + executor.apply_pre_execution_changes().unwrap(); + drop(executor); + + let factory = db.load_cache_account(MULTISIG_RECOVERY_FACTORY).unwrap(); + let info = factory.account_info().unwrap(); + assert_eq!(info.balance, U256::from(42)); + assert_eq!(info.nonce, 7); + assert_eq!(info.code.as_ref().unwrap().original_byte_slice(), [0xef]); + assert_eq!( + db.storage(MULTISIG_RECOVERY_FACTORY, storage_slot).unwrap(), + storage_value + ); + } + #[test] fn test_pre_t3_does_not_deploy_signature_verifier_code() { // Moderato does not have T4 active (no t3Time set), so the code should NOT be deployed. diff --git a/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap b/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap index 2d209cebb5..9cbca51ca4 100644 --- a/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap +++ b/crates/node/tests/it/tempo_transaction/snapshots/it__tempo_transaction__gas_estimation_snapshots.snap @@ -21,74 +21,74 @@ baseline: 274318 "webauthn::batch_5_transfers": 574475 "webauthn::batch_10_transfers": 603776 "webauthn::contract_creation": 785926 -"key_auth_p256_0_limits::noop": 538498 -"key_auth_secp256k1_0_limits::noop": 538498 -"key_auth_webauthn_0_limits::noop": 538498 -"key_auth_secp256k1_witness::noop": 542127 -"key_auth_p256_0_limits::transfer": 808740 -"key_auth_secp256k1_0_limits::transfer": 808740 -"key_auth_webauthn_0_limits::transfer": 808740 -"key_auth_secp256k1_witness::transfer": 812369 -"key_auth_p256_0_limits::batch_2_transfers": 814600 -"key_auth_secp256k1_0_limits::batch_2_transfers": 814600 -"key_auth_webauthn_0_limits::batch_2_transfers": 814600 -"key_auth_secp256k1_witness::batch_2_transfers": 818229 -"key_auth_p256_0_limits::batch_5_transfers": 832181 -"key_auth_secp256k1_0_limits::batch_5_transfers": 832181 -"key_auth_webauthn_0_limits::batch_5_transfers": 832181 -"key_auth_secp256k1_witness::batch_5_transfers": 835809 -"key_auth_p256_0_limits::batch_10_transfers": 861481 -"key_auth_secp256k1_0_limits::batch_10_transfers": 861481 -"key_auth_webauthn_0_limits::batch_10_transfers": 861481 -"key_auth_secp256k1_witness::batch_10_transfers": 865110 -"key_auth_p256_1_limit::noop": 1042466 -"key_auth_secp256k1_1_limit::noop": 1042466 -"key_auth_webauthn_1_limit::noop": 1042466 -"key_auth_secp256k1_witness_1_limit::noop": 1046095 -"key_auth_p256_1_limit::transfer": 1312708 -"key_auth_secp256k1_1_limit::transfer": 1312708 -"key_auth_webauthn_1_limit::transfer": 1312708 -"key_auth_secp256k1_witness_1_limit::transfer": 1316337 -"key_auth_p256_1_limit::batch_2_transfers": 1318568 -"key_auth_secp256k1_1_limit::batch_2_transfers": 1318568 -"key_auth_webauthn_1_limit::batch_2_transfers": 1318568 -"key_auth_secp256k1_witness_1_limit::batch_2_transfers": 1322197 -"key_auth_p256_1_limit::batch_5_transfers": 1336149 -"key_auth_secp256k1_1_limit::batch_5_transfers": 1336149 -"key_auth_webauthn_1_limit::batch_5_transfers": 1336149 -"key_auth_secp256k1_witness_1_limit::batch_5_transfers": 1339777 -"key_auth_p256_1_limit::batch_10_transfers": 1365450 -"key_auth_secp256k1_1_limit::batch_10_transfers": 1365450 -"key_auth_webauthn_1_limit::batch_10_transfers": 1365450 -"key_auth_secp256k1_witness_1_limit::batch_10_transfers": 1369078 -"key_auth_secp256k1_target_any_selector::transfer": 1823732 -"key_auth_p256_3_limits::noop": 2050403 -"key_auth_secp256k1_3_limits::noop": 2050403 -"key_auth_webauthn_3_limits::noop": 2050403 -"key_auth_p256_3_limits::transfer": 2320645 -"key_auth_secp256k1_3_limits::transfer": 2320645 -"key_auth_webauthn_3_limits::transfer": 2320645 -"key_auth_p256_3_limits::batch_2_transfers": 2326505 -"key_auth_secp256k1_3_limits::batch_2_transfers": 2326505 -"key_auth_webauthn_3_limits::batch_2_transfers": 2326505 -"key_auth_p256_3_limits::batch_5_transfers": 2344085 -"key_auth_secp256k1_3_limits::batch_5_transfers": 2344085 -"key_auth_webauthn_3_limits::batch_5_transfers": 2344085 -"key_auth_p256_3_limits::batch_10_transfers": 2373386 -"key_auth_secp256k1_3_limits::batch_10_transfers": 2373386 -"key_auth_webauthn_3_limits::batch_10_transfers": 2373386 -"key_auth_secp256k1_selector_any_recipient::transfer": 2586740 -"key_auth_secp256k1_selector_recipient::transfer": 3347732 -"keychain_secp256k1::noop": 541623 -"keychain_secp256k1::transfer": 812066 -"keychain_secp256k1::batch_2_transfers": 818229 -"keychain_secp256k1::batch_5_transfers": 836716 -"keychain_secp256k1::batch_10_transfers": 867529 -"keychain_p256::noop": 546662 -"keychain_p256::transfer": 817106 -"keychain_p256::batch_2_transfers": 823268 -"keychain_p256::batch_5_transfers": 841756 -"keychain_p256::batch_10_transfers": 872569 -"keychain_secp256k1_selector_any_recipient::transfer": 2592485 -"keychain_secp256k1_selector_recipient::transfer": 3351562 -"keychain_secp256k1_target_any_selector::transfer": 1827260 +"key_auth_p256_0_limits::noop": 540716 +"key_auth_secp256k1_0_limits::noop": 540716 +"key_auth_webauthn_0_limits::noop": 540716 +"key_auth_secp256k1_witness::noop": 544344 +"key_auth_p256_0_limits::transfer": 810958 +"key_auth_secp256k1_0_limits::transfer": 810958 +"key_auth_webauthn_0_limits::transfer": 810958 +"key_auth_secp256k1_witness::transfer": 814586 +"key_auth_p256_0_limits::batch_2_transfers": 816818 +"key_auth_secp256k1_0_limits::batch_2_transfers": 816818 +"key_auth_webauthn_0_limits::batch_2_transfers": 816818 +"key_auth_secp256k1_witness::batch_2_transfers": 820446 +"key_auth_p256_0_limits::batch_5_transfers": 834398 +"key_auth_secp256k1_0_limits::batch_5_transfers": 834398 +"key_auth_webauthn_0_limits::batch_5_transfers": 834398 +"key_auth_secp256k1_witness::batch_5_transfers": 838027 +"key_auth_p256_0_limits::batch_10_transfers": 863699 +"key_auth_secp256k1_0_limits::batch_10_transfers": 863699 +"key_auth_webauthn_0_limits::batch_10_transfers": 863699 +"key_auth_secp256k1_witness::batch_10_transfers": 867327 +"key_auth_p256_1_limit::noop": 1044684 +"key_auth_secp256k1_1_limit::noop": 1044684 +"key_auth_webauthn_1_limit::noop": 1044684 +"key_auth_secp256k1_witness_1_limit::noop": 1048312 +"key_auth_p256_1_limit::transfer": 1314926 +"key_auth_secp256k1_1_limit::transfer": 1314926 +"key_auth_webauthn_1_limit::transfer": 1314926 +"key_auth_secp256k1_witness_1_limit::transfer": 1318554 +"key_auth_p256_1_limit::batch_2_transfers": 1320786 +"key_auth_secp256k1_1_limit::batch_2_transfers": 1320786 +"key_auth_webauthn_1_limit::batch_2_transfers": 1320786 +"key_auth_secp256k1_witness_1_limit::batch_2_transfers": 1324415 +"key_auth_p256_1_limit::batch_5_transfers": 1338366 +"key_auth_secp256k1_1_limit::batch_5_transfers": 1338366 +"key_auth_webauthn_1_limit::batch_5_transfers": 1338366 +"key_auth_secp256k1_witness_1_limit::batch_5_transfers": 1341995 +"key_auth_p256_1_limit::batch_10_transfers": 1367667 +"key_auth_secp256k1_1_limit::batch_10_transfers": 1367667 +"key_auth_webauthn_1_limit::batch_10_transfers": 1367667 +"key_auth_secp256k1_witness_1_limit::batch_10_transfers": 1371296 +"key_auth_secp256k1_target_any_selector::transfer": 1825950 +"key_auth_p256_3_limits::noop": 2052620 +"key_auth_secp256k1_3_limits::noop": 2052620 +"key_auth_webauthn_3_limits::noop": 2052620 +"key_auth_p256_3_limits::transfer": 2322862 +"key_auth_secp256k1_3_limits::transfer": 2322862 +"key_auth_webauthn_3_limits::transfer": 2322862 +"key_auth_p256_3_limits::batch_2_transfers": 2328722 +"key_auth_secp256k1_3_limits::batch_2_transfers": 2328722 +"key_auth_webauthn_3_limits::batch_2_transfers": 2328722 +"key_auth_p256_3_limits::batch_5_transfers": 2346303 +"key_auth_secp256k1_3_limits::batch_5_transfers": 2346303 +"key_auth_webauthn_3_limits::batch_5_transfers": 2346303 +"key_auth_p256_3_limits::batch_10_transfers": 2375604 +"key_auth_secp256k1_3_limits::batch_10_transfers": 2375604 +"key_auth_webauthn_3_limits::batch_10_transfers": 2375604 +"key_auth_secp256k1_selector_any_recipient::transfer": 2588958 +"key_auth_secp256k1_selector_recipient::transfer": 3349950 +"keychain_secp256k1::noop": 543840 +"keychain_secp256k1::transfer": 814284 +"keychain_secp256k1::batch_2_transfers": 820446 +"keychain_secp256k1::batch_5_transfers": 838934 +"keychain_secp256k1::batch_10_transfers": 869746 +"keychain_p256::noop": 548880 +"keychain_p256::transfer": 819323 +"keychain_p256::batch_2_transfers": 825486 +"keychain_p256::batch_5_transfers": 843974 +"keychain_p256::batch_10_transfers": 874786 +"keychain_secp256k1_selector_any_recipient::transfer": 2594703 +"keychain_secp256k1_selector_recipient::transfer": 3353780 +"keychain_secp256k1_target_any_selector::transfer": 1829477 diff --git a/crates/payload/builder/Cargo.toml b/crates/payload/builder/Cargo.toml index d2b916fdad..84d6b4e305 100644 --- a/crates/payload/builder/Cargo.toml +++ b/crates/payload/builder/Cargo.toml @@ -52,6 +52,7 @@ crossbeam-channel.workspace = true tokio.workspace = true [dev-dependencies] +alloy-sol-types.workspace = true proptest.workspace = true [features] diff --git a/crates/payload/builder/src/prewarming.rs b/crates/payload/builder/src/prewarming.rs index 73a61f89c6..6da5dd2ebf 100644 --- a/crates/payload/builder/src/prewarming.rs +++ b/crates/payload/builder/src/prewarming.rs @@ -470,13 +470,22 @@ fn is_parallel_candidate(tx: &BestTransaction) -> bool { .transaction .nonce_key() .is_some_and(|nonce_key| !nonce_key.is_zero()) + // Stateful signature validation depends on account code/storage that replay does not bind. + && !tx + .transaction + .inner() + .as_aa() + .is_some_and(|tx| { + tx.signature().is_keychain() || tx.signature().is_multisig() + }) } #[cfg(test)] mod tests { use super::*; use alloy_consensus::{BlockHeader, Header, Signed, TxLegacy}; - use alloy_primitives::{Address, Bytes, Signature, TxKind, U256}; + use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; + use alloy_sol_types::SolCall; use reth_evm::{ConfigureEvm, NextBlockEnvAttributes}; use reth_primitives_traits::{ Recovered, SealedHeader, transaction::error::InvalidTransactionError, @@ -493,7 +502,14 @@ mod tests { }; use tempo_chainspec::TempoChainSpec; use tempo_evm::{TempoEvmConfig, TempoNextBlockEnvAttributes}; - use tempo_primitives::{TempoHeader, TempoPrimitives, TempoTxEnvelope}; + use tempo_precompiles::{PATH_USD_ADDRESS, tip20::ITIP20}; + use tempo_primitives::{ + AASigned, TempoHeader, TempoPrimitives, TempoTransaction, TempoTxEnvelope, + transaction::{ + Call, KeychainSignature, MultisigConfig, MultisigOwner, MultisigSignature, + PrimitiveSignature, TempoSignature, + }, + }; use tempo_transaction_pool::transaction::TempoPooledTransaction; #[derive(Debug, Default)] @@ -582,6 +598,80 @@ mod tests { }) } + fn test_aa_payment(sender: Address, signature: TempoSignature) -> BestTransaction { + let tx = TempoTransaction { + chain_id: 42431, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 20_000_000_000, + gas_limit: 1_000_000, + calls: vec![Call { + to: TxKind::Call(PATH_USD_ADDRESS), + value: U256::ZERO, + input: ITIP20::transferCall { + to: Address::random(), + amount: U256::ONE, + } + .abi_encode() + .into(), + }], + nonce_key: U256::ONE, + ..Default::default() + }; + let envelope: TempoTxEnvelope = AASigned::new_unhashed(tx, signature).into(); + let pooled = TempoPooledTransaction::new(Recovered::new_unchecked(envelope, sender)); + assert!(pooled.is_payment()); + Arc::new(ValidPoolTransaction { + transaction_id: TransactionId::new(0u64.into(), 0), + transaction: pooled, + propagate: true, + timestamp: Instant::now(), + origin: TransactionOrigin::External, + authority_ids: None, + }) + } + + #[test] + fn stateful_signature_payments_are_not_parallel_candidates() { + let account = Address::random(); + let primitive = test_aa_payment( + account, + TempoSignature::Primitive(PrimitiveSignature::default()), + ); + assert!(is_parallel_candidate(&primitive)); + + let keychain = test_aa_payment( + account, + TempoSignature::Keychain(KeychainSignature::new( + account, + PrimitiveSignature::default(), + )), + ); + assert!(!is_parallel_candidate(&keychain)); + + let config = MultisigConfig { + salt: B256::ZERO, + version: 0, + threshold: 1, + owners: vec![MultisigOwner { + owner: account, + weight: 1, + }], + }; + let multisig_account = config.derive_account().unwrap(); + let multisig = test_aa_payment( + multisig_account, + TempoSignature::Multisig( + MultisigSignature::try_new( + multisig_account, + config, + vec![PrimitiveSignature::default().to_bytes()], + ) + .unwrap(), + ), + ); + assert!(!is_parallel_candidate(&multisig)); + } + struct TestPrewarming { prewarming: Option, executor: TaskExecutor, diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 7595638d4d..bdbb8977df 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -29,6 +29,7 @@ use tempo_primitives::{TempoAddressExt, transaction::CallScope as RlpCallScope}; use crate::{ ACCOUNT_KEYCHAIN_ADDRESS, error::Result, + native_multisig::NativeMultisig, storage::{Handler, Mapping, Set}, tip20_factory::TIP20Factory, }; @@ -284,6 +285,13 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } + if self.storage.spec().is_t12() + && !NativeMultisig::new() + .get_config_commitment(key_id)? + .is_zero() + { + return Err(AccountKeychainError::invalid_key_id().into()); + } // Admin keys are explicit access-key rows; the root key remains implicit. if is_admin && key_id == msg_sender { return Err(AccountKeychainError::invalid_key_id().into()); @@ -1634,6 +1642,7 @@ mod tests { DEFAULT_FEE_TOKEN, IAccountKeychain::SignatureType, legacySetAllowedCallsCall as setAllowedCallsCall, }; + use tempo_primitives::transaction::{MultisigConfig, MultisigOwner}; #[test] fn test_rlp_input_cost() { @@ -1791,6 +1800,42 @@ mod tests { tempo_alloy::provider::keychain::KeyRestrictions::default().into() } + fn native_multisig_config() -> MultisigConfig { + MultisigConfig { + salt: B256::ZERO, + version: 0, + threshold: 1, + owners: vec![ + MultisigOwner { + owner: Address::from([0x11; 20]), + weight: 1, + }, + MultisigOwner { + owner: Address::from([0x22; 20]), + weight: 1, + }, + ], + } + } + + fn store_native_multisig_commitment(config: &MultisigConfig) -> Result
{ + let account = config.derive_account().map_err(|error| { + TempoPrecompileError::Fatal(format!("invalid test multisig config: {error}")) + })?; + let mut current = config.clone(); + current.version = 1; + let commitment = current.commitment().map_err(|error| { + TempoPrecompileError::Fatal(format!("invalid test multisig config: {error}")) + })?; + let mut storage = StorageCtx; + storage.sstore( + tempo_contracts::precompiles::NATIVE_MULTISIG_ADDRESS, + NativeMultisig::config_commitment_storage_slot(account), + U256::from_be_slice(commitment.as_slice()), + )?; + Ok(account) + } + #[test] fn test_t6_root_authorizes_admin_key() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); @@ -1817,6 +1862,69 @@ mod tests { }) } + #[test] + fn test_t12_native_multisig_account_can_authorize_access_key() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); + let config = native_multisig_config(); + let key_id = Address::from([0x33; 20]); + + StorageCtx::enter(&mut storage, || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + let account = store_native_multisig_commitment(&config)?; + + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.set_transaction_key(Address::ZERO)?; + + authorize_key( + &mut keychain, + account, + authorizeKeyCall { + keyId: key_id, + signatureType: SignatureType::Secp256k1, + config: unrestricted_restrictions(), + }, + )?; + + assert!(keychain.is_active_key(account, key_id)?); + + Ok(()) + }) + } + + #[test] + fn test_t12_native_multisig_account_cannot_be_authorized_as_access_key() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); + let config = native_multisig_config(); + let account = Address::from([0x44; 20]); + + StorageCtx::enter(&mut storage, || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + let multisig_account = store_native_multisig_commitment(&config)?; + + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + assert_invalid_key_id( + keychain + .authorize_key( + account, + multisig_account, + SignatureType::Secp256k1, + unrestricted_restrictions(), + None, + ) + .expect_err("native multisig account must not become an access key"), + ); + assert!(!keychain.is_active_key(account, multisig_account)?); + + Ok(()) + }) + } + #[test] fn test_t6_is_admin_key_uses_active_key_status() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs index 13bbfe0134..087e97d760 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -229,6 +229,8 @@ pub fn extend_tempo_precompiles( Some(ValidatorConfig::create_precompile(&env)) } else if *address == ACCOUNT_KEYCHAIN_ADDRESS { Some(AccountKeychain::create_precompile(&env)) + } else if *address == NATIVE_MULTISIG_ADDRESS && env.cfg.spec.is_t12() { + Some(NativeMultisig::create_precompile(&env)) } else if *address == VALIDATOR_CONFIG_V2_ADDRESS { Some(ValidatorConfigV2::create_precompile(&env)) } else if *address == SIGNATURE_VERIFIER_ADDRESS && env.cfg.spec.is_t3() { @@ -1236,6 +1238,27 @@ mod tests { ); } + #[test] + fn test_native_multisig_registered_at_t12_only() { + let mut t11 = CfgEnv::::default(); + t11.set_spec_and_mainnet_gas_params(TempoHardfork::T11); + assert!( + test_tempo_precompiles(&t11) + .get(&NATIVE_MULTISIG_ADDRESS) + .is_none(), + "NativeMultisig should NOT be registered before T12" + ); + + let mut t12 = CfgEnv::::default(); + t12.set_spec_and_mainnet_gas_params(TempoHardfork::T12); + assert!( + test_tempo_precompiles(&t12) + .get(&NATIVE_MULTISIG_ADDRESS) + .is_some(), + "NativeMultisig should be registered at T12" + ); + } + #[test] fn test_p256verify_availability_across_t1c_boundary() { let p256verify_address = p256verify_address(); @@ -1272,7 +1295,7 @@ mod tests { #[test] fn multisig_account_eligibility_uses_registered_precompiles() { - let spec = TempoHardfork::T11; + let spec = TempoHardfork::T12; let p256verify_address = p256verify_address(); for address in EthPrecompiles::new(ethereum_precompile_spec(spec)).warm_addresses() { assert!(!is_valid_multisig_account(*address, spec)); diff --git a/crates/precompiles/src/native_multisig/auth.rs b/crates/precompiles/src/native_multisig/auth.rs index 473b1d1312..e0a13e9543 100644 --- a/crates/precompiles/src/native_multisig/auth.rs +++ b/crates/precompiles/src/native_multisig/auth.rs @@ -214,7 +214,7 @@ mod tests { #[test] fn initial_witness_requires_zero_commitment() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); StorageCtx::enter(&mut storage, || { NativeMultisig::new().validate_authorization_state(&signature(0)) })?; @@ -223,7 +223,7 @@ mod tests { #[test] fn current_witness_requires_matching_commitment() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); let signature = signature(1); StorageCtx::enter(&mut storage, || { let mut multisig = NativeMultisig::new(); diff --git a/crates/precompiles/src/native_multisig/dispatch.rs b/crates/precompiles/src/native_multisig/dispatch.rs index e2b8df279d..9cefb2ebb5 100644 --- a/crates/precompiles/src/native_multisig/dispatch.rs +++ b/crates/precompiles/src/native_multisig/dispatch.rs @@ -60,7 +60,7 @@ mod tests { #[test] fn selector_coverage() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); StorageCtx::enter(&mut storage, || { let mut multisig = NativeMultisig::new(); let unsupported = check_selector_coverage( diff --git a/crates/precompiles/src/native_multisig/mod.rs b/crates/precompiles/src/native_multisig/mod.rs index 9219f40e41..5a6811e6b1 100644 --- a/crates/precompiles/src/native_multisig/mod.rs +++ b/crates/precompiles/src/native_multisig/mod.rs @@ -267,7 +267,7 @@ mod tests { #[test] fn derive_account_is_stateless() -> eyre::Result<()> { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); let config = initial_config(); let expected = config.derive_account().unwrap(); @@ -292,7 +292,7 @@ mod tests { fn updates_replace_one_commitment_and_emit_config() -> eyre::Result<()> { let config = initial_config(); let account = config.derive_account().unwrap(); - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11) + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12) .with_tx_kind(TxKind::Call(NATIVE_MULTISIG_ADDRESS)); let first_owner = address!("0000000000000000000000000000000000000022"); @@ -437,7 +437,7 @@ mod tests { ]; for (case, tx_kind, origin, directly_authorized, transaction_key) in cases { - let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T12); if let Some(tx_kind) = tx_kind { storage = storage.with_tx_kind(tx_kind); } diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index 0a4d6cbb74..ab127e4c8e 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -1658,15 +1658,6 @@ mod tests { fn multisig_signature_json_rejects_structured_form() { let json = serde_json::json!({ "account": Address::repeat_byte(0x11), - "config": { - "salt": B256::ZERO, - "version": 1, - "threshold": 1, - "owners": [{ - "owner": indexed_owner(2), - "weight": 1, - }], - }, "signatures": [], }); let error = serde_json::from_value::(json) diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 2bd5aad79a..1ddc5789c0 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -30,6 +30,7 @@ revm.workspace = true alloy-evm = { workspace = true, features = ["std"] } alloy-primitives.workspace = true alloy-consensus.workspace = true +alloy-rlp.workspace = true alloy-sol-types.workspace = true auto_impl.workspace = true @@ -41,7 +42,6 @@ thiserror.workspace = true eyre.workspace = true alloy-primitives = { workspace = true, features = ["rand"] } alloy-eips.workspace = true -alloy-rlp.workspace = true alloy-signer.workspace = true alloy-signer-local.workspace = true base64.workspace = true diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 67224e0f15..79965d2db9 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -151,6 +151,10 @@ pub enum TempoInvalidTransaction { #[error("failed to recover signer from KeyAuthorization signature")] KeyAuthorizationSignatureRecoveryFailed, + /// Keychain encoding is not valid for a key authorization signature. + #[error("key authorization signatures cannot use keychain encoding")] + InvalidKeyAuthorizationSignature, + /// KeyAuthorization not signed by root account. /// /// The KeyAuthorization must be signed by the root account (transaction caller), @@ -237,10 +241,29 @@ pub enum TempoInvalidTransaction { #[error("keychain operations are not supported in subblock transactions")] KeychainOpInSubblockTransaction, - /// Native multisig transactions are not active. + /// Native multisig transactions are not active on this hardfork. #[error("native multisig transactions are not active")] NativeMultisigNotActive, + /// Native multisig transaction shape or stateless policy is invalid. + /// + /// This is deterministic for the transaction payload and is treated as a bad transaction. + #[error("native multisig invalid transaction: {reason}")] + NativeMultisigInvalidTransaction { + /// Validation error details. + reason: String, + }, + + /// Native multisig state or configuration validation failed. + /// + /// This can depend on the account's current native multisig storage and is not treated as a + /// bad transaction. + #[error("native multisig validation failed: {reason}")] + NativeMultisigValidationFailed { + /// Validation error details. + reason: String, + }, + /// Fee payment error. #[error(transparent)] CollectFeePreTx(#[from] FeePaymentError), @@ -307,6 +330,7 @@ impl TempoInvalidTransaction { | Self::AccessKeyRecoveryFailed | Self::AccessKeyCannotAuthorizeOtherKeys | Self::KeyAuthorizationSignatureRecoveryFailed + | Self::InvalidKeyAuthorizationSignature | Self::KeyAuthorizationNotSignedByRoot { .. } | Self::KeychainUserAddressMismatch { .. } | Self::KeyAuthorizationChainIdMismatch { .. } @@ -317,6 +341,7 @@ impl TempoInvalidTransaction { | Self::ExpiringNonceNonceNotZero | Self::SubblockTransactionMustHaveZeroFee | Self::KeychainOpInSubblockTransaction + | Self::NativeMultisigInvalidTransaction { .. } | Self::LegacyKeychainSignature | Self::CallsValidation(_) => true, @@ -331,6 +356,7 @@ impl TempoInvalidTransaction { | Self::KeychainPrecompileError { .. } | Self::KeychainValidationFailed { .. } | Self::NativeMultisigNotActive + | Self::NativeMultisigValidationFailed { .. } | Self::CollectFeePreTx(_) | Self::NonceManagerError(_) | Self::V2KeychainBeforeActivation => false, @@ -528,6 +554,30 @@ mod tests { } } + #[test] + fn test_native_multisig_bad_transaction_classification() { + let invalid_shape = TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "invalid shape".to_string(), + }; + assert!( + invalid_shape.is_bad_transaction(), + "stateless native multisig shape/policy failures should be bad transactions" + ); + + let validation_failed = TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "below threshold".to_string(), + }; + assert!( + !validation_failed.is_bad_transaction(), + "config/quorum validation can depend on native multisig account state" + ); + + assert!( + !TempoInvalidTransaction::NativeMultisigNotActive.is_bad_transaction(), + "native multisig transactions can become valid after fork activation" + ); + } + #[test] fn test_is_nonce_too_low() { let err = TempoInvalidTransaction::EthInvalidTransaction(InvalidTransaction::NonceTooLow { diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 886138903c..bd1601408f 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -329,7 +329,9 @@ mod tests { use crate::gas_params::{tempo_gas_params, tempo_gas_params_with_amsterdam}; use alloy_eips::eip7702::Authorization; use alloy_evm::FromRecoveredTx; - use alloy_primitives::{Address, Bytes, TxKind, U256, bytes, hex}; + use alloy_primitives::{Address, B256, Bytes, TxKind, U256, bytes, hex}; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; use alloy_sol_types::{SolCall, SolError}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use p256::{ @@ -355,8 +357,11 @@ mod tests { use tempo_precompiles::{ AuthorizedKey, DelegateCallNotAllowed, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, STORAGE_CREDITS_ADDRESS, + native_multisig::NativeMultisig, nonce::NonceManager, - storage::{FromWord, Handler, StorageCtx, evm::EvmPrecompileStorageProvider}, + storage::{ + FromWord, Handler, StorageActions, StorageCtx, evm::EvmPrecompileStorageProvider, + }, storage_credits::{CreditMode, StorageCredits}, test_util::TIP20Setup, tip20::{ITIP20, TIP20Token}, @@ -364,7 +369,8 @@ mod tests { use tempo_primitives::{ TempoTransaction, transaction::{ - KeyAuthorization, KeychainSignature, SignatureType, TempoSignedAuthorization, + KeyAuthorization, KeychainSignature, MultisigConfig, MultisigOwner, MultisigSignature, + SignatureType, TempoSignedAuthorization, multisig_digest, tempo_transaction::Call, tt_signature::{ PrimitiveSignature, TempoSignature, WebAuthnSignature, derive_p256_address, @@ -499,6 +505,55 @@ mod tests { evm } + fn initial_multisig_fixture( + salt: u8, + ) -> eyre::Result<(TempoEvm, ()>, TempoTxEnv, Address)> { + let signer = PrivateKeySigner::from_bytes(&B256::from([0x11; 32]))?; + let config = MultisigConfig { + salt: B256::repeat_byte(salt), + version: 0, + threshold: 1, + owners: vec![MultisigOwner { + owner: signer.address(), + weight: 1, + }], + }; + let account = config.derive_account().map_err(eyre::Report::msg)?; + let tx = TxBuilder::new() + .call_identity(&[]) + .gas_limit(2_000_000) + .build(); + let digest = multisig_digest(tx.signature_hash(), account, 0); + let owner_signature = + PrimitiveSignature::Secp256k1(signer.sign_hash_sync(&digest)?).to_bytes(); + let signed_tx = tx.into_signed(TempoSignature::Multisig( + MultisigSignature::try_new(account, config, vec![owner_signature]).unwrap(), + )); + + let db = CacheDB::new(EmptyDB::new()); + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T12; + cfg.gas_params = tempo_gas_params(TempoHardfork::T12); + + let ctx = Context::mainnet() + .with_db(db) + .with_block(Default::default()) + .with_cfg(cfg) + .with_tx(Default::default()); + + let mut evm = TempoEvm::new(ctx, ()); + fund_account(&mut evm, account); + StorageCtx::enter_ctx(&mut evm.ctx, StorageActions::disabled(), || { + NativeMultisig::new().initialize() + })?; + + Ok(( + evm, + TempoTxEnv::from_recovered_tx(&signed_tx, account), + account, + )) + } + /// Create an EVM with T4 hardfork, the TIP-1016 regular/state gas split /// enabled (`enable_amsterdam_eip8037` plus the matching gas table), and a /// funded account. @@ -870,6 +925,41 @@ mod tests { // ==================== End Test Utility Functions ==================== + #[test] + fn test_t12_initial_multisig_does_not_persist_config() -> eyre::Result<()> { + let (mut evm, tx_env, account) = initial_multisig_fixture(0x44)?; + let result = evm.transact_commit(tx_env)?; + assert!(result.is_success()); + assert_eq!( + evm.ctx + .db() + .basic_ref(account)? + .map(|info| info.nonce) + .unwrap_or_default(), + 1 + ); + StorageCtx::enter_ctx(&mut evm.ctx, StorageActions::disabled(), || { + assert!( + NativeMultisig::new() + .get_config_commitment(account)? + .is_zero() + ); + Ok::<_, tempo_precompiles::error::TempoPrecompileError>(()) + })?; + + Ok(()) + } + + #[test] + fn test_t12_initial_multisig_simulates_repeatedly() -> eyre::Result<()> { + let (mut evm, mut tx_env, _) = initial_multisig_fixture(0x55)?; + tx_env.execution_context = crate::ExecutionContext::Simulation; + assert!(evm.transact(tx_env.clone())?.result.is_success()); + assert!(evm.transact(tx_env)?.result.is_success()); + + Ok(()) + } + #[test_case::test_case(TempoHardfork::T1)] #[test_case::test_case(TempoHardfork::T1C)] fn test_access_millis_timestamp(spec: TempoHardfork) -> eyre::Result<()> { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index daaae8914f..b5687603dc 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -44,6 +44,7 @@ use tempo_precompiles::{ SelectorRule as PrecompileSelectorRule, TokenLimit, }, error::TempoPrecompileError, + native_multisig::NativeMultisig, nonce::{INonce::getNonceCall, NonceManager}, storage::{ Handler as _, PrecompileStorageProvider, StorageActions, StorageCtx, @@ -55,7 +56,8 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - SignatureType, TEMPO_EXPIRING_NONCE_KEY, calc_gas_balance_spending, validate_calls, + MultisigSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, calc_gas_balance_spending, + validate_calls, }, }; @@ -64,9 +66,17 @@ use crate::{ error::{FeePaymentError, TempoHaltReason}, evm::TempoContext, gas_credits, + native_multisig::{ + validate_native_multisig_signature_account, + validate_state as validate_native_multisig_state, + }, signature_gas::tempo_signature_verification_gas, }; +#[cfg(test)] +use crate::signature_gas::{ + NATIVE_MULTISIG_COMMITMENT_READ_GAS, primitive_signature_verification_gas, +}; /// Base gas for KeyAuthorization (22k storage + 5k buffer), signature gas added at runtime const KEY_AUTH_BASE_GAS: u64 = 27_000; @@ -297,9 +307,9 @@ fn calculate_key_authorization_gas( gas_params: &GasParams, spec: tempo_chainspec::hardfork::TempoHardfork, ) -> (u64, u64) { - // All signature types pay ECRECOVER_GAS (3k) as the baseline since - // tempo_signature_verification_gas assumes ecrecover is already in base 21k. - // For KeyAuthorization, we're doing an additional signature verification. + // KeyAuthorization signature verification is additional to the transaction signature. The + // generic signature schedule subtracts the traditional secp256k1 verification covered by the + // base transaction stipend, so add it back here. let sig_gas = ECRECOVER_GAS + tempo_signature_verification_gas(&key_auth.signature); let num_limits = key_auth @@ -394,6 +404,13 @@ impl TempoEvmHandler { ) -> Result<(), EVMError> { let ctx = evm.ctx_mut(); let channel_open_context_hash = ctx.tx.channel_open_context_hash(); + let directly_authorized_multisig = ctx + .tx + .tempo_tx_env + .as_deref() + .and_then(|env| env.signature.as_multisig()) + .map(MultisigSignature::account) + .unwrap_or_default(); // Seed transient precompile transaction context for both regular execution and RPC // simulations (`eth_call` / `eth_estimateGas`) that go through handler execution. @@ -405,8 +422,13 @@ impl TempoEvmHandler { StorageActions::disabled(), || { let mut keychain = AccountKeychain::new(); + keychain.set_transaction_key(Address::ZERO)?; keychain.set_tx_origin(ctx.tx.caller())?; + let mut multisig = NativeMultisig::new(); + multisig.set_tx_origin(ctx.tx.caller())?; + multisig.set_directly_authorized_account(directly_authorized_multisig)?; + if let Some(channel_open_context_hash) = channel_open_context_hash { let mut channel_reserve = TIP20ChannelReserve::new(); channel_reserve.set_channel_open_context_hash(channel_open_context_hash)?; @@ -1016,9 +1038,6 @@ where // Load the fee payer balance let account_balance = get_token_balance(journal, fee_token, fee_payer)?; - // Load caller's account - let mut caller_account = journal.load_account_with_code_mut(tx.caller())?.data; - let nonce_key = tx .tempo_tx_env .as_ref() @@ -1026,6 +1045,18 @@ where .unwrap_or_default(); let spec = cfg.spec(); + let early_multisig_balance = validate_native_multisig_state( + journal, + block, + cfg, + tx, + actions.clone(), + init_gas, + account_balance, + )?; + + // Load caller's account mutably after multisig validation. + let mut caller_account = journal.load_account_with_code_mut(tx.caller())?.data; // Only treat as expiring nonce if T1 is active, otherwise treat as regular 2D nonce let is_expiring_nonce = nonce_key == TEMPO_EXPIRING_NONCE_KEY && spec.is_t1(); @@ -1230,7 +1261,10 @@ where } // calculate the new balance after the fee is collected. - let new_balance = calculate_caller_fee(account_balance, tx, block, cfg)?; + let new_balance = match early_multisig_balance { + Some(balance) => balance, + None => calculate_caller_fee(account_balance, tx, block, cfg)?, + }; // doing max to avoid underflow as new_balance can be more than account // balance if `cfg.is_balance_check_disabled()` is true. let gas_balance_spending = core::cmp::max(account_balance, new_balance) - new_balance; @@ -1244,6 +1278,9 @@ where let mut loaded_tx_access_key = None; // Access key whose fee-token spending limit was debited during fee collection, if any. let mut keychain_fee_key = None; + // Access key selected for execution. Root-authorized sidecars temporarily clear this + // while mutating the keychain, then restore it before user calls execute. + let mut keychain_transaction_key = Address::ZERO; let mut same_tx_key_authorization_use = false; if let Some(tempo_tx_env) = tx.tempo_tx_env.as_ref() && let Some(keychain_sig) = tempo_tx_env.signature.as_keychain() @@ -1269,6 +1306,7 @@ where .key_id(&tempo_tx_env.signature_hash) .map_err(|_| TempoInvalidTransaction::AccessKeyRecoveryFailed)? }; + keychain_transaction_key = access_key_addr; let key_auth = tempo_tx_env.key_authorization.as_ref(); // Classify whether this keychain-signed tx is using the same access key that the @@ -1334,7 +1372,10 @@ where // T6 adds admin delegation: a keychain signer may authorize a different // child key only if the acting transaction key is itself an active admin key. - if key_auth.is_some() && !key.is_admin { + // A multisig sidecar is independently authorized by the root quorum. + if key_auth.is_some_and(|key_auth| !key_auth.signature.is_multisig()) + && !key.is_admin + { return Err( TempoInvalidTransaction::AccessKeyCannotAuthorizeOtherKeys.into() ); @@ -1367,14 +1408,17 @@ where && let Some(key_auth) = tempo_tx_env.key_authorization.as_ref() { let auth_signer = key_auth - .recover_signer() + .recover_authorizing_account() .map_err(|_| TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed)?; if auth_signer != tx.caller { let key_auth_sig_type: u8 = key_auth .signature .signature_type() - .expect("non-primitive key authorization rejected in validate_env") + .ok_or_else(|| TempoInvalidTransaction::KeychainValidationFailed { + reason: "admin key authorization signature type is not registered" + .to_string(), + })? .into(); let signer_is_admin = match loaded_tx_access_key { Some(loaded_key) @@ -1591,6 +1635,13 @@ where allowedCalls: precompile_allowed_calls, }; + let quorum_authorized = key_auth.signature.is_multisig(); + if quorum_authorized { + keychain + .set_transaction_key(Address::ZERO) + .map_err(|e| EVMError::Custom(e.to_string()))?; + } + // Call precompile to authorize the key (same phase as nonce increment). let result = if key_auth.is_admin() { keychain.authorize_admin_key( @@ -1608,6 +1659,11 @@ where key_auth.witness(), ) }; + if quorum_authorized { + keychain + .set_transaction_key(keychain_transaction_key) + .map_err(|e| EVMError::Custom(e.to_string()))?; + } match result { // all is good, we can do execution. @@ -1804,19 +1860,6 @@ where ) .map_err(TempoInvalidTransaction::from)?; - if aa_env.signature.is_multisig() - || aa_env - .key_authorization - .as_ref() - .is_some_and(|authorization| authorization.signature.is_multisig()) - || aa_env - .tempo_authorization_list - .iter() - .any(|authorization| authorization.signature().is_multisig()) - { - return Err(TempoInvalidTransaction::NativeMultisigNotActive.into()); - } - // Access-key CREATE is a cheap structural rejection that does not depend on any // per-call scope walk or state mutation. Rejecting it here keeps validation work // constant and avoids entering CREATE execution paths that require special protocol- @@ -1847,20 +1890,72 @@ where if let Some(key_auth) = &aa_env.key_authorization && key_auth.signature.is_keychain() { - return Err(TempoInvalidTransaction::KeychainValidationFailed { - reason: "key authorization signatures cannot use keychain encoding".to_string(), - } - .into()); + return Err(TempoInvalidTransaction::InvalidKeyAuthorizationSignature.into()); } let has_keychain_fields = aa_env.key_authorization.is_some() || aa_env.signature.is_keychain(); + let has_native_multisig_authorization = aa_env.signature.is_multisig() + || aa_env + .key_authorization + .as_ref() + .is_some_and(|key_auth| key_auth.signature.is_multisig()); + + if has_native_multisig_authorization && !cfg.spec.is_t12() { + return Err(TempoInvalidTransaction::NativeMultisigNotActive.into()); + } + + if aa_env.subblock_transaction && has_native_multisig_authorization { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "native multisig signatures are not allowed in subblock transactions" + .to_string(), + } + .into()); + } + + if let Some(multisig_signature) = aa_env.signature.as_multisig() { + validate_native_multisig_signature_account( + multisig_signature, + tx.caller, + *cfg.spec(), + "multisig signature account does not match transaction caller", + )?; + } if aa_env.subblock_transaction && has_keychain_fields { return Err(TempoInvalidTransaction::KeychainOpInSubblockTransaction.into()); } + if aa_env + .tempo_authorization_list + .iter() + .any(|auth| auth.signature().is_multisig()) + { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "native multisig signatures are not allowed in authorization lists" + .to_string(), + } + .into()); + } + if let Some(key_auth) = &aa_env.key_authorization { + if let Some(signature) = key_auth.signature.as_multisig() { + validate_native_multisig_signature_account( + signature, + tx.caller, + *cfg.spec(), + "multisig key authorization must be signed by the transaction caller", + )?; + if key_auth.account != Some(tx.caller) { + return Err(TempoInvalidTransaction::KeychainValidationFailed { + reason: + "multisig-signed key authorization must name the transaction caller" + .to_string(), + } + .into()); + } + } + // Check if this TX is using a Keychain signature (access key). Non-admin access // keys cannot authorize other keys; T6 admin keys can. let mut same_tx_auth_use = false; @@ -1933,7 +2028,7 @@ where } if !cfg.spec.is_t6() { - let auth_signer = key_auth.recover_signer().map_err(|_| { + let auth_signer = key_auth.recover_authorizing_account().map_err(|_| { TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed })?; @@ -1979,7 +2074,7 @@ where } if cfg.spec.is_t6() { - let auth_signer = key_auth.recover_signer().map_err(|_| { + let auth_signer = key_auth.recover_authorizing_account().map_err(|_| { TempoInvalidTransaction::KeyAuthorizationSignatureRecoveryFailed })?; if auth_signer != tx.caller && key_auth.account.is_none() { @@ -1992,6 +2087,7 @@ where if auth_signer == tx.caller && aa_env.signature.is_keychain() && !same_tx_auth_use + && !key_auth.signature.is_multisig() { return Err(TempoInvalidTransaction::KeychainValidationFailed { reason: diff --git a/crates/revm/src/handler/tests.rs b/crates/revm/src/handler/tests.rs index 42523edf62..e03e5cbf3f 100644 --- a/crates/revm/src/handler/tests.rs +++ b/crates/revm/src/handler/tests.rs @@ -1,13 +1,10 @@ use super::*; use crate::{ - FeeTokenResolver, ProtocolFeeManager, TempoBlockEnv, TempoFeeManager, TempoTxEnv, - evm::TempoEvm, - gas_params::tempo_gas_params, - signature_gas::{ - P256_VERIFY_GAS, primitive_signature_verification_gas, tempo_signature_verification_gas, - }, + ExecutionContext, FeeTokenResolver, ProtocolFeeManager, TempoBlockEnv, TempoFeeManager, + TempoTxEnv, evm::TempoEvm, gas_params::tempo_gas_params, signature_gas::P256_VERIFY_GAS, tx::TempoBatchCallEnv, }; +use alloy_eips::eip7702::{Authorization, RecoveredAuthority}; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; use proptest::prelude::*; use revm::{ @@ -20,17 +17,18 @@ use revm::{ instructions::utility::IntoU256, }, primitives::hardfork::SpecId, + state::AccountInfo, }; use tempo_chainspec::hardfork::TempoHardfork; -use tempo_contracts::precompiles::{DEFAULT_FEE_TOKEN, ITIPFeeAMM}; +use tempo_contracts::precompiles::{DEFAULT_FEE_TOKEN, ITIPFeeAMM, NATIVE_MULTISIG_ADDRESS}; use tempo_precompiles::{ - PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, storage::ContractStorage, test_util::TIP20Setup, - tip_fee_manager::TipFeeManager, + PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, account_keychain::getTransactionKeyCall, + storage::ContractStorage, test_util::TIP20Setup, tip_fee_manager::TipFeeManager, }; use tempo_primitives::transaction::{ Call, KeyAuthorization, KeychainSignature, MultisigConfig, MultisigOwner, MultisigSignature, PrimitiveSignature, RecoveredTempoAuthorization, SignatureType, TempoSignature, - TempoSignedAuthorization, + TempoSignedAuthorization, multisig_digest, tt_signature::{P256SignatureWithPreHash, WebAuthnSignature}, }; @@ -42,6 +40,81 @@ fn create_test_journal() -> Journal> { type TestHandlerEvmResult = Result as revm::Database>::Error, TempoInvalidTransaction>>; +fn native_multisig_config() -> MultisigConfig { + MultisigConfig { + salt: B256::ZERO, + version: 0, + threshold: 1, + owners: vec![ + MultisigOwner { + owner: Address::from([0x11; 20]), + weight: 1, + }, + MultisigOwner { + owner: Address::from([0x22; 20]), + weight: 1, + }, + ], + } +} + +fn single_owner_native_multisig_config(salt: u8, owner: Address) -> MultisigConfig { + MultisigConfig { + salt: B256::repeat_byte(salt), + version: 0, + threshold: 1, + owners: vec![MultisigOwner { owner, weight: 1 }], + } +} + +fn authorization() -> Authorization { + Authorization { + chain_id: U256::ONE, + address: Address::random(), + nonce: 0, + } +} + +fn recovered_authorization( + authority: Address, + signature: TempoSignature, +) -> RecoveredTempoAuthorization { + RecoveredTempoAuthorization::new_unchecked( + TempoSignedAuthorization::new_unchecked(authorization(), signature), + RecoveredAuthority::Valid(authority), + ) +} + +fn store_native_multisig_account( + evm: &mut TempoEvm, ()>, + config: &MultisigConfig, +) { + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + let account = config.derive_account().unwrap(); + let mut current = config.clone(); + current.version = 1; + let commitment = current.commitment().unwrap(); + NativeMultisig::new().storage_mut().sstore( + NATIVE_MULTISIG_ADDRESS, + NativeMultisig::config_commitment_storage_slot(account), + U256::from_be_slice(commitment.as_slice()), + ) + }) + .expect("native multisig setup succeeds"); +} + +fn insert_test_code(evm: &mut TempoEvm, ()>, account: Address) { + let code = revm::bytecode::Bytecode::new_raw(Bytes::from_static(&[0x00])); + evm.inner.ctx.db_mut().insert_account_info( + account, + AccountInfo { + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); +} + struct TestHandlerEvm { evm: TempoEvm, ()>, handler: TempoEvmHandler, ()>, @@ -49,7 +122,12 @@ struct TestHandlerEvm { impl TestHandlerEvm { fn tx(spec: TempoHardfork, configure_tx_env: impl FnOnce(&mut TempoTxEnv)) -> Self { - let mut tx_env = TempoTxEnv::default(); + let mut tx_env = TempoTxEnv { + execution_context: ExecutionContext::Transaction { + tx_hash: B256::ZERO, + }, + ..Default::default() + }; configure_tx_env(&mut tx_env); Self::new(spec, tx_env) } @@ -60,6 +138,9 @@ impl TestHandlerEvm { configure_tx_env: impl FnOnce(&mut TempoTxEnv), ) -> Self { let mut tx_env = TempoTxEnv { + execution_context: ExecutionContext::Transaction { + tx_hash: B256::ZERO, + }, tempo_tx_env: Some(Box::new(aa_env)), ..Default::default() }; @@ -2070,6 +2151,50 @@ fn test_multicall_gas_refund_accounting() { ); } +#[test] +fn test_multicall_exposes_current_target_and_restores_tx_kind() { + const GAS_LIMIT: u64 = 1_000_000; + let original_kind = TxKind::Call(NATIVE_MULTISIG_ADDRESS); + let later_kind = TxKind::Call(Address::repeat_byte(0x44)); + let expected_kinds = [original_kind, later_kind]; + let calls = expected_kinds + .map(|to| Call { + to, + value: U256::ZERO, + input: Bytes::new(), + }) + .to_vec(); + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, make_aa_env(calls.clone()), |tx_env| { + tx_env.inner.kind = original_kind; + tx_env.inner.gas_limit = GAS_LIMIT; + }); + let mut observed = Vec::new(); + + test.handler + .execute_multi_call_with( + &mut test.evm, + GAS_LIMIT, + 0, + calls, + |_handler, evm, call_gas: &mut GasTracker| { + // Nested execution and precompile storage observe this transaction context. + observed.push(evm.ctx().tx().kind()); + Ok(FrameResult::Call(CallOutcome::new( + InterpreterResult::new( + InstructionResult::Stop, + Bytes::new(), + Gas::new(call_gas.remaining()), + ), + 0..0, + ))) + }, + ) + .expect("multicall execution succeeds"); + + assert_eq!(observed, expected_kinds); + assert_eq!(test.evm.ctx().tx().kind(), original_kind); +} + /// Strategy for optional u64 timestamps. fn arb_opt_timestamp() -> impl Strategy> { prop_oneof![Just(None), any::().prop_map(Some)] @@ -3477,6 +3602,173 @@ mod keychain { ); } + #[test] + fn test_t12_multisig_with_committed_config_can_use_keychain_signature() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let access_key = Address::repeat_byte(0x44); + let (mut evm, h) = make_evm(account, access_key, None, TempoHardfork::T12, None, true); + store_native_multisig_account(&mut evm, &config); + + let result = h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "multisig keychain transaction with a committed config should pass, got: {result:?}" + ); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + let keychain = AccountKeychain::new(); + assert_eq!( + keychain.get_transaction_key(getTransactionKeyCall {}, account)?, + access_key + ); + Ok::<_, TempoPrecompileError>(()) + }) + .expect("transaction key read succeeds"); + } + + #[test] + fn test_t12_non_admin_key_relays_multisig_key_authorization() { + let (owner, owner_address) = generate_keypair(); + let config = single_owner_native_multisig_config(0x44, owner_address); + let account = config.derive_account().unwrap(); + let access_key = Address::repeat_byte(0x45); + let child_key = Address::repeat_byte(0x46); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, child_key) + .with_account(account); + let approval = owner + .sign_hash_sync(&multisig_digest( + key_authorization.signature_hash(), + account, + config.version, + )) + .expect("owner signs key authorization"); + let key_authorization = key_authorization.into_signed(TempoSignature::Multisig( + MultisigSignature::try_new( + account, + config, + vec![TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + approval, + ))], + ) + .unwrap(), + )); + let (mut evm, h) = make_evm( + account, + access_key, + Some(key_authorization), + TempoHardfork::T12, + None, + true, + ); + evm.tx.execution_context = ExecutionContext::Transaction { + tx_hash: B256::ZERO, + }; + + h.validate_env(&mut evm) + .expect("multisig key authorization should pass stateless validation"); + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()) + .expect("root quorum should authorize the child key"); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + use tempo_precompiles::account_keychain::getKeyCall; + + let keychain = AccountKeychain::new(); + assert_eq!( + keychain + .get_key(getKeyCall { + account, + keyId: child_key, + }) + .expect("child key read succeeds") + .keyId, + child_key, + "quorum-authorized child key should be registered", + ); + }); + } + + #[test] + fn test_t12_multisig_with_committed_config_and_code_rejects_keychain_signature() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let access_key = Address::repeat_byte(0x44); + let (mut evm, h) = make_evm(account, access_key, None, TempoHardfork::T12, None, true); + store_native_multisig_account(&mut evm, &config); + insert_test_code(&mut evm, account); + + let result = h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { ref reason } + )) if reason.contains("cannot have code") + ), + "multisig access-key transaction must reject code-bearing parent: {result:?}" + ); + } + + #[test] + fn test_t12_key_authorization_rejects_multisig_access_key() { + let (signer, user) = generate_keypair(); + let config = native_multisig_config(); + let multisig_account = config.derive_account().unwrap(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1337, SignatureType::Secp256k1, multisig_account), + ); + let (mut evm, h) = make_evm( + user, + multisig_account, + Some(signed), + TempoHardfork::T12, + None, + false, + ); + store_native_multisig_account(&mut evm, &config); + + let Err(EVMError::Transaction(err)) = + h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()) + else { + panic!("native multisig account must not become an access key"); + }; + assert!(matches!( + &err, + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + if reason.contains("cannot be used as an access key") + )); + assert!(!err.is_bad_transaction()); + } + + #[test] + fn test_t12_ordinary_key_authorization_charges_one_commitment_read() { + let (signer, user) = generate_keypair(); + let access_key = Address::random(); + let signed = sign_key_auth( + &signer, + KeyAuthorization::unrestricted(1337, SignatureType::Secp256k1, access_key), + ); + let (mut evm, h) = make_evm( + user, + access_key, + Some(signed), + TempoHardfork::T12, + None, + false, + ); + let gas_params = tempo_gas_params(TempoHardfork::T12); + let expected = + gas_params.warm_storage_read_cost() + gas_params.cold_storage_additional_cost(); + let mut init_gas = InitialAndFloorGas::default(); + + h.validate_against_state_and_deduct_caller(&mut evm, &mut init_gas) + .expect("ordinary access-key authorization should pass"); + + assert_eq!(init_gas.initial_regular_gas, expected); + } + #[test] fn test_keychain_version_rejection() { let caller = Address::random(); @@ -4484,16 +4776,8 @@ fn test_state_gas_failed_batch_preserves_upfront_create_intrinsic_gas() { } #[test] -fn native_multisig_execution_remains_inactive() { - let config = MultisigConfig { - salt: B256::ZERO, - version: 0, - threshold: 1, - owners: vec![MultisigOwner { - owner: Address::repeat_byte(0x11), - weight: 1, - }], - }; +fn test_t11_rejects_native_multisig_signature() { + let config = native_multisig_config(); let account = config.derive_account().unwrap(); let aa_env = TempoBatchCallEnv { signature: TempoSignature::Multisig( @@ -4502,7 +4786,7 @@ fn native_multisig_execution_remains_inactive() { config, vec![TempoSignature::Primitive(PrimitiveSignature::default())], ) - .expect("valid multisig test fixture"), + .unwrap(), ), aa_calls: vec![Call { to: TxKind::Call(Address::random()), @@ -4515,28 +4799,24 @@ fn native_multisig_execution_remains_inactive() { tx_env.inner.caller = account; }); - assert!(matches!( - test.validate_env(), - Err(EVMError::Transaction( - TempoInvalidTransaction::NativeMultisigNotActive - )) - )); + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigNotActive + )) + ), + "native multisig signatures should be rejected before T12" + ); } #[test] -fn native_multisig_key_authorization_remains_inactive() { - let config = MultisigConfig { - salt: B256::ZERO, - version: 0, - threshold: 1, - owners: vec![MultisigOwner { - owner: Address::repeat_byte(0x11), - weight: 1, - }], - }; +fn test_t11_rejects_native_multisig_key_authorization_signature() { + let config = native_multisig_config(); let account = config.derive_account().unwrap(); let key_authorization = - KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x55)) + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x33)) .with_account(account) .into_signed(TempoSignature::Multisig( MultisigSignature::try_new( @@ -4547,6 +4827,7 @@ fn native_multisig_key_authorization_remains_inactive() { .unwrap(), )); let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), key_authorization: Some(key_authorization), aa_calls: vec![Call { to: TxKind::Call(Address::random()), @@ -4568,7 +4849,44 @@ fn native_multisig_key_authorization_remains_inactive() { } #[test] -fn keychain_key_authorization_is_rejected() { +fn test_t12_unfunded_multisig_rejects_before_owner_verification() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let mut aa_env = make_single_call_env(Bytes::new()); + aa_env.signature = TempoSignature::Multisig( + MultisigSignature::try_new( + account, + config, + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + ) + .unwrap(), + ); + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.gas_limit = 100_000; + tx_env.inner.gas_price = 1; + tx_env.inner.gas_priority_fee = Some(1); + }); + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + TIP20Setup::path_usd(account) + .with_issuer(account) + .apply() + .map(|_| ()) + }) + .expect("pathUSD setup succeeds"); + + assert!(matches!( + test.validate_against_state_and_deduct_caller(), + Err(EVMError::Transaction( + TempoInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::LackOfFundForMaxFee { .. } + ) + )) + )); +} + +#[test] +fn test_t12_rejects_keychain_key_authorization_signature() { let caller = Address::repeat_byte(0x11); let key_authorization = KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x33)) @@ -4586,14 +4904,206 @@ fn keychain_key_authorization_is_rejected() { }], ..Default::default() }; - let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { tx_env.inner.caller = caller; }); - let error = test.validate_env().unwrap_err(); - assert!( - error - .to_string() - .contains("key authorization signatures cannot use keychain encoding") + let Err(EVMError::Transaction(error)) = test.validate_env() else { + panic!("keychain key authorization must be rejected"); + }; + assert_eq!( + error, + TempoInvalidTransaction::InvalidKeyAuthorizationSignature + ); + assert!(error.is_bad_transaction()); +} + +#[test] +fn test_t12_non_multisig_roles_skip_multisig_storage() { + let authority = native_multisig_config().derive_account().unwrap(); + let cases = [ + ( + "primitive roles", + TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + Some(Some(authority)), + ), + ( + "keychain authorization-list entry", + TempoSignature::Keychain(KeychainSignature::new( + authority, + PrimitiveSignature::default(), + )), + None, + ), + ]; + + for (case, authorization_signature, fee_payer) in cases { + let mut aa_env = make_single_call_env(Bytes::new()); + aa_env.tempo_authorization_list = + vec![recovered_authorization(authority, authorization_signature)]; + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + tx_env.fee_payer = fee_payer; + }); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + test.validate_against_state_and_deduct_caller() + .unwrap_or_else(|error| panic!("{case} should not consult multisig state: {error:?}")); + + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "{case} must not read multisig storage: {actions:?}" + ); + } +} + +#[test] +fn test_t12_rejects_multisig_authorization_list_signature() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let signature = TempoSignature::Multisig( + MultisigSignature::try_new( + account, + config, + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + ) + .unwrap(), + ); + let mut aa_env = make_single_call_env(Bytes::new()); + aa_env.tempo_authorization_list = vec![recovered_authorization(account, signature)]; + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + }); + + assert!(matches!( + test.validate_env(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason } + )) if reason.contains("not allowed in authorization lists") + )); +} + +#[test] +fn test_t12_current_multisig_requires_matching_commitment() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let mut current = config.clone(); + current.version = 1; + let mut aa_env = make_single_call_env(Bytes::new()); + aa_env.signature = TempoSignature::Multisig( + MultisigSignature::try_new( + account, + current, + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + ) + .unwrap(), + ); + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.execution_context = ExecutionContext::Simulation; + }); + + assert!(matches!( + test.validate_against_state_and_deduct_caller(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + )) if reason.contains("commitment mismatch") + )); + + store_native_multisig_account(&mut test.evm, &config); + test.validate_against_state_and_deduct_caller() + .expect("matching current witness should pass simulation validation"); +} + +#[test] +fn test_t12_rejects_code_bearing_nested_multisig_owner() { + let owner = Address::repeat_byte(0x11); + let child_config = single_owner_native_multisig_config(0x42, owner); + let child_account = child_config.derive_account().unwrap(); + let parent_config = single_owner_native_multisig_config(0x43, child_account); + let parent_account = parent_config.derive_account().unwrap(); + let nested = MultisigSignature::try_new( + child_account, + child_config, + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + ) + .unwrap(); + let mut aa_env = make_single_call_env(Bytes::new()); + aa_env.signature = TempoSignature::Multisig( + MultisigSignature::try_new( + parent_account, + parent_config, + vec![TempoSignature::Multisig(nested)], + ) + .unwrap(), + ); + let mut test = TestHandlerEvm::aa(TempoHardfork::T12, aa_env, |tx_env| { + tx_env.inner.caller = parent_account; + tx_env.execution_context = ExecutionContext::Simulation; + }); + insert_test_code(&mut test.evm, child_account); + + assert!(matches!( + test.validate_against_state_and_deduct_caller(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + )) if reason.contains("cannot have code") + )); +} + +#[test] +fn test_aa_gas_native_multisig_charges_commitment_and_witness() { + use alloy_rlp::Encodable; + use revm::interpreter::gas::{ + KECCAK256, KECCAK256WORD, STANDARD_TOKEN_COST, get_tokens_in_calldata_istanbul, + }; + + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let signature = MultisigSignature::try_new( + account, + config.clone(), + vec![TempoSignature::Primitive(PrimitiveSignature::default())], + ) + .unwrap(); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(signature); + + let gas_params = tempo_gas_params(TempoHardfork::T12); + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T12, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T12, + ) + .unwrap(); + + let mut witness = Vec::new(); + account.encode(&mut witness); + config.encode(&mut witness); + let keccak = |len: usize| KECCAK256 + KECCAK256WORD * u64::try_from(len.div_ceil(32)).unwrap(); + let expected = NATIVE_MULTISIG_COMMITMENT_READ_GAS + + get_tokens_in_calldata_istanbul(&witness) * STANDARD_TOKEN_COST + + keccak(config.account_salt_preimage_len()) + + keccak(tempo_primitives::transaction::MULTISIG_ACCOUNT_CREATE2_PREIMAGE_LEN) + + keccak(tempo_primitives::transaction::MULTISIG_SIGNATURE_DOMAIN.len() + 32 + 20 + 8); + + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected ); } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 1acda365e5..9755c1c939 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -17,6 +17,7 @@ pub mod gas_credits; pub mod gas_params; pub mod handler; mod instructions; +mod native_multisig; mod signature_gas; mod tx; diff --git a/crates/revm/src/native_multisig.rs b/crates/revm/src/native_multisig.rs new file mode 100644 index 0000000000..ff5934517e --- /dev/null +++ b/crates/revm/src/native_multisig.rs @@ -0,0 +1,273 @@ +use alloy_primitives::{Address, U256}; +use reth_evm::EvmInternals; +use revm::{ + Database, Journal, + context::{ + Cfg, CfgEnv, JournalTr, Transaction, + result::{EVMError, InvalidTransaction}, + }, + handler::pre_execution::calculate_caller_fee, + interpreter::InitialAndFloorGas, +}; +use std::fmt::Debug; +use tempo_chainspec::hardfork::TempoHardfork; +use tempo_precompiles::{ + is_valid_multisig_account, + native_multisig::{NativeMultisig, auth::NativeMultisigAuthError}, + storage::{ + PrecompileStorageProvider, StorageActions, StorageCtx, evm::EvmPrecompileStorageProvider, + }, +}; +use tempo_primitives::transaction::{MultisigSignature, TempoSignature}; + +use crate::{ExecutionContext, TempoBlockEnv, TempoInvalidTransaction, TempoTxEnv}; + +pub(super) fn validate_state( + journal: &mut Journal, + block: &TempoBlockEnv, + cfg: &CfgEnv, + tx: &TempoTxEnv, + actions: StorageActions, + init_gas: &mut InitialAndFloorGas, + account_balance: U256, +) -> Result, EVMError> { + if !cfg.spec().is_t12() { + return Ok(None); + } + + let tempo_tx_env = tx.tempo_tx_env.as_deref(); + let outer_multisig_signature = tempo_tx_env.and_then(|aa| aa.signature.as_multisig()); + let key_authorization_multisig_signature = tempo_tx_env + .and_then(|aa| aa.key_authorization.as_ref()) + .and_then(|key_auth| key_auth.signature.as_multisig()); + let has_multisig_signature = + outer_multisig_signature.is_some() || key_authorization_multisig_signature.is_some(); + + // Reject an unfunded worst-case quorum before owner verification. Subblock fee failures + // must remain after nonce consumption and use the normal path below. + let early_multisig_balance = if !tx.is_subblock_transaction() && has_multisig_signature { + Some(calculate_caller_fee(account_balance, tx, block, cfg)?) + } else { + None + }; + + let key_authorization_key_id = tempo_tx_env + .and_then(|aa| aa.key_authorization.as_ref()) + .map(|key_auth| key_auth.key_id); + let outer_keychain_signature = tempo_tx_env.is_some_and(|aa| aa.signature.is_keychain()); + let validates_caller_multisig = outer_multisig_signature.is_some() + || key_authorization_multisig_signature + .is_some_and(|signature| signature.account() == tx.caller()); + let caller_has_code = if validates_caller_multisig || outer_keychain_signature { + !journal + .load_account(tx.caller())? + .data + .info + .is_empty_code_hash() + } else { + false + }; + let keychain_caller_has_code = outer_keychain_signature && caller_has_code; + let requires_native_multisig_state = + has_multisig_signature || key_authorization_key_id.is_some() || keychain_caller_has_code; + + if !requires_native_multisig_state { + return Ok(early_multisig_balance); + } + + if validates_caller_multisig && caller_has_code { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig account cannot have code or EIP-7702 delegation".to_string(), + } + .into()); + } + + let mut nested_accounts = Vec::new(); + for signature in [ + outer_multisig_signature, + key_authorization_multisig_signature, + ] + .into_iter() + .flatten() + { + collect_nested_multisig_accounts(signature, &mut nested_accounts); + } + for account in nested_accounts { + if !journal + .load_account(account)? + .data + .info + .is_empty_code_hash() + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig owner account {account} cannot have code or EIP-7702 delegation" + ), + } + .into()); + } + } + + let commitment_gating_gas = if key_authorization_key_id.is_some() || keychain_caller_has_code { + let internals = EvmInternals::new(journal, block, cfg, tx); + let mut provider = + EvmPrecompileStorageProvider::new_max_gas(internals, cfg).with_actions(actions.clone()); + let validation = StorageCtx::enter(&mut provider, || { + let multisig = NativeMultisig::new(); + + if keychain_caller_has_code + && !multisig + .get_config_commitment(tx.caller()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + .is_zero() + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig account cannot have code or EIP-7702 delegation" + .to_string(), + } + .into()); + } + + if let Some(key_id) = key_authorization_key_id + && !multisig + .get_config_commitment(key_id) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + .is_zero() + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig account {key_id} cannot be used as an access key" + ), + } + .into()); + } + + Ok::<(), EVMError>(()) + }); + let gas_used = provider.gas_used(); + validation?; + gas_used + } else { + 0 + }; + + init_gas.initial_regular_gas = init_gas + .initial_regular_gas + .saturating_add(commitment_gating_gas); + + if tx.gas_limit() < init_gas.initial_total_gas() { + return Err(InvalidTransaction::CallGasCostMoreThanGasLimit { + gas_limit: tx.gas_limit(), + initial_gas: init_gas.initial_total_gas(), + } + .into()); + } + + if cfg.is_amsterdam_eip8037_enabled() + && init_gas.initial_regular_gas().max(init_gas.floor_gas) > cfg.tx_gas_limit_cap() + { + return Err(InvalidTransaction::GasFloorMoreThanGasLimit { + gas_floor: init_gas.initial_regular_gas(), + gas_limit: cfg.tx_gas_limit_cap(), + } + .into()); + } + + if has_multisig_signature { + StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + actions, + |multisig: NativeMultisig| -> Result<(), EVMError> { + for signature in [ + outer_multisig_signature, + key_authorization_multisig_signature, + ] + .into_iter() + .flatten() + { + multisig + .validate_authorization_state(signature) + .map_err(map_native_multisig_error::)?; + } + Ok(()) + }, + )?; + } + + if tx.execution_context() != ExecutionContext::Simulation { + if let Some(signature) = outer_multisig_signature { + NativeMultisig::verify_authorization_quorum( + tempo_tx_env + .expect("outer multisig signature is derived from tempo_tx_env") + .signature_hash, + signature, + ) + .map_err(map_native_multisig_error::)?; + } + if let Some(signature) = key_authorization_multisig_signature { + let key_auth = tempo_tx_env + .and_then(|aa| aa.key_authorization.as_ref()) + .expect("multisig signature is derived from key authorization"); + NativeMultisig::verify_authorization_quorum( + key_auth.authorization.signature_hash(), + signature, + ) + .map_err(map_native_multisig_error::)?; + } + } + + Ok(early_multisig_balance) +} + +pub(super) fn validate_native_multisig_signature_account( + signature: &MultisigSignature, + expected_account: Address, + spec: TempoHardfork, + account_mismatch_reason: &'static str, +) -> Result { + let account = signature.recover_account().map_err(|reason| { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: reason.to_string(), + } + })?; + if account != expected_account { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: account_mismatch_reason.to_string(), + }); + } + if !is_valid_multisig_account(account, spec) { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "multisig signature names a reserved account address".to_string(), + }); + } + Ok(account) +} + +fn collect_nested_multisig_accounts(signature: &MultisigSignature, accounts: &mut Vec
) { + for approval in signature.signatures() { + let TempoSignature::Multisig(nested) = approval else { + continue; + }; + accounts.push(nested.account()); + collect_nested_multisig_accounts(nested, accounts); + } +} + +fn map_native_multisig_error( + error: NativeMultisigAuthError, +) -> EVMError { + match error { + NativeMultisigAuthError::Fatal(error) => EVMError::Custom(error), + NativeMultisigAuthError::InvalidTransaction(reason) => { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason }.into() + } + NativeMultisigAuthError::ValidationFailed(reason) => { + TempoInvalidTransaction::NativeMultisigValidationFailed { reason }.into() + } + } +} diff --git a/crates/revm/src/signature_gas.rs b/crates/revm/src/signature_gas.rs index aa7b9484ba..a73e977558 100644 --- a/crates/revm/src/signature_gas.rs +++ b/crates/revm/src/signature_gas.rs @@ -1,7 +1,13 @@ +use alloy_rlp::Encodable; use revm::interpreter::gas::{ - COLD_SLOAD_COST, STANDARD_TOKEN_COST, get_tokens_in_calldata_istanbul, + COLD_ACCOUNT_ACCESS_COST, COLD_SLOAD_COST, KECCAK256, KECCAK256WORD, STANDARD_TOKEN_COST, + get_tokens_in_calldata_istanbul, +}; +use tempo_precompiles::{ECRECOVER_GAS, native_multisig::MULTISIG_ACCOUNT_CREATE2_GAS}; +use tempo_primitives::transaction::{ + MAX_MULTISIG_NESTING_DEPTH, MULTISIG_SIGNATURE_DOMAIN, MultisigSignature, PrimitiveSignature, + TempoSignature, }; -use tempo_primitives::transaction::{PrimitiveSignature, TempoSignature}; /// Additional gas for P256 signature verification. /// @@ -12,6 +18,14 @@ pub(crate) const P256_VERIFY_GAS: u64 = 5_000; /// Additional gas for keychain signatures (key validation overhead: cold SLOAD + processing). const KEYCHAIN_VALIDATION_GAS: u64 = COLD_SLOAD_COST + 900; +/// Gas for reading a multisig account's configuration commitment. +pub(crate) const NATIVE_MULTISIG_COMMITMENT_READ_GAS: u64 = COLD_SLOAD_COST; + +/// Gas for checking that each nested multisig owner account has no code or delegation. +const NATIVE_MULTISIG_NESTED_ACCOUNT_GAS: u64 = COLD_ACCOUNT_ACCESS_COST; + +const MULTISIG_DIGEST_PREIMAGE_LEN: usize = MULTISIG_SIGNATURE_DOMAIN.len() + 32 + 20 + 8; + /// Calculates the gas cost for verifying a primitive signature. /// /// Returns the additional gas required beyond the base transaction cost: @@ -30,6 +44,57 @@ pub(crate) fn primitive_signature_verification_gas(signature: &PrimitiveSignatur } } +#[inline] +fn full_primitive_signature_gas(signature: &PrimitiveSignature) -> u64 { + ECRECOVER_GAS + primitive_signature_verification_gas(signature) +} + +fn native_multisig_owner_approval_gas(signature: &TempoSignature, depth: usize) -> u64 { + match signature { + TempoSignature::Primitive(primitive) => full_primitive_signature_gas(primitive), + TempoSignature::Multisig(multisig) if depth < MAX_MULTISIG_NESTING_DEPTH => { + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS + .saturating_add(native_multisig_node_gas(multisig, depth + 1)) + } + // Shape validation rejects these cases before intrinsic gas is computed. + TempoSignature::Keychain(_) | TempoSignature::Multisig(_) => 0, + } +} + +fn keccak_gas(byte_len: usize) -> u64 { + KECCAK256.saturating_add( + KECCAK256WORD.saturating_mul(u64::try_from(byte_len.div_ceil(32)).unwrap_or(u64::MAX)), + ) +} + +fn native_multisig_witness_calldata_gas(signature: &MultisigSignature) -> u64 { + let mut encoded = + Vec::with_capacity(signature.account().length() + signature.config().length()); + signature.account().encode(&mut encoded); + signature.config().encode(&mut encoded); + get_tokens_in_calldata_istanbul(&encoded).saturating_mul(STANDARD_TOKEN_COST) +} + +fn native_multisig_node_gas(signature: &MultisigSignature, depth: usize) -> u64 { + let config = signature.config(); + let config_proof_gas = if config.version == 0 { + keccak_gas(config.account_salt_preimage_len()).saturating_add(MULTISIG_ACCOUNT_CREATE2_GAS) + } else { + keccak_gas(config.commitment_preimage_len()) + }; + let approval_gas = signature + .signatures() + .iter() + .map(|approval| native_multisig_owner_approval_gas(approval, depth)) + .fold(0u64, u64::saturating_add); + + NATIVE_MULTISIG_COMMITMENT_READ_GAS + .saturating_add(native_multisig_witness_calldata_gas(signature)) + .saturating_add(config_proof_gas) + .saturating_add(keccak_gas(MULTISIG_DIGEST_PREIMAGE_LEN)) + .saturating_add(approval_gas) +} + /// Calculates the gas cost for verifying an AA signature. /// /// For keychain signatures, adds key validation overhead to the inner signature cost. Returns the @@ -37,11 +102,12 @@ pub(crate) fn primitive_signature_verification_gas(signature: &PrimitiveSignatur #[inline] pub(crate) fn tempo_signature_verification_gas(signature: &TempoSignature) -> u64 { match signature { - TempoSignature::Primitive(prim_sig) => primitive_signature_verification_gas(prim_sig), - TempoSignature::Keychain(keychain_sig) => { - primitive_signature_verification_gas(&keychain_sig.signature) + KEYCHAIN_VALIDATION_GAS + TempoSignature::Primitive(primitive) => primitive_signature_verification_gas(primitive), + TempoSignature::Keychain(keychain) => { + primitive_signature_verification_gas(&keychain.signature) + KEYCHAIN_VALIDATION_GAS + } + TempoSignature::Multisig(multisig) => { + native_multisig_node_gas(multisig, 1).saturating_sub(ECRECOVER_GAS) } - // Native multisig transactions are rejected before intrinsic gas is calculated. - TempoSignature::Multisig(_) => 0, } } From ba295f603dadf84ee13cc6e4560ec409aadf8042 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:42:07 +0200 Subject: [PATCH 2/3] test(consensus): cover native multisig gas --- crates/payload/builder/src/prewarming.rs | 2 +- .../precompiles/src/account_keychain/mod.rs | 4 - crates/revm/src/evm.rs | 8 +- crates/revm/src/handler/tests.rs | 184 +++++++++++++++--- crates/revm/src/native_multisig.rs | 6 +- 5 files changed, 161 insertions(+), 43 deletions(-) diff --git a/crates/payload/builder/src/prewarming.rs b/crates/payload/builder/src/prewarming.rs index 6da5dd2ebf..9b5a861b58 100644 --- a/crates/payload/builder/src/prewarming.rs +++ b/crates/payload/builder/src/prewarming.rs @@ -664,7 +664,7 @@ mod tests { MultisigSignature::try_new( multisig_account, config, - vec![PrimitiveSignature::default().to_bytes()], + vec![TempoSignature::Primitive(PrimitiveSignature::default())], ) .unwrap(), ), diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index bdbb8977df..161a2506ac 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -1869,8 +1869,6 @@ mod tests { let key_id = Address::from([0x33; 20]); StorageCtx::enter(&mut storage, || { - let mut multisig = NativeMultisig::new(); - multisig.initialize()?; let account = store_native_multisig_commitment(&config)?; let mut keychain = AccountKeychain::new(); @@ -1901,8 +1899,6 @@ mod tests { let account = Address::from([0x44; 20]); StorageCtx::enter(&mut storage, || { - let mut multisig = NativeMultisig::new(); - multisig.initialize()?; let multisig_account = store_native_multisig_commitment(&config)?; let mut keychain = AccountKeychain::new(); diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index bd1601408f..d8799354ba 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -524,8 +524,9 @@ mod tests { .gas_limit(2_000_000) .build(); let digest = multisig_digest(tx.signature_hash(), account, 0); - let owner_signature = - PrimitiveSignature::Secp256k1(signer.sign_hash_sync(&digest)?).to_bytes(); + let owner_signature = TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + signer.sign_hash_sync(&digest)?, + )); let signed_tx = tx.into_signed(TempoSignature::Multisig( MultisigSignature::try_new(account, config, vec![owner_signature]).unwrap(), )); @@ -543,9 +544,6 @@ mod tests { let mut evm = TempoEvm::new(ctx, ()); fund_account(&mut evm, account); - StorageCtx::enter_ctx(&mut evm.ctx, StorageActions::disabled(), || { - NativeMultisig::new().initialize() - })?; Ok(( evm, diff --git a/crates/revm/src/handler/tests.rs b/crates/revm/src/handler/tests.rs index e03e5cbf3f..1c18ee0738 100644 --- a/crates/revm/src/handler/tests.rs +++ b/crates/revm/src/handler/tests.rs @@ -5057,26 +5057,110 @@ fn test_t12_rejects_code_bearing_nested_multisig_owner() { )); } -#[test] -fn test_aa_gas_native_multisig_charges_commitment_and_witness() { +fn expected_multisig_node_gas(signature: &MultisigSignature) -> u64 { use alloy_rlp::Encodable; use revm::interpreter::gas::{ KECCAK256, KECCAK256WORD, STANDARD_TOKEN_COST, get_tokens_in_calldata_istanbul, }; - let config = native_multisig_config(); - let account = config.derive_account().unwrap(); - let signature = MultisigSignature::try_new( - account, - config.clone(), - vec![TempoSignature::Primitive(PrimitiveSignature::default())], + let keccak = |len: usize| KECCAK256 + KECCAK256WORD * len.div_ceil(32) as u64; + let primitive = |signature: &PrimitiveSignature| { + ECRECOVER_GAS + + match signature { + PrimitiveSignature::Secp256k1(_) => 0, + PrimitiveSignature::P256(_) => P256_VERIFY_GAS, + PrimitiveSignature::WebAuthn(signature) => { + P256_VERIFY_GAS + + get_tokens_in_calldata_istanbul(&signature.webauthn_data) + * STANDARD_TOKEN_COST + } + } + }; + let config = signature.config(); + let mut witness = Vec::new(); + signature.account().encode(&mut witness); + config.encode(&mut witness); + let proof = if config.version == 0 { + keccak(config.account_salt_preimage_len()) + + tempo_precompiles::native_multisig::MULTISIG_ACCOUNT_CREATE2_GAS + } else { + keccak(config.commitment_preimage_len()) + }; + let approvals = signature + .signatures() + .iter() + .map(|approval| match approval { + TempoSignature::Primitive(signature) => primitive(signature), + TempoSignature::Multisig(signature) => { + COLD_ACCOUNT_ACCESS_COST + expected_multisig_node_gas(signature) + } + TempoSignature::Keychain(_) => unreachable!("shape validation rejects keychain"), + }) + .sum::(); + + NATIVE_MULTISIG_COMMITMENT_READ_GAS + + get_tokens_in_calldata_istanbul(&witness) * STANDARD_TOKEN_COST + + proof + + keccak(tempo_primitives::transaction::MULTISIG_SIGNATURE_DOMAIN.len() + 32 + 20 + 8) + + approvals +} + +#[test] +fn test_aa_native_multisig_gas_matches_tip_formula() { + let p256 = PrimitiveSignature::P256(P256SignatureWithPreHash { + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + pre_hash: false, + }); + let webauthn = PrimitiveSignature::WebAuthn(WebAuthnSignature { + webauthn_data: Bytes::from_static(&[0, 1, 0xff]), + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + }); + let initial = native_multisig_config(); + let account = initial.derive_account().unwrap(); + let mut current = initial.clone(); + current.version = 1; + let direct = |config: MultisigConfig, approval| { + MultisigSignature::try_new(account, config, vec![TempoSignature::Primitive(approval)]) + .unwrap() + }; + + let child_config = single_owner_native_multisig_config(0x42, Address::repeat_byte(0x44)); + let child_account = child_config.derive_account().unwrap(); + let child = MultisigSignature::try_new( + child_account, + child_config, + vec![TempoSignature::Primitive(p256.clone())], + ) + .unwrap(); + let parent_config = single_owner_native_multisig_config(0x43, child_account); + let parent_account = parent_config.derive_account().unwrap(); + let nested = MultisigSignature::try_new( + parent_account, + parent_config, + vec![TempoSignature::Multisig(child)], ) .unwrap(); - let base_env = make_single_call_env(Bytes::from(vec![1, 2])); - let mut multisig_env = base_env.clone(); - multisig_env.signature = TempoSignature::Multisig(signature); + let cases = [ + ( + "initial secp256k1", + direct(initial, PrimitiveSignature::default()), + ), + ("current P256", direct(current, p256)), + ( + "initial WebAuthn", + direct(native_multisig_config(), webauthn), + ), + ("nested", nested), + ]; let gas_params = tempo_gas_params(TempoHardfork::T12); + let base_env = make_single_call_env(Bytes::from_static(&[1, 2])); let base_gas = calculate_aa_batch_intrinsic_gas( &base_env, &gas_params, @@ -5084,26 +5168,70 @@ fn test_aa_gas_native_multisig_charges_commitment_and_witness() { TempoHardfork::T12, ) .unwrap(); - let multisig_gas = calculate_aa_batch_intrinsic_gas( - &multisig_env, - &gas_params, - None::>, - TempoHardfork::T12, + + for (case, signature) in cases { + let mut env = base_env.clone(); + env.signature = TempoSignature::Multisig(signature.clone()); + let gas = calculate_aa_batch_intrinsic_gas( + &env, + &gas_params, + None::>, + TempoHardfork::T12, + ) + .unwrap(); + assert_eq!( + gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_multisig_node_gas(&signature) - ECRECOVER_GAS, + "{case}", + ); + } +} + +#[test] +fn test_multisig_key_authorization_gas_is_independent() { + let config = native_multisig_config(); + let account = config.derive_account().unwrap(); + let signature = MultisigSignature::try_new( + account, + config, + vec![TempoSignature::Primitive(PrimitiveSignature::default())], ) .unwrap(); + let authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x55)) + .with_account(account); + let primitive = authorization + .clone() + .into_signed(PrimitiveSignature::default()); + let multisig = authorization.into_signed(TempoSignature::Multisig(signature.clone())); + let gas_params = tempo_gas_params(TempoHardfork::T12); + let primitive_gas = + calculate_key_authorization_gas(&primitive, &gas_params, TempoHardfork::T12).0; + let multisig_gas = + calculate_key_authorization_gas(&multisig, &gas_params, TempoHardfork::T12).0; + let non_signature_gas = primitive_gas - ECRECOVER_GAS; + assert_eq!( + multisig_gas - non_signature_gas, + expected_multisig_node_gas(&signature), + ); - let mut witness = Vec::new(); - account.encode(&mut witness); - config.encode(&mut witness); - let keccak = |len: usize| KECCAK256 + KECCAK256WORD * u64::try_from(len.div_ceil(32)).unwrap(); - let expected = NATIVE_MULTISIG_COMMITMENT_READ_GAS - + get_tokens_in_calldata_istanbul(&witness) * STANDARD_TOKEN_COST - + keccak(config.account_salt_preimage_len()) - + keccak(tempo_primitives::transaction::MULTISIG_ACCOUNT_CREATE2_PREIMAGE_LEN) - + keccak(tempo_primitives::transaction::MULTISIG_SIGNATURE_DOMAIN.len() + 32 + 20 + 8); - + let mut base_env = make_single_call_env(Bytes::new()); + base_env.key_authorization = Some(primitive); + let mut combined_env = base_env.clone(); + combined_env.signature = TempoSignature::Multisig(signature.clone()); + combined_env.key_authorization = Some(multisig); + let intrinsic = |env| { + calculate_aa_batch_intrinsic_gas( + env, + &gas_params, + None::>, + TempoHardfork::T12, + ) + .unwrap() + .initial_regular_gas + }; assert_eq!( - multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, - expected + intrinsic(&combined_env) - intrinsic(&base_env), + 2 * (expected_multisig_node_gas(&signature) - ECRECOVER_GAS), ); } diff --git a/crates/revm/src/native_multisig.rs b/crates/revm/src/native_multisig.rs index ff5934517e..9833cad26f 100644 --- a/crates/revm/src/native_multisig.rs +++ b/crates/revm/src/native_multisig.rs @@ -230,11 +230,7 @@ pub(super) fn validate_native_multisig_signature_account( spec: TempoHardfork, account_mismatch_reason: &'static str, ) -> Result { - let account = signature.recover_account().map_err(|reason| { - TempoInvalidTransaction::NativeMultisigInvalidTransaction { - reason: reason.to_string(), - } - })?; + let account = signature.account(); if account != expected_account { return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason: account_mismatch_reason.to_string(), From 73f12d1b4e64dc592f613d99808db8fd6859ca35 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:02:38 +0200 Subject: [PATCH 3/3] fix(consensus): account for multisig validation state --- crates/revm/src/handler.rs | 7 ++++++- crates/revm/src/handler/tests.rs | 12 ++++++++++++ crates/revm/src/native_multisig.rs | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index b5687603dc..f3382ed87f 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -295,7 +295,8 @@ fn translate_allowed_calls_for_precompile( /// On T1/T1A this was double-charged alongside the gas-metered precompile call. /// /// T1B+: Gas = signature verification + SLOAD (existing key check) + -/// SSTORE (write key) + N × SSTORE (per spending limit) +/// SSTORE (write key) + N × SSTORE (per spending limit), plus the T12 +/// warm SLOAD that rejects native multisig accounts as access keys. /// This is the sole gas accounting — the precompile runs with unlimited gas. /// /// Returns `(total_gas, state_gas)` where `total_gas` includes the state gas portion. @@ -351,6 +352,10 @@ fn calculate_key_authorization_gas( } let mut regular_gas = sig_gas + sload_cost + sstore_cost * num_sstores + BUFFER; + if spec.is_t12() { + regular_gas += gas_params.warm_storage_read_cost(); + } + if has_t5_witness { regular_gas += sload_cost + KEY_AUTH_EXTRA_EVENT_BUFFER; } diff --git a/crates/revm/src/handler/tests.rs b/crates/revm/src/handler/tests.rs index 1c18ee0738..7244738bd7 100644 --- a/crates/revm/src/handler/tests.rs +++ b/crates/revm/src/handler/tests.rs @@ -5235,3 +5235,15 @@ fn test_multisig_key_authorization_gas_is_independent() { 2 * (expected_multisig_node_gas(&signature) - ECRECOVER_GAS), ); } + +#[test] +fn test_t12_key_authorization_charges_warm_multisig_commitment_read() { + let authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x55)) + .into_signed(PrimitiveSignature::default()); + let gas_params = tempo_gas_params(TempoHardfork::T12); + let t11 = calculate_key_authorization_gas(&authorization, &gas_params, TempoHardfork::T11).0; + let t12 = calculate_key_authorization_gas(&authorization, &gas_params, TempoHardfork::T12).0; + + assert_eq!(t12 - t11, gas_params.warm_storage_read_cost()); +} diff --git a/crates/revm/src/native_multisig.rs b/crates/revm/src/native_multisig.rs index 9833cad26f..2449605136 100644 --- a/crates/revm/src/native_multisig.rs +++ b/crates/revm/src/native_multisig.rs @@ -112,6 +112,7 @@ pub(super) fn validate_state( let internals = EvmInternals::new(journal, block, cfg, tx); let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, cfg).with_actions(actions.clone()); + provider.set_tip1060_storage_credits(false); let validation = StorageCtx::enter(&mut provider, || { let multisig = NativeMultisig::new();