diff --git a/crates/contracts/src/precompiles/mod.rs b/crates/contracts/src/precompiles/mod.rs index 34dbaf354d..aab9e93bbf 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::T11), ]; diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 3a705fe6d4..a91d32472e 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -29,8 +29,9 @@ 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, - RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STORAGE_CREDITS_ADDRESS, - TIP20_CHANNEL_RESERVE_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, initial_zone_factory_state, + 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, }; use tempo_primitives::{ SubBlock, SubBlockMetadata, TempoReceipt, TempoTxEnvelope, TempoTxType, @@ -628,6 +629,9 @@ where if self.inner.spec.is_t8_active_at_timestamp(timestamp) { self.deploy_precompile_at_boundary(CURRENT_COMMITTEE_ADDRESS, &[])?; } + if self.inner.spec.is_t11_active_at_timestamp(timestamp) { + self.deploy_precompile_at_boundary(NATIVE_MULTISIG_ADDRESS, &[])?; + } if self.inner.spec.is_t10_active_at_timestamp(timestamp) { self.deploy_zone_factory_at_boundary()?; } @@ -1967,22 +1971,45 @@ 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_t11_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 T11" + ); + 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_t11_native_multisig_code() { + 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()); + } + #[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/evm/src/evm.rs b/crates/evm/src/evm.rs index c05b31c776..5f395793e5 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -366,9 +366,10 @@ mod tests { zones::{ZONE_MESSENGER_RUNTIME, ZONE_PORTAL_RUNTIME}, }; use tempo_precompiles::{ - NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, STORAGE_CREDITS_ADDRESS, - TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS, + NATIVE_MULTISIG_ADDRESS, NONCE_PRECOMPILE_ADDRESS, PATH_USD_ADDRESS, + STORAGE_CREDITS_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS, error::TempoPrecompileError, + native_multisig::NativeMultisig, storage::{ContractStorage, StorageAction, StorageActions, StorageCtx, StorageKey}, storage_credits::StorageCredits, test_util::TIP20Setup, @@ -1510,6 +1511,10 @@ mod tests { let nonce_key = U256::from(42); let sender_nonce_key_slot = nonce_key .mapping_slot(sender.mapping_slot(tempo_precompiles::nonce::slots::NONCES)); + let (sender_multisig_account_slot, _) = + NativeMultisig::account_threshold_storage_slot(sender); + let (recipient_multisig_account_slot, _) = + NativeMultisig::account_threshold_storage_slot(recipient); #[rustfmt::skip] let labels = StorageActionSnapshotLabels { @@ -1521,6 +1526,7 @@ mod tests { (TIP403_REGISTRY_ADDRESS, "TIP403_REGISTRY"), (STORAGE_CREDITS_ADDRESS, "STORAGE_CREDITS"), (NONCE_PRECOMPILE_ADDRESS, "NONCE_MANAGER"), + (NATIVE_MULTISIG_ADDRESS, "NATIVE_MULTISIG"), ]), slots: BTreeMap::from([ ((TIP_FEE_MANAGER_ADDRESS, validator_token_slot), "validatorTokens[beneficiary]"), @@ -1536,6 +1542,8 @@ mod tests { ((STORAGE_CREDITS_ADDRESS, StorageCredits::slot(fee_token)), "storageCredits[FEE_TOKEN]"), ((STORAGE_CREDITS_ADDRESS, StorageCredits::slot(two_hop_fee_token)), "storageCredits[TWO_HOP_FEE_TOKEN]"), ((NONCE_PRECOMPILE_ADDRESS, sender_nonce_key_slot), "nonces[sender][42]"), + ((NATIVE_MULTISIG_ADDRESS, sender_multisig_account_slot), "accounts[sender]"), + ((NATIVE_MULTISIG_ADDRESS, recipient_multisig_account_slot), "accounts[recipient]"), ]), tip20_slots: BTreeMap::from([ (tip20_slots::CURRENCY, "currency"), diff --git a/crates/node/tests/it/tempo_transaction/runners.rs b/crates/node/tests/it/tempo_transaction/runners.rs index 094a1c1de2..9340c1c561 100644 --- a/crates/node/tests/it/tempo_transaction/runners.rs +++ b/crates/node/tests/it/tempo_transaction/runners.rs @@ -1752,11 +1752,7 @@ pub(super) async fn run_fill_sign_send( let tx_hash = if uses_p256 { let (signing_key, pub_key_x, pub_key_y, signer_addr) = generate_p256_access_key(); - // In the E2E fill flow, P256/WebAuthn signers use a fee payer - // to cover gas (the fee_payer flag on FillTestCase is not checked - // here because eth_fillTransaction always requires one). - let fee_payer_signer = PrivateKeySigner::random(); - let _ = env.fund_account(fee_payer_signer.address()).await?; + let _ = env.fund_account(signer_addr).await?; let current_timestamp = env.current_block_timestamp().await?; @@ -1790,7 +1786,6 @@ pub(super) async fn run_fill_sign_send( "eth_fillTransaction should not set fee_token (client must set it)" ); tx.fee_token = Some(DEFAULT_FEE_TOKEN); - sign_fee_payer(&mut tx, signer_addr, &fee_payer_signer)?; let signature = match test_case.key_type { KeyType::P256 => sign_aa_tx_p256(&tx, &signing_key, pub_key_x, pub_key_y)?, 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 4cf821c2e9..c40187e124 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": 574113 "webauthn::batch_10_transfers": 603050 "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": 808668 -"key_auth_secp256k1_0_limits::transfer": 808668 -"key_auth_webauthn_0_limits::transfer": 808668 -"key_auth_secp256k1_witness::transfer": 812296 -"key_auth_p256_0_limits::batch_2_transfers": 814455 -"key_auth_secp256k1_0_limits::batch_2_transfers": 814455 -"key_auth_webauthn_0_limits::batch_2_transfers": 814455 -"key_auth_secp256k1_witness::batch_2_transfers": 818084 -"key_auth_p256_0_limits::batch_5_transfers": 831818 -"key_auth_secp256k1_0_limits::batch_5_transfers": 831818 -"key_auth_webauthn_0_limits::batch_5_transfers": 831818 -"key_auth_secp256k1_witness::batch_5_transfers": 835446 -"key_auth_p256_0_limits::batch_10_transfers": 860756 -"key_auth_secp256k1_0_limits::batch_10_transfers": 860756 -"key_auth_webauthn_0_limits::batch_10_transfers": 860756 -"key_auth_secp256k1_witness::batch_10_transfers": 864384 -"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": 1312636 -"key_auth_secp256k1_1_limit::transfer": 1312636 -"key_auth_webauthn_1_limit::transfer": 1312636 -"key_auth_secp256k1_witness_1_limit::transfer": 1316264 -"key_auth_p256_1_limit::batch_2_transfers": 1318423 -"key_auth_secp256k1_1_limit::batch_2_transfers": 1318423 -"key_auth_webauthn_1_limit::batch_2_transfers": 1318423 -"key_auth_secp256k1_witness_1_limit::batch_2_transfers": 1322052 -"key_auth_p256_1_limit::batch_5_transfers": 1335786 -"key_auth_secp256k1_1_limit::batch_5_transfers": 1335786 -"key_auth_webauthn_1_limit::batch_5_transfers": 1335786 -"key_auth_secp256k1_witness_1_limit::batch_5_transfers": 1339415 -"key_auth_p256_1_limit::batch_10_transfers": 1364724 -"key_auth_secp256k1_1_limit::batch_10_transfers": 1364724 -"key_auth_webauthn_1_limit::batch_10_transfers": 1364724 -"key_auth_secp256k1_witness_1_limit::batch_10_transfers": 1368352 -"key_auth_secp256k1_target_any_selector::transfer": 1823660 -"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": 2320572 -"key_auth_secp256k1_3_limits::transfer": 2320572 -"key_auth_webauthn_3_limits::transfer": 2320572 -"key_auth_p256_3_limits::batch_2_transfers": 2326360 -"key_auth_secp256k1_3_limits::batch_2_transfers": 2326360 -"key_auth_webauthn_3_limits::batch_2_transfers": 2326360 -"key_auth_p256_3_limits::batch_5_transfers": 2343723 -"key_auth_secp256k1_3_limits::batch_5_transfers": 2343723 -"key_auth_webauthn_3_limits::batch_5_transfers": 2343723 -"key_auth_p256_3_limits::batch_10_transfers": 2372660 -"key_auth_secp256k1_3_limits::batch_10_transfers": 2372660 -"key_auth_webauthn_3_limits::batch_10_transfers": 2372660 -"key_auth_secp256k1_selector_any_recipient::transfer": 2586668 -"key_auth_secp256k1_selector_recipient::transfer": 3347660 -"keychain_secp256k1::noop": 541623 -"keychain_secp256k1::transfer": 811994 -"keychain_secp256k1::batch_2_transfers": 818084 -"keychain_secp256k1::batch_5_transfers": 836354 -"keychain_secp256k1::batch_10_transfers": 866803 -"keychain_p256::noop": 546662 -"keychain_p256::transfer": 817033 -"keychain_p256::batch_2_transfers": 823123 -"keychain_p256::batch_5_transfers": 841393 -"keychain_p256::batch_10_transfers": 871843 -"keychain_secp256k1_selector_any_recipient::transfer": 2592413 -"keychain_secp256k1_selector_recipient::transfer": 3351490 -"keychain_secp256k1_target_any_selector::transfer": 1827187 +"key_auth_p256_0_limits::noop": 540615 +"key_auth_secp256k1_0_limits::noop": 540615 +"key_auth_webauthn_0_limits::noop": 540615 +"key_auth_secp256k1_witness::noop": 544243 +"key_auth_p256_0_limits::transfer": 810784 +"key_auth_secp256k1_0_limits::transfer": 810784 +"key_auth_webauthn_0_limits::transfer": 810784 +"key_auth_secp256k1_witness::transfer": 814413 +"key_auth_p256_0_limits::batch_2_transfers": 816572 +"key_auth_secp256k1_0_limits::batch_2_transfers": 816572 +"key_auth_webauthn_0_limits::batch_2_transfers": 816572 +"key_auth_secp256k1_witness::batch_2_transfers": 820200 +"key_auth_p256_0_limits::batch_5_transfers": 833934 +"key_auth_secp256k1_0_limits::batch_5_transfers": 833934 +"key_auth_webauthn_0_limits::batch_5_transfers": 833934 +"key_auth_secp256k1_witness::batch_5_transfers": 837563 +"key_auth_p256_0_limits::batch_10_transfers": 862872 +"key_auth_secp256k1_0_limits::batch_10_transfers": 862872 +"key_auth_webauthn_0_limits::batch_10_transfers": 862872 +"key_auth_secp256k1_witness::batch_10_transfers": 866501 +"key_auth_p256_1_limit::noop": 1044583 +"key_auth_secp256k1_1_limit::noop": 1044583 +"key_auth_webauthn_1_limit::noop": 1044583 +"key_auth_secp256k1_witness_1_limit::noop": 1048212 +"key_auth_p256_1_limit::transfer": 1314752 +"key_auth_secp256k1_1_limit::transfer": 1314752 +"key_auth_webauthn_1_limit::transfer": 1314752 +"key_auth_secp256k1_witness_1_limit::transfer": 1318381 +"key_auth_p256_1_limit::batch_2_transfers": 1320540 +"key_auth_secp256k1_1_limit::batch_2_transfers": 1320540 +"key_auth_webauthn_1_limit::batch_2_transfers": 1320540 +"key_auth_secp256k1_witness_1_limit::batch_2_transfers": 1324169 +"key_auth_p256_1_limit::batch_5_transfers": 1337903 +"key_auth_secp256k1_1_limit::batch_5_transfers": 1337903 +"key_auth_webauthn_1_limit::batch_5_transfers": 1337903 +"key_auth_secp256k1_witness_1_limit::batch_5_transfers": 1341531 +"key_auth_p256_1_limit::batch_10_transfers": 1366841 +"key_auth_secp256k1_1_limit::batch_10_transfers": 1366841 +"key_auth_webauthn_1_limit::batch_10_transfers": 1366841 +"key_auth_secp256k1_witness_1_limit::batch_10_transfers": 1370469 +"key_auth_secp256k1_target_any_selector::transfer": 1825776 +"key_auth_p256_3_limits::noop": 2052520 +"key_auth_secp256k1_3_limits::noop": 2052520 +"key_auth_webauthn_3_limits::noop": 2052520 +"key_auth_p256_3_limits::transfer": 2322689 +"key_auth_secp256k1_3_limits::transfer": 2322689 +"key_auth_webauthn_3_limits::transfer": 2322689 +"key_auth_p256_3_limits::batch_2_transfers": 2328477 +"key_auth_secp256k1_3_limits::batch_2_transfers": 2328477 +"key_auth_webauthn_3_limits::batch_2_transfers": 2328477 +"key_auth_p256_3_limits::batch_5_transfers": 2345839 +"key_auth_secp256k1_3_limits::batch_5_transfers": 2345839 +"key_auth_webauthn_3_limits::batch_5_transfers": 2345839 +"key_auth_p256_3_limits::batch_10_transfers": 2374777 +"key_auth_secp256k1_3_limits::batch_10_transfers": 2374777 +"key_auth_webauthn_3_limits::batch_10_transfers": 2374777 +"key_auth_secp256k1_selector_any_recipient::transfer": 2588784 +"key_auth_secp256k1_selector_recipient::transfer": 3349776 +"keychain_secp256k1::noop": 543739 +"keychain_secp256k1::transfer": 814110 +"keychain_secp256k1::batch_2_transfers": 820200 +"keychain_secp256k1::batch_5_transfers": 838470 +"keychain_secp256k1::batch_10_transfers": 868920 +"keychain_p256::noop": 548779 +"keychain_p256::transfer": 819150 +"keychain_p256::batch_2_transfers": 825240 +"keychain_p256::batch_5_transfers": 843510 +"keychain_p256::batch_10_transfers": 873960 +"keychain_secp256k1_selector_any_recipient::transfer": 2594529 +"keychain_secp256k1_selector_recipient::transfer": 3353606 +"keychain_secp256k1_target_any_selector::transfer": 1829304 diff --git a/crates/precompiles/src/account_keychain/mod.rs b/crates/precompiles/src/account_keychain/mod.rs index 5da9634ef4..ea06820d4a 100644 --- a/crates/precompiles/src/account_keychain/mod.rs +++ b/crates/precompiles/src/account_keychain/mod.rs @@ -28,6 +28,7 @@ use tempo_primitives::TempoAddressExt; use crate::{ ACCOUNT_KEYCHAIN_ADDRESS, error::Result, + native_multisig::NativeMultisig, storage::{Handler, Mapping, Set}, tip20_factory::TIP20Factory, }; @@ -267,6 +268,9 @@ impl AccountKeychain { if key_id == Address::ZERO { return Err(AccountKeychainError::zero_public_key().into()); } + if self.storage.spec().is_t11() && NativeMultisig::new().is_multisig_account(key_id)? { + 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()); @@ -1573,6 +1577,7 @@ mod tests { use revm::state::Bytecode; use tempo_chainspec::hardfork::TempoHardfork; use tempo_contracts::precompiles::{DEFAULT_FEE_TOKEN, IAccountKeychain::SignatureType}; + use tempo_primitives::transaction::{InitMultisig, MultisigOwner}; fn authorize_key( keychain: &mut AccountKeychain, @@ -1669,6 +1674,23 @@ mod tests { tempo_alloy::provider::keychain::KeyRestrictions::default().into() } + fn native_multisig_config() -> InitMultisig { + InitMultisig { + salt: B256::ZERO, + threshold: 1, + owners: vec![ + MultisigOwner { + owner: Address::from([0x11; 20]), + weight: 1, + }, + MultisigOwner { + owner: Address::from([0x22; 20]), + weight: 1, + }, + ], + } + } + #[test] fn test_t6_root_authorizes_admin_key() -> eyre::Result<()> { let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); @@ -1695,6 +1717,116 @@ mod tests { }) } + #[test] + fn test_t11_native_multisig_accounts_can_authorize_and_use_access_keys() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let config = native_multisig_config(); + let account = config.account().unwrap(); + let key_id = Address::from([0x33; 20]); + + StorageCtx::enter(&mut storage, || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(account, &config)?; + + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.set_transaction_key(Address::ZERO)?; + + keychain.authorize_key( + account, + key_id, + SignatureType::Secp256k1, + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: true, + limits: vec![TokenLimit { + token: DEFAULT_FEE_TOKEN, + amount: U256::from(123u64), + period: 0, + }], + allowAnyCalls: true, + allowedCalls: Vec::new(), + }, + None, + )?; + + assert!(keychain.is_admin_key(account, account)?); + assert!(!keychain.is_admin_key(account, key_id)?); + assert!(keychain.is_active_key(account, key_id)?); + + let key = keychain.get_key(getKeyCall { + account, + keyId: key_id, + })?; + assert_eq!(key.keyId, key_id); + assert_eq!(key.expiry, u64::MAX); + assert!(key.enforceLimits); + + let remaining = + keychain.get_remaining_limit_with_period(getRemainingLimitWithPeriodCall { + account, + keyId: key_id, + token: DEFAULT_FEE_TOKEN, + })?; + assert_eq!(remaining.remaining, U256::from(123u64)); + assert_eq!(remaining.periodEnd, 0); + + keychain.set_transaction_key(key_id)?; + keychain.authorize_transfer(account, DEFAULT_FEE_TOKEN, U256::from(23u64))?; + + let remaining = + keychain.get_remaining_limit_with_period(getRemainingLimitWithPeriodCall { + account, + keyId: key_id, + token: DEFAULT_FEE_TOKEN, + })?; + assert_eq!(remaining.remaining, U256::from(100u64)); + + let allowed = keychain.get_allowed_calls(getAllowedCallsCall { + account, + keyId: key_id, + })?; + assert!(!allowed.isScoped); + assert!(allowed.scopes.is_empty()); + + Ok(()) + }) + } + + #[test] + fn test_t11_native_multisig_account_cannot_be_authorized_as_access_key() -> eyre::Result<()> { + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T11); + let config = native_multisig_config(); + let multisig_account = config.account().unwrap(); + let account = Address::from([0x44; 20]); + + StorageCtx::enter(&mut storage, || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(multisig_account, &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 6e0396be97..62c237265a 100644 --- a/crates/precompiles/src/lib.rs +++ b/crates/precompiles/src/lib.rs @@ -204,6 +204,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_t11() { + 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() { @@ -1159,6 +1161,27 @@ mod tests { ); } + #[test] + fn test_native_multisig_registered_at_t11_only() { + let mut t10 = CfgEnv::::default(); + t10.set_spec_and_mainnet_gas_params(TempoHardfork::T10); + assert!( + test_tempo_precompiles(&t10) + .get(&NATIVE_MULTISIG_ADDRESS) + .is_none(), + "NativeMultisig should NOT be registered before T11" + ); + + let mut t11 = CfgEnv::::default(); + t11.set_spec_and_mainnet_gas_params(TempoHardfork::T11); + assert!( + test_tempo_precompiles(&t11) + .get(&NATIVE_MULTISIG_ADDRESS) + .is_some(), + "NativeMultisig should be registered at T11" + ); + } + #[test] fn test_p256verify_availability_across_t1c_boundary() { let has_p256 = |spec: TempoHardfork| -> bool { diff --git a/crates/primitives/src/transaction/multisig.rs b/crates/primitives/src/transaction/multisig.rs index dbfa3bc569..ef3f066c25 100644 --- a/crates/primitives/src/transaction/multisig.rs +++ b/crates/primitives/src/transaction/multisig.rs @@ -661,6 +661,8 @@ pub struct MultisigSignature { signatures: Vec, /// Cached multisig digest for the transaction hash and config version this signature approved. cached_digest: OnceLock<(B256, Address, u64, B256)>, + /// Stored config size inferred by RPC simulation. This is never serialized or signed. + simulation_config_owner_count: Option, } #[cfg(feature = "serde")] @@ -745,6 +747,7 @@ impl MultisigSignature { address, signatures, cached_digest: OnceLock::new(), + simulation_config_owner_count: None, }; signature.validate_shape()?; Ok(signature) @@ -770,6 +773,28 @@ impl MultisigSignature { self.address.init() } + /// Attaches the registered config size inferred for RPC gas simulation. + #[doc(hidden)] + pub fn with_simulation_config_owner_count( + mut self, + owner_count: usize, + ) -> Result { + if owner_count == 0 || owner_count > MAX_MULTISIG_OWNERS { + return Err("invalid multisig simulation owner count"); + } + if self.init().is_some() { + return Err("bootstrap multisig signatures cannot have a stored config owner count"); + } + self.simulation_config_owner_count = Some(owner_count); + Ok(self) + } + + /// Returns the registered config size inferred for RPC gas simulation. + #[doc(hidden)] + pub const fn simulation_config_owner_count(&self) -> Option { + self.simulation_config_owner_count + } + /// Performs stateless sender-recovery checks and returns the attempted multisig account. pub fn recover_account(&self) -> Result { self.validate_shape()?; @@ -1058,7 +1083,7 @@ impl alloy_rlp::Encodable for MultisigSignature { /// - `is_zero` and the fixed / low-range precompile cases fix ~152-160 bits (>= ~2^156 work): not /// grindable in practice, kept as cheap defense-in-depth. /// -/// EVM built-in precompiles are checked locally so native multisig does not change the shared +/// EVM built-in precompiles are checked locally so TIP-1061 does not change the shared /// `is_precompile` behavior used by earlier protocol features. pub fn is_valid_multisig_account(account: Address, spec: TempoHardfork) -> bool { !account.is_zero() @@ -1182,7 +1207,9 @@ mod tests { }; use proptest::prelude::*; use sha2::{Digest, Sha256}; - use tempo_contracts::precompiles::{PATH_USD_ADDRESS, SYSTEM_PRECOMPILES}; + use tempo_contracts::precompiles::{ + NATIVE_MULTISIG_ADDRESS, PATH_USD_ADDRESS, SYSTEM_PRECOMPILES, + }; fn sorted_secp_config(owners: &[(Address, u8)], threshold: u8) -> InitMultisig { let mut owners = owners @@ -1563,6 +1590,7 @@ mod tests { #[test] fn multisig_account_eligibility_uses_current_hardfork_precompile_set() { let identity_precompile = Address::with_last_byte(0x04); + assert!(!identity_precompile.is_precompile(TempoHardfork::T11)); assert!(!is_valid_multisig_account( identity_precompile, TempoHardfork::T11 @@ -1576,6 +1604,15 @@ mod tests { P256VERIFY_ADDRESS, TempoHardfork::T1C )); + + assert!(is_valid_multisig_account( + NATIVE_MULTISIG_ADDRESS, + TempoHardfork::T7 + )); + assert!(!is_valid_multisig_account( + NATIVE_MULTISIG_ADDRESS, + TempoHardfork::T11 + )); assert!(!is_valid_multisig_account( PATH_USD_ADDRESS, TempoHardfork::Genesis diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 2ffded2a74..bf692584b1 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -3,7 +3,9 @@ use alloy_evm::error::InvalidTxError; use alloy_primitives::{Address, U256}; use revm::context::result::{EVMError, ExecutionResult, HaltReason, InvalidTransaction}; -use tempo_primitives::transaction::{KeyAuthorizationChainIdError, KeychainVersionError}; +use tempo_primitives::transaction::{ + KeyAuthorizationChainIdError, KeychainVersionError, MultisigConfigError, MultisigQuorumError, +}; /// Tempo-specific invalid transaction errors. /// @@ -236,10 +238,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, config, quorum, or authorization 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), @@ -316,6 +337,7 @@ impl TempoInvalidTransaction { | Self::ExpiringNonceNonceNotZero | Self::SubblockTransactionMustHaveZeroFee | Self::KeychainOpInSubblockTransaction + | Self::NativeMultisigInvalidTransaction { .. } | Self::LegacyKeychainSignature | Self::CallsValidation(_) => true, @@ -330,6 +352,7 @@ impl TempoInvalidTransaction { | Self::KeychainPrecompileError { .. } | Self::KeychainValidationFailed { .. } | Self::NativeMultisigNotActive + | Self::NativeMultisigValidationFailed { .. } | Self::CollectFeePreTx(_) | Self::NonceManagerError(_) | Self::V2KeychainBeforeActivation => false, @@ -365,6 +388,22 @@ impl From<&'static str> for TempoInvalidTransaction { } } +impl From for TempoInvalidTransaction { + fn from(err: MultisigConfigError) -> Self { + Self::NativeMultisigValidationFailed { + reason: err.as_str().to_string(), + } + } +} + +impl From for TempoInvalidTransaction { + fn from(err: MultisigQuorumError) -> Self { + Self::NativeMultisigValidationFailed { + reason: String::from(err), + } + } +} + impl From for TempoInvalidTransaction { fn from(err: KeychainVersionError) -> Self { match err { @@ -527,6 +566,38 @@ 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" + ); + + let config_error = TempoInvalidTransaction::from(MultisigConfigError::ZeroThreshold); + assert!(!config_error.is_bad_transaction()); + assert!(config_error.to_string().contains("threshold")); + + let quorum_error = TempoInvalidTransaction::from(MultisigQuorumError::WeightBelowThreshold); + assert!(!quorum_error.is_bad_transaction()); + assert!(quorum_error.to_string().contains("below threshold")); + } + #[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..7f831c514c 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, + InitMultisig, KeyAuthorization, KeychainSignature, MultisigOwner, MultisigSignature, + SignatureType, TempoSignedAuthorization, multisig_digest, tempo_transaction::Call, tt_signature::{ PrimitiveSignature, TempoSignature, WebAuthnSignature, derive_p256_address, @@ -499,6 +505,24 @@ mod tests { evm } + /// Create an EVM with T11 hardfork enabled and a funded account. + fn create_funded_evm_t11(address: Address) -> TempoEvm, ()> { + let db = CacheDB::new(EmptyDB::new()); + let mut cfg = CfgEnv::::default(); + cfg.spec = TempoHardfork::T11; + cfg.gas_params = tempo_gas_params(TempoHardfork::T11); + + 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, address); + evm + } + /// 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 +894,138 @@ mod tests { // ==================== End Test Utility Functions ==================== + #[test] + fn test_t11_bootstrap_multisig_persists_after_transaction_commit() -> eyre::Result<()> { + let mut signers = [ + PrivateKeySigner::from_bytes(&B256::from([0x11; 32]))?, + PrivateKeySigner::from_bytes(&B256::from([0x22; 32]))?, + ]; + signers.sort_by_key(|signer| signer.address()); + + let config = InitMultisig { + salt: B256::repeat_byte(0x44), + threshold: 1, + owners: signers + .iter() + .map(|signer| MultisigOwner { + owner: signer.address(), + weight: 1, + }) + .collect(), + }; + let account = config.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(signers[0].sign_hash_sync(&digest)?).to_bytes(); + let signed_tx = tx.into_signed(TempoSignature::Multisig(MultisigSignature::new( + account, + vec![owner_signature], + Some(config.clone()), + ))); + + let mut evm = create_funded_evm_t11(account); + StorageCtx::enter_ctx(&mut evm.ctx, StorageActions::disabled(), || { + NativeMultisig::new().initialize() + })?; + + let result = evm.transact_commit(TempoTxEnv::from_recovered_tx(&signed_tx, account))?; + assert!(result.is_success()); + + let nonce = evm + .ctx + .db() + .basic_ref(account)? + .map(|info| info.nonce) + .unwrap_or_default(); + assert_eq!(nonce, 1); + + StorageCtx::enter_ctx( + &mut evm.ctx, + StorageActions::disabled(), + || -> eyre::Result<()> { + let multisig = NativeMultisig::new(); + assert!(multisig.is_multisig_account(account)?); + assert_eq!( + multisig.get_multisig_config(account)?.threshold, + config.threshold + ); + Ok(()) + }, + )?; + + Ok(()) + } + + #[test] + fn test_t11_rpc_simulation_bootstrap_transacts_repeatedly_on_one_evm() -> eyre::Result<()> { + let mut signers = [ + PrivateKeySigner::from_bytes(&B256::from([0x11; 32]))?, + PrivateKeySigner::from_bytes(&B256::from([0x22; 32]))?, + ]; + signers.sort_by_key(|signer| signer.address()); + + let config = InitMultisig { + salt: B256::repeat_byte(0x55), + threshold: 1, + owners: signers + .iter() + .map(|signer| MultisigOwner { + owner: signer.address(), + weight: 1, + }) + .collect(), + }; + let account = config.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(signers[0].sign_hash_sync(&digest)?).to_bytes(); + let signed_tx = tx.into_signed(TempoSignature::Multisig(MultisigSignature::new( + account, + vec![owner_signature], + Some(config), + ))); + + let mut evm = create_funded_evm_t11(account); + StorageCtx::enter_ctx(&mut evm.ctx, StorageActions::disabled(), || { + NativeMultisig::new().initialize() + })?; + + let mut tx_env = TempoTxEnv::from_recovered_tx(&signed_tx, account); + tx_env.execution_context = crate::ExecutionContext::Simulation; + + // Gas estimation transacts repeatedly on one EVM, discarding each + // result. The discarded bootstrap must not poison later iterations. + let first = evm.transact(tx_env.clone())?; + assert!(first.result.is_success(), "first simulation should succeed"); + + let second = evm.transact(tx_env)?; + assert!( + second.result.is_success(), + "repeat simulation should succeed: {:?}", + second.result + ); + + // Discarded simulations leave no registered account behind. + StorageCtx::enter_ctx( + &mut evm.ctx, + StorageActions::disabled(), + || -> eyre::Result<()> { + assert!(!NativeMultisig::new().is_multisig_account(account)?); + Ok(()) + }, + )?; + + 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 faf04b6ad7..35cd194d55 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -6,7 +6,7 @@ use std::{ sync::{Arc, OnceLock}, }; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, B256, U256}; use reth_evm::{EvmError, EvmInternals}; use revm::{ Database, @@ -32,10 +32,12 @@ use revm::{ interpreter::EthInterpreter, }, precompile::PrecompileError, + state::AccountInfo, }; use tempo_chainspec::constants::gas::STORAGE_CREDIT_VALUE; -use tempo_contracts::precompiles::{ - IAccountKeychain::SignatureType as PrecompileSignatureType, TIPFeeAMMError, +use tempo_contracts::{ + TempoHardfork, + precompiles::{IAccountKeychain::SignatureType as PrecompileSignatureType, TIPFeeAMMError}, }; use tempo_precompiles::{ ECRECOVER_GAS, @@ -44,6 +46,10 @@ use tempo_precompiles::{ SelectorRule as PrecompileSelectorRule, TokenLimit, }, error::TempoPrecompileError, + native_multisig::{ + NativeMultisig, + auth::{NativeMultisigAuthConfig, NativeMultisigAuthError}, + }, nonce::{INonce::getNonceCall, NonceManager}, storage::{ Handler as _, PrecompileStorageProvider, StorageActions, StorageCtx, @@ -55,18 +61,29 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoAddressExt, transaction::{ - SignatureType, TEMPO_EXPIRING_NONCE_KEY, calc_gas_balance_spending, validate_calls, + InitMultisig, MAX_MULTISIG_NESTING_DEPTH, MultisigSignature, SignatureType, + TEMPO_EXPIRING_NONCE_KEY, TempoSignature, calc_gas_balance_spending, + is_valid_multisig_account, validate_calls, }, }; use crate::{ - ProtocolFeeContext, TempoBatchCallEnv, TempoEvm, TempoInvalidTransaction, + ExecutionContext, ProtocolFeeContext, TempoBatchCallEnv, TempoEvm, TempoInvalidTransaction, error::{FeePaymentError, TempoHaltReason}, evm::TempoContext, gas_credits, - signature_gas::tempo_signature_verification_gas, + signature_gas::{ + calculate_native_multisig_bootstrap_storage_gas, + native_multisig_complete_config_validation_gas, tempo_signature_verification_gas, + }, }; +#[cfg(test)] +use crate::signature_gas::{ + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS, NATIVE_MULTISIG_OWNER_WEIGHT_GAS, + NATIVE_MULTISIG_VALIDATION_GAS, native_multisig_bootstrap_storage_slots, + 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 +314,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 @@ -405,8 +422,12 @@ 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())?; + 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 +1037,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 +1044,417 @@ where .unwrap_or_default(); let spec = cfg.spec(); + let mut native_multisig_bootstrap: Option<(Address, InitMultisig)> = 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()); + // Multisig accounts name their caller directly, so an unfunded bootstrap can otherwise + // supply a fully valid worst-case quorum before the ordinary fee check runs. Reject it + // before owner verification, then reuse the result at the normal fee-deduction point. + // Subblock fee failures must remain after nonce consumption and use the normal path below. + let early_multisig_balance = if spec.is_t11() + && !tx.is_subblock_transaction() + && (outer_multisig_signature.is_some() + || key_authorization_multisig_signature.is_some()) + { + 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 has_keychain_authorization_list_entry = tempo_tx_env.is_some_and(|aa| { + aa.tempo_authorization_list + .iter() + .any(|auth| auth.signature().is_keychain()) + }); + 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_account_info = + if spec.is_t11() && (validates_caller_multisig || outer_keychain_signature) { + Some(journal.load_account(tx.caller())?.data.info.clone()) + } else { + None + }; + let keychain_caller_has_code = spec.is_t11() + && outer_keychain_signature + && !caller_account_info + .as_ref() + .expect("loaded for a keychain transaction") + .is_empty_code_hash(); + // Primitive signatures cannot feasibly recover to derived multisig addresses, and + // keychain validation is handled separately. + let requires_native_multisig_state = outer_multisig_signature.is_some() + || key_authorization_multisig_signature.is_some() + || key_authorization_key_id.is_some() + || has_keychain_authorization_list_entry + || keychain_caller_has_code; + + if spec.is_t11() && requires_native_multisig_state { + let is_rpc_simulation = tx.execution_context() == ExecutionContext::Simulation; + + let native_multisig_registry_validation_gas = { + 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_precompile = NativeMultisig::new(); + + if keychain_caller_has_code + && multisig_precompile + .is_multisig_account(tx.caller()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + { + 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_precompile + .is_multisig_account(key_id) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig account {key_id} cannot be used as an access key" + ), + } + .into()); + } + + let ensure_authority_not_multisig = |authority| -> Result< + (), + EVMError, + > { + if multisig_precompile + .is_multisig_account(authority) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + { + // Whether an authority is a native multisig account is read from + // storage, so this rejection depends on chain state and can flip + // after signing/relay. Classify it as a state-dependent validation + // failure (not a bad transaction) so honest relays are not penalized. + // The deterministic payload-only checks in validate_env stay + // NativeMultisigInvalidTransaction. + let error = TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig account {authority} cannot be used as an authorization-list authority" + ), + }; + return Err(error.into()); + } + Ok(()) + }; + + if let Some(tempo_tx_env) = tempo_tx_env { + for auth in tempo_tx_env + .tempo_authorization_list + .iter() + .filter(|auth| auth.signature().is_keychain()) + { + if let Some(authority) = auth.authority() { + ensure_authority_not_multisig(authority)?; + } + } + } + + Ok::<(), EVMError>(()) + }); + let gas_used = provider.gas_used(); + validation?; + gas_used + }; + + let native_multisig_config_validation_gas = StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + actions.clone(), + |multisig_precompile: NativeMultisig| -> Result< + u64, + EVMError, + > { + let root_owner_count = if validates_caller_multisig { + multisig_precompile + .load_registered_header_if_present(tx.caller()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + .map(|header| header.owner_count) + } else { + None + }; + let mut gas = 0u64; + for signature in [ + outer_multisig_signature, + key_authorization_multisig_signature, + ] + .into_iter() + .flatten() + { + gas = gas.saturating_add( + native_multisig_config_validation_gas_for_signature::( + &multisig_precompile, + signature, + root_owner_count, + is_rpc_simulation, + 1, + )?, + ); + } + Ok(gas) + }, + )?; + + init_gas.initial_regular_gas = init_gas + .initial_regular_gas + .saturating_add(native_multisig_registry_validation_gas) + .saturating_add(native_multisig_config_validation_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()); + } + + for signature in [ + outer_multisig_signature, + key_authorization_multisig_signature, + ] + .into_iter() + .flatten() + { + for approval in signature.signatures() { + let TempoSignature::Multisig(nested) = approval else { + continue; + }; + if !journal + .load_account(nested.account())? + .data + .info + .is_empty_code_hash() + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig owner account {} cannot have code or EIP-7702 delegation", + nested.account() + ), + } + .into()); + } + } + } + + StorageCtx::enter_precompile( + journal, + block, + cfg, + tx, + actions.clone(), + |multisig_precompile: NativeMultisig| -> Result< + (), + EVMError, + > { + let caller_multisig_config = if validates_caller_multisig { + multisig_precompile + .load_registered_config_if_present(tx.caller()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + } else { + None + }; + let caller_is_multisig = caller_multisig_config.is_some(); + + if validates_caller_multisig + && !caller_account_info + .as_ref() + .expect("loaded when validating caller multisig authorization") + .is_empty_code_hash() + { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig account cannot have code or EIP-7702 delegation" + .to_string(), + } + .into()); + } + + if let Some(signature) = outer_multisig_signature { + let tempo_tx_env = tempo_tx_env + .expect("outer multisig signature is derived from tempo_tx_env"); + if caller_is_multisig { + if signature.init().is_some() { + return Err( + TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig account already initialized" + .to_string(), + } + .into(), + ); + } + signature.validate_registered_shape().map_err(|reason| { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: reason.to_string(), + } + })?; + let config = caller_multisig_config + .as_ref() + .expect("caller is a registered native multisig account"); + if !is_rpc_simulation { + verify_native_multisig_authorization::( + &multisig_precompile, + tempo_tx_env.signature_hash, + signature, + NativeMultisigAuthConfig::Registered(config.clone()), + )?; + } + } else { + let init_config = signature.init().ok_or_else(|| { + TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: + "first native multisig transaction requires multisig_init" + .to_string(), + } + })?; + validate_native_multisig_bootstrap_nonce( + caller_account_info + .as_ref() + .expect("loaded when validating caller multisig authorization"), + )?; + if !is_rpc_simulation { + verify_native_multisig_authorization::( + &multisig_precompile, + tempo_tx_env.signature_hash, + signature, + NativeMultisigAuthConfig::Inline(init_config), + )?; + } + native_multisig_bootstrap = + Some((signature.account(), init_config.clone())); + } + + if is_rpc_simulation { + validate_rpc_nested_multisig_configs::( + &multisig_precompile, + signature, + )?; + } + } + + if let Some(signature) = key_authorization_multisig_signature { + let key_auth = tempo_tx_env + .and_then(|aa| aa.key_authorization.as_ref()) + .expect("key authorization multisig signature is derived from key auth"); + if signature.account() != tx.caller() { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "multisig key authorization must be signed by the transaction caller" + .to_string(), + } + .into()); + } + let config = if let Some(init_config) = signature.init() { + if caller_is_multisig { + return Err( + TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig account already initialized" + .to_string(), + } + .into(), + ); + } + if native_multisig_bootstrap.is_some() { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "multisig_init must be carried by exactly one of the outer or key authorization signatures".to_string(), + } + .into()); + } + validate_native_multisig_bootstrap_nonce( + caller_account_info + .as_ref() + .expect("loaded when validating caller multisig authorization"), + )?; + NativeMultisigAuthConfig::Inline(init_config) + } else if let Some((account, init_config)) = + native_multisig_bootstrap.as_ref() + && signature.account() == *account + { + NativeMultisigAuthConfig::BootstrapCompanion(init_config) + } else { + signature.validate_registered_shape().map_err(|reason| { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: reason.to_string(), + } + })?; + let config = caller_multisig_config.ok_or_else( + || -> EVMError { + TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: format!( + "native multisig account {} is not registered", + signature.account() + ), + } + .into() + }, + )?; + NativeMultisigAuthConfig::Registered(config) + }; + let bootstrap = match &config { + NativeMultisigAuthConfig::Inline(init_config) + if native_multisig_bootstrap.is_none() => + { + Some((signature.account(), (*init_config).clone())) + } + _ => None, + }; + + if is_rpc_simulation { + validate_rpc_nested_multisig_configs::( + &multisig_precompile, + signature, + )?; + } else { + verify_native_multisig_authorization::( + &multisig_precompile, + key_auth.authorization.signature_hash(), + signature, + config, + )?; + } + if let Some(bootstrap) = bootstrap { + native_multisig_bootstrap = Some(bootstrap); + } + } + + Ok(()) + }, + )?; + } + + // 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 +1659,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; @@ -1367,14 +1799,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) @@ -1486,6 +1921,26 @@ where evm.collected_fee = gas_balance_spending; } + // Native multisig bootstrap is validated before fee collection, but the config write must + // happen in the committed pre-execution phase. This also seeds the transient + // bootstrapped-account guard before user calls can reach the multisig precompile. + if let Some((account, config)) = native_multisig_bootstrap { + StorageCtx::enter_evm_without_tip1060_accounting( + journal, + block, + cfg, + tx, + actions.clone(), + || { + let mut multisig = NativeMultisig::new(); + multisig + .store_initial_config(account, &config) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::) + }, + )?; + } + // If the transaction includes a KeyAuthorization, validate and authorize the key // only after fee collection has succeeded. This pre-execution write is deliberately // outside the later user-call batch checkpoint, so same-transaction authorize-and-use @@ -1804,19 +2259,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- @@ -1850,25 +2292,121 @@ where .validate_version(cfg.spec().is_t1c()) .map_err(TempoInvalidTransaction::from)?; if key_auth.signature.is_keychain() { - return Err(TempoInvalidTransaction::KeychainValidationFailed { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason: "key authorization signatures cannot use keychain encoding" .to_string(), } .into()); } - if key_auth.signature.is_multisig() { - return Err(TempoInvalidTransaction::NativeMultisigNotActive.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_t11() { + 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() { + let account = validate_native_multisig_signature_account( + multisig_signature, + tx.caller, + *cfg.spec(), + "multisig signature account does not match transaction caller", + )?; + if aa_env + .tempo_authorization_list + .iter() + .any(|auth| auth.authority() == Some(account)) + { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: format!( + "native multisig account {account} cannot be used as an authorization-list authority" + ), + } + .into()); + } + } 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() { + let account = 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()); + } + if signature.init().is_some() { + if aa_env + .tempo_authorization_list + .iter() + .any(|auth| auth.authority() == Some(account)) + { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: format!( + "native multisig account {account} cannot be used as an authorization-list authority" + ), + } + .into()); + } + if aa_env + .signature + .as_multisig() + .is_some_and(|outer| outer.init().is_some()) + { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "multisig_init must be carried by exactly one of the outer or key authorization signatures" + .to_string(), + } + .into()); + } + if !aa_env.signature.is_v2_keychain() { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "key authorization multisig_init requires an outer V2 keychain signature" + .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; @@ -1941,7 +2479,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 })?; @@ -1987,7 +2525,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() { @@ -2284,6 +2822,7 @@ pub struct ValidationContext { /// - Key authorization costs (if present): /// - Pre-T1B: 27k base + 3k ecrecover + 22k per spending limit /// - T1B+: ecrecover + SLOAD + SSTORE × (1 + N limits) +/// - Native multisig bootstrap storage costs when `multisig_init` is present /// - Floor gas calculation (EIP-7623, Prague+) pub fn calculate_aa_batch_intrinsic_gas<'a>( aa_env: &TempoBatchCallEnv, @@ -2303,6 +2842,25 @@ pub fn calculate_aa_batch_intrinsic_gas<'a>( // 2. Signature verification gas gas.initial_regular_gas += tempo_signature_verification_gas(signature); + // 2b. Native multisig bootstrap storage costs. The initial configuration may be carried by + // either the outer transaction signature or its key authorization signature. + let native_multisig_init = signature + .as_multisig() + .and_then(|multisig_signature| multisig_signature.init()) + .or_else(|| { + key_authorization + .and_then(|key_auth| key_auth.signature.as_multisig()) + .and_then(|multisig_signature| multisig_signature.init()) + }); + if spec.is_t11() + && let Some(init) = native_multisig_init + { + let (regular_gas, state_gas) = + calculate_native_multisig_bootstrap_storage_gas(init, gas_params, spec); + gas.initial_regular_gas += regular_gas; + gas.initial_state_gas += state_gas; + } + let cold_account_cost = gas_params.warm_storage_read_cost() + gas_params.cold_account_additional_cost(); @@ -2605,5 +3163,150 @@ pub fn validate_time_window( Ok(()) } +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 signature.init().is_some() && !is_valid_multisig_account(account, spec) { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "multisig_init derives a reserved account address".to_string(), + }); + } + Ok(account) +} + +fn verify_native_multisig_authorization( + multisig: &NativeMultisig, + inner_digest: B256, + signature: &MultisigSignature, + config: NativeMultisigAuthConfig<'_>, +) -> Result<(), EVMError> { + multisig + .verify_authorization(inner_digest, signature, config, |account| { + multisig + .load_registered_config(account) + .map_err(NativeMultisigAuthError::from) + }) + .map_err(map_native_multisig_error::) +} + +fn native_multisig_config_validation_gas_for_signature( + multisig: &NativeMultisig, + signature: &MultisigSignature, + registered_owner_count: Option, + is_rpc_simulation: bool, + depth: usize, +) -> Result> { + let has_simulation_hint = + is_rpc_simulation && signature.simulation_config_owner_count().is_some(); + let mut gas = if !has_simulation_hint { + registered_owner_count + .map(native_multisig_complete_config_validation_gas) + .unwrap_or_default() + } else { + 0 + }; + + for approval in signature.signatures() { + let TempoSignature::Multisig(nested) = approval else { + continue; + }; + nested.validate_registered_shape().map_err(|reason| { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: format!("invalid nested multisig owner signature: {reason}"), + } + })?; + if depth >= MAX_MULTISIG_NESTING_DEPTH { + return Err(TempoInvalidTransaction::NativeMultisigInvalidTransaction { + reason: "native multisig nesting depth exceeded".to_string(), + } + .into()); + } + let nested_has_simulation_hint = + is_rpc_simulation && nested.simulation_config_owner_count().is_some(); + let nested_owner_count = if !nested_has_simulation_hint { + Some( + multisig + .load_registered_header(nested.account()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)? + .owner_count, + ) + } else { + None + }; + gas = gas.saturating_add(native_multisig_config_validation_gas_for_signature::( + multisig, + nested, + nested_owner_count, + is_rpc_simulation, + depth + 1, + )?); + } + + Ok(gas) +} + +fn validate_rpc_nested_multisig_configs( + multisig: &NativeMultisig, + signature: &MultisigSignature, +) -> Result<(), EVMError> { + for approval in signature.signatures() { + let TempoSignature::Multisig(nested) = approval else { + continue; + }; + + if nested.simulation_config_owner_count().is_none() { + multisig + .load_registered_config(nested.account()) + .map_err(NativeMultisigAuthError::from) + .map_err(map_native_multisig_error::)?; + } + + validate_rpc_nested_multisig_configs::(multisig, nested)?; + } + + Ok(()) +} + +fn validate_native_multisig_bootstrap_nonce( + account_info: &AccountInfo, +) -> Result<(), TempoInvalidTransaction> { + if account_info.nonce != 0 { + return Err(TempoInvalidTransaction::NativeMultisigValidationFailed { + reason: "native multisig bootstrap requires zero protocol nonce".to_string(), + }); + } + Ok(()) +} + +fn map_native_multisig_error( + err: NativeMultisigAuthError, +) -> EVMError { + match err { + NativeMultisigAuthError::Fatal(err) => { + EVMError::::Custom(err) + } + NativeMultisigAuthError::InvalidTransaction(reason) => { + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason }.into() + } + NativeMultisigAuthError::ValidationFailed(reason) => { + TempoInvalidTransaction::NativeMultisigValidationFailed { reason }.into() + } + } +} + #[cfg(test)] mod tests; diff --git a/crates/revm/src/handler/tests.rs b/crates/revm/src/handler/tests.rs index 9d9e59f373..368417862e 100644 --- a/crates/revm/src/handler/tests.rs +++ b/crates/revm/src/handler/tests.rs @@ -1,18 +1,17 @@ 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, - }, + evm::TempoEvm, gas_params::tempo_gas_params, signature_gas::P256_VERIFY_GAS, tx::TempoBatchCallEnv, }; +use alloy_eips::eip7702::{Authorization, RecoveredAuthority, RecoveredAuthorization}; use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; use proptest::prelude::*; use revm::{ Context, Journal, MainContext, - context::CfgEnv, + context::{CfgEnv, either::Either}, database::{CacheDB, EmptyDB}, handler::Handler, interpreter::{ @@ -20,16 +19,20 @@ 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, + PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS, account_keychain::getTransactionKeyCall, + native_multisig::RegisteredMultisigConfig, storage::ContractStorage, test_util::TIP20Setup, tip_fee_manager::TipFeeManager, }; use tempo_primitives::transaction::{ - Call, InitMultisig, KeyAuthorization, MultisigOwner, MultisigSignature, PrimitiveSignature, - RecoveredTempoAuthorization, SignatureType, TempoSignature, TempoSignedAuthorization, + Call, InitMultisig, KeyAuthorization, KeychainSignature, MAX_MULTISIG_OWNER_SIGNATURE_BYTES, + MAX_MULTISIG_OWNERS, MAX_MULTISIG_SIGNATURES, MAX_WEBAUTHN_SIGNATURE_LENGTH, MultisigOwner, + MultisigSignature, MultisigSignatureError, PrimitiveSignature, RecoveredTempoAuthorization, + TempoSignature, TempoSignedAuthorization, derive_p256_address, multisig_digest, tt_signature::{P256SignatureWithPreHash, WebAuthnSignature}, }; @@ -41,6 +44,102 @@ fn create_test_journal() -> Journal> { type TestHandlerEvmResult = Result as revm::Database>::Error, TempoInvalidTransaction>>; +fn native_multisig_config() -> InitMultisig { + InitMultisig { + salt: B256::ZERO, + 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) -> InitMultisig { + InitMultisig { + salt: B256::repeat_byte(salt), + threshold: 1, + owners: vec![MultisigOwner { owner, weight: 1 }], + } +} + +fn sign_native_multisig( + config: &InitMultisig, + inner_digest: B256, + signer: &PrivateKeySigner, + include_init: bool, +) -> TempoSignature { + let account = config.account().unwrap(); + let config_version = if include_init { 0 } else { 1 }; + let owner_signature = PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&multisig_digest(inner_digest, account, config_version)) + .expect("owner signing succeeds"), + ) + .to_bytes(); + TempoSignature::Multisig(MultisigSignature::new( + account, + vec![owner_signature], + include_init.then(|| config.clone()), + )) +} + +fn authorization(_authority: Address) -> Authorization { + Authorization { + chain_id: U256::ONE, + address: Address::random(), + nonce: 0, + } +} + +fn recovered_authorization(authority: Address) -> RecoveredAuthorization { + RecoveredAuthorization::new_unchecked( + authorization(authority), + RecoveredAuthority::Valid(authority), + ) +} + +fn tempo_authorization(authority: Address) -> RecoveredTempoAuthorization { + RecoveredTempoAuthorization::new_unchecked( + TempoSignedAuthorization::new_unchecked( + authorization(authority), + TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + ), + RecoveredAuthority::Valid(authority), + ) +} + +fn tempo_keychain_authorization(authority: Address) -> RecoveredTempoAuthorization { + RecoveredTempoAuthorization::new_unchecked( + TempoSignedAuthorization::new_unchecked( + authorization(authority), + TempoSignature::Keychain(KeychainSignature::new( + authority, + PrimitiveSignature::default(), + )), + ), + RecoveredAuthority::Valid(authority), + ) +} + +fn store_native_multisig_account(test: &mut TestHandlerEvm, config: &InitMultisig) { + let account = config.account().unwrap(); + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(account, config) + }) + .expect("native multisig setup succeeds"); +} + struct TestHandlerEvm { evm: TempoEvm, ()>, handler: TempoEvmHandler, ()>, @@ -48,7 +147,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) } @@ -59,6 +163,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() }; @@ -3476,6 +3583,185 @@ mod keychain { ); } + #[test] + fn test_t11_registered_multisig_can_use_keychain_signature() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let access_key = Address::repeat_byte(0x44); + let (mut evm, h) = make_evm(account, access_key, None, TempoHardfork::T11, None, false); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(account, &config)?; + + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.set_transaction_key(Address::ZERO)?; + keychain.authorize_key( + account, + access_key, + PrecompileSignatureType::Secp256k1, + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: true, + limits: vec![TokenLimit { + token: DEFAULT_FEE_TOKEN, + amount: U256::from(1_000u64), + period: 0, + }], + allowAnyCalls: true, + allowedCalls: vec![], + }, + None, + )?; + + Ok::<_, TempoPrecompileError>(()) + }) + .expect("native multisig access-key setup succeeds"); + + let result = h.validate_against_state_and_deduct_caller(&mut evm, &mut Default::default()); + assert!( + result.is_ok(), + "registered multisig keychain transaction 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_t11_registered_multisig_with_code_rejects_keychain_signature() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let access_key = Address::repeat_byte(0x44); + let (mut evm, h) = make_evm(account, access_key, None, TempoHardfork::T11, None, false); + + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(account, &config)?; + + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(account)?; + keychain.set_transaction_key(Address::ZERO)?; + keychain.authorize_key( + account, + access_key, + PrecompileSignatureType::Secp256k1, + KeyRestrictions { + expiry: u64::MAX, + enforceLimits: true, + limits: vec![TokenLimit { + token: DEFAULT_FEE_TOKEN, + amount: U256::from(1_000u64), + period: 0, + }], + allowAnyCalls: true, + allowedCalls: vec![], + }, + None, + )?; + + Ok::<_, TempoPrecompileError>(()) + }) + .expect("native multisig access-key setup succeeds"); + + 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() + }, + ); + + 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_t11_key_authorization_rejects_multisig_access_key() { + let (signer, user) = generate_keypair(); + let config = native_multisig_config(); + let multisig_account = config.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::T11, + None, + false, + ); + StorageCtx::enter_ctx(&mut evm.inner.ctx, StorageActions::disabled(), || { + let mut multisig = NativeMultisig::new(); + multisig.initialize()?; + multisig.store_initial_config(multisig_account, &config) + }) + .expect("native multisig setup succeeds"); + + 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_t11_ordinary_key_authorization_charges_one_registry_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::T11, + None, + false, + ); + let gas_params = tempo_gas_params(TempoHardfork::T11); + 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(); @@ -4483,15 +4769,75 @@ fn test_state_gas_failed_batch_preserves_upfront_create_intrinsic_gas() { } #[test] -fn native_multisig_execution_remains_inactive() { - let config = InitMultisig { - salt: B256::ZERO, - threshold: 1, - owners: vec![MultisigOwner { - owner: Address::repeat_byte(0x11), - weight: 1, +fn test_t10_rejects_native_multisig_signature() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T10, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigNotActive + )) + ), + "native multisig signatures should be rejected before T11" + ); +} + +#[test] +fn test_t10_rejects_native_multisig_key_authorization_signature() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x33)) + .with_account(account) + .into_signed(TempoSignature::Multisig(MultisigSignature::new( + account, + vec![PrimitiveSignature::default().to_bytes()], + Some(config), + ))); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), + key_authorization: Some(key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), }], + ..Default::default() }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T10, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + assert!(matches!( + test.validate_env(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigNotActive + )) + )); +} + +#[test] +fn test_t11_unfunded_multisig_rejects_before_owner_verification() { + let config = native_multisig_config(); let account = config.account().unwrap(); let aa_env = TempoBatchCallEnv { signature: TempoSignature::Multisig(MultisigSignature::new( @@ -4508,28 +4854,39 @@ fn native_multisig_execution_remains_inactive() { }; let mut test = TestHandlerEvm::aa(TempoHardfork::T11, 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_env(), + test.validate_against_state_and_deduct_caller(), Err(EVMError::Transaction( - TempoInvalidTransaction::NativeMultisigNotActive + TempoInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::LackOfFundForMaxFee { .. } + ) )) )); } #[test] -fn native_multisig_key_authorization_remains_inactive() { - let account = Address::repeat_byte(0x44); +fn test_t11_rejects_keychain_key_authorization_signature() { + let caller = Address::repeat_byte(0x11); let key_authorization = - KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x55)) - .with_account(account) - .into_signed(TempoSignature::Multisig(MultisigSignature::new( - account, - vec![PrimitiveSignature::default().to_bytes()], - None, + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x33)) + .into_signed(TempoSignature::Keychain(KeychainSignature::new( + caller, + PrimitiveSignature::default(), ))); let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), key_authorization: Some(key_authorization), aa_calls: vec![Call { to: TxKind::Call(Address::random()), @@ -4539,13 +4896,1794 @@ fn native_multisig_key_authorization_remains_inactive() { ..Default::default() }; let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { - tx_env.inner.caller = account; + tx_env.inner.caller = caller; }); assert!(matches!( test.validate_env(), Err(EVMError::Transaction( - TempoInvalidTransaction::NativeMultisigNotActive - )) + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason } + )) if reason.contains("cannot use keychain encoding") )); } + +#[test] +fn test_t11_primitive_transaction_skips_native_multisig_storage() { + let caller = Address::repeat_byte(0x11); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = caller; + }); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + test.validate_against_state_and_deduct_caller() + .expect("plain primitive transaction should pass"); + + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "plain primitive transaction must not read native multisig storage: {actions:?}" + ); +} + +#[test] +fn test_t11_primitive_fee_payer_skips_native_multisig_storage() { + let config = native_multisig_config(); + let fee_payer = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + tx_env.fee_payer = Some(Some(fee_payer)); + }); + store_native_multisig_account(&mut test, &config); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("primitive fee payer is accepted under the collision assumption"); + + assert_eq!(init_gas.initial_regular_gas, 0); + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "primitive fee payer must not read native multisig storage: {actions:?}" + ); +} + +#[test] +fn test_t11_primitive_auth_list_skips_native_multisig_storage() { + let authorities = [Address::repeat_byte(0x22), Address::repeat_byte(0x33)]; + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: authorities.map(tempo_authorization).to_vec(), + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + }); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("ordinary authorization-list authorities should pass"); + + assert_eq!(init_gas.initial_regular_gas, 0); + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "primitive authorization-list authorities must not read native multisig storage: {actions:?}" + ); +} + +#[test] +fn test_t11_keychain_auth_list_registry_read_is_metered() { + let authority = Address::repeat_byte(0x22); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![tempo_keychain_authorization(authority)], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + }); + + let cold_storage_read_gas = test.gas_params().warm_storage_read_cost() + + test.gas_params().cold_storage_additional_cost(); + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("ordinary keychain authority should pass"); + + assert_eq!(init_gas.initial_regular_gas, cold_storage_read_gas); +} + +#[test] +fn test_t11_keychain_auth_list_rejects_registered_multisig_authority() { + let config = native_multisig_config(); + let authority = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![tempo_keychain_authorization(authority)], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x11); + }); + store_native_multisig_account(&mut test, &config); + + let result = test.validate_against_state_and_deduct_caller(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + )) if reason.contains("authorization-list authority") + ), + "registered multisig keychain authority should be rejected" + ); +} + +#[test] +fn test_t11_auth_list_rejects_multisig_signature_statelessly() { + let account = Address::repeat_byte(0x22); + let authorization = RecoveredTempoAuthorization::new_unchecked( + TempoSignedAuthorization::new_unchecked( + authorization(account), + TempoSignature::Multisig(MultisigSignature::new( + account, + vec![PrimitiveSignature::default().to_bytes()], + None, + )), + ), + RecoveredAuthority::Valid(account), + ); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![authorization], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, 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_t11_primitive_aa_auth_list_does_not_gate_registered_address() { + let config = native_multisig_config(); + let authority = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![tempo_authorization(authority)], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::random(); + }); + store_native_multisig_account(&mut test, &config); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + test.validate_against_state_and_deduct_caller() + .expect("primitive authority is accepted under the collision assumption"); + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "primitive authority must not read native multisig storage: {actions:?}" + ); +} + +#[test] +fn test_t11_standard_auth_list_does_not_gate_registered_address() { + let config = native_multisig_config(); + let authority = config.account().unwrap(); + let mut test = TestHandlerEvm::tx(TempoHardfork::T11, |tx_env| { + tx_env.inner.caller = Address::random(); + tx_env.inner.kind = TxKind::Call(Address::random()); + tx_env.inner.authorization_list = vec![Either::Right(recovered_authorization(authority))]; + }); + store_native_multisig_account(&mut test, &config); + let actions = StorageActions::enabled(); + test.evm = test.evm.with_actions(actions.clone()); + + test.validate_against_state_and_deduct_caller() + .expect("standard primitive authority is accepted under the collision assumption"); + let actions = actions.take().expect("storage actions are enabled"); + assert!( + actions + .iter() + .all(|action| action.address() != NATIVE_MULTISIG_ADDRESS), + "standard primitive authority must not read native multisig storage: {actions:?}" + ); +} + +#[test] +fn test_t11_bootstrap_auth_list_rejects_current_multisig_authority() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![tempo_authorization(account)], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason } + )) if reason.contains("authorization-list authority") + ), + "bootstrap multisig account cannot be an authorization-list authority" + ); +} + +#[test] +fn test_t11_multisig_signature_rejects_caller_mismatch_in_env_validation() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = Address::repeat_byte(0x99); + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { reason } + )) if reason.contains("account does not match transaction caller") + ), + "native multisig caller mismatch should be rejected statically" + ); +} + +#[test] +fn test_t11_subblock_rejects_native_multisig_signature() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config), + )), + subblock_transaction: true, + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { ref reason } + )) if reason.contains("subblock") + ), + "unexpected validation result: {result:?}" + ); +} + +#[test] +fn test_t11_bootstrap_multisig_persists_initial_config() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let mut signers = [ + PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(), + PrivateKeySigner::from_bytes(&B256::from([0x22; 32])).unwrap(), + ]; + signers.sort_by_key(|signer| signer.address()); + + let config = InitMultisig { + salt: B256::repeat_byte(0x44), + threshold: 1, + owners: signers + .iter() + .map(|signer| MultisigOwner { + owner: signer.address(), + weight: 1, + }) + .collect(), + }; + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x42); + let digest = multisig_digest(signature_hash, account, 0); + let owner_signature = PrimitiveSignature::Secp256k1( + signers[0] + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes(); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![owner_signature], + Some(config.clone()), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + tx_hash: B256::repeat_byte(0x24), + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + + test.validate_against_state_and_deduct_caller() + .expect("valid bootstrap transaction should pass"); + + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + let multisig = NativeMultisig::new(); + assert!(multisig.is_multisig_account(account)?); + let stored = multisig.get_multisig_config(account)?; + assert_eq!(stored.version, 1); + assert_eq!(stored.threshold, config.threshold); + Ok::<_, TempoPrecompileError>(()) + }) + .expect("stored native multisig config should be readable"); +} + +#[test] +fn test_t11_bootstrap_multisig_rejects_nonzero_protocol_nonce() { + let owner = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let config = single_owner_native_multisig_config(0x44, owner.address()); + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x42); + let aa_env = TempoBatchCallEnv { + signature: sign_native_multisig(&config, signature_hash, &owner, true), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.nonce = 1; + }); + test.evm.ctx().db_mut().insert_account_info( + account, + AccountInfo { + nonce: 1, + ..Default::default() + }, + ); + + assert!(matches!( + test.validate_against_state_and_deduct_caller(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + )) if reason.contains("zero protocol nonce") + )); +} + +#[test] +fn test_t11_key_authorization_can_bootstrap_and_authorize_access_key() { + let owner = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let access_key = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x22)).unwrap(); + let config = single_owner_native_multisig_config(0x44, owner.address()); + let account = config.account().unwrap(); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, access_key.address()) + .with_account(account); + let signed_key_authorization = key_authorization.clone().into_signed(sign_native_multisig( + &config, + key_authorization.signature_hash(), + &owner, + true, + )); + let signature_hash = B256::repeat_byte(0x42); + let access_key_signature = PrimitiveSignature::Secp256k1( + access_key + .sign_hash_sync(&KeychainSignature::signing_hash(signature_hash, account)) + .unwrap(), + ); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Keychain(KeychainSignature::new(account, access_key_signature)), + key_authorization: Some(signed_key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + tx_hash: B256::repeat_byte(0x24), + ..Default::default() + }; + let mut nonzero_nonce_test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env.clone(), |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + tx_env.inner.nonce = 1; + }); + nonzero_nonce_test.evm.ctx().db_mut().insert_account_info( + account, + AccountInfo { + nonce: 1, + ..Default::default() + }, + ); + assert!(matches!( + nonzero_nonce_test.validate_against_state_and_deduct_caller(), + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + )) if reason.contains("zero protocol nonce") + )); + + let mut registered_test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env.clone(), |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut registered_test, &config); + let Err(EVMError::Transaction(err)) = + registered_test.validate_against_state_and_deduct_caller() + else { + panic!("registered account should reject key-authorization bootstrap"); + }; + assert!(matches!( + &err, + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + if reason.contains("already initialized") + )); + assert!(!err.is_bad_transaction()); + + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + + test.validate_env().expect("T11 key authorization is valid"); + test.validate_against_state_and_deduct_caller() + .expect("key authorization bootstrap succeeds"); + + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + assert!(NativeMultisig::new().is_multisig_account(account)?); + assert!(AccountKeychain::new().is_active_key(account, access_key.address())?); + Ok::<_, TempoPrecompileError>(()) + }) + .expect("bootstrap config and access key are stored"); +} + +#[test] +fn test_t11_outer_bootstrap_config_can_authorize_companion_key_auth() { + let owner = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let access_key = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x22)).unwrap(); + let config = single_owner_native_multisig_config(0x44, owner.address()); + let account = config.account().unwrap(); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, access_key.address()) + .with_account(account); + let authorization_digest = multisig_digest(key_authorization.signature_hash(), account, 0); + let authorization_signature = PrimitiveSignature::Secp256k1( + owner + .sign_hash_sync(&authorization_digest) + .expect("owner signing succeeds"), + ); + let signed_key_authorization = key_authorization.into_signed(TempoSignature::Multisig( + MultisigSignature::new(account, vec![authorization_signature.to_bytes()], None), + )); + let signature_hash = B256::repeat_byte(0x42); + let aa_env = TempoBatchCallEnv { + signature: sign_native_multisig(&config, signature_hash, &owner, true), + key_authorization: Some(signed_key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + test.validate_against_state_and_deduct_caller() + .expect("companion key authorization uses the outer bootstrap config"); + + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + assert!(NativeMultisig::new().is_multisig_account(account)?); + assert!(AccountKeychain::new().is_active_key(account, access_key.address())?); + Ok::<_, TempoPrecompileError>(()) + }) + .expect("bootstrap config and access key are stored"); +} + +#[test] +fn test_t11_key_authorization_bootstrap_rejects_current_multisig_authority() { + let owner = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let access_key = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x22)).unwrap(); + let config = single_owner_native_multisig_config(0x44, owner.address()); + let account = config.account().unwrap(); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, access_key.address()) + .with_account(account); + let signed_key_authorization = key_authorization.clone().into_signed(sign_native_multisig( + &config, + key_authorization.signature_hash(), + &owner, + true, + )); + let signature_hash = B256::repeat_byte(0x42); + let access_key_signature = PrimitiveSignature::Secp256k1( + access_key + .sign_hash_sync(&KeychainSignature::signing_hash(signature_hash, account)) + .unwrap(), + ); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Keychain(KeychainSignature::new(account, access_key_signature)), + key_authorization: Some(signed_key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + tempo_authorization_list: vec![tempo_authorization(account)], + signature_hash, + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { ref reason } + )) if reason.contains("authorization-list authority") + ), + "key-authorization bootstrap account cannot be an authorization-list authority: {result:?}" + ); +} + +#[test] +fn test_t11_multisig_key_authorization_rejects_different_account() { + let caller = Address::repeat_byte(0x44); + let signing_account = Address::repeat_byte(0x55); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, Address::repeat_byte(0x66)) + .with_account(caller) + .into_signed(TempoSignature::Multisig(MultisigSignature::new( + signing_account, + vec![PrimitiveSignature::default().to_bytes()], + None, + ))); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::default()), + key_authorization: Some(key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = caller; + }); + + let result = test.validate_env(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigInvalidTransaction { ref reason } + )) if reason.contains("must be signed by the transaction caller") + ), + "a multisig account cannot authorize a key for another account: {result:?}" + ); +} + +#[test] +fn test_t11_registered_multisig_can_authorize_access_key() { + let owner = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let access_key = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x22)).unwrap(); + let config = single_owner_native_multisig_config(0x44, owner.address()); + let account = config.account().unwrap(); + let key_authorization = + KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, access_key.address()) + .with_account(account); + let signed_key_authorization = key_authorization.clone().into_signed(sign_native_multisig( + &config, + key_authorization.signature_hash(), + &owner, + false, + )); + let signature_hash = B256::repeat_byte(0x42); + let aa_env = TempoBatchCallEnv { + signature: sign_native_multisig(&config, signature_hash, &owner, false), + key_authorization: Some(signed_key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + tx_hash: B256::repeat_byte(0x24), + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &config); + + test.validate_env().expect("T11 key authorization is valid"); + let cold_storage_read_gas = test.gas_params().warm_storage_read_cost() + + test.gas_params().cold_storage_additional_cost(); + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("registered multisig key authorization succeeds"); + assert_eq!( + init_gas.initial_regular_gas, + 2 * native_multisig_complete_config_validation_gas(config.owners.len()) + + cold_storage_read_gas, + "multisig roots should charge complete config validation and the access key should charge one registry read" + ); + + StorageCtx::enter_ctx(&mut test.evm.inner.ctx, StorageActions::disabled(), || { + assert!(AccountKeychain::new().is_active_key(account, access_key.address())?); + Ok::<_, TempoPrecompileError>(()) + }) + .expect("access key is stored"); +} + +#[test] +fn test_t11_rpc_simulation_skips_registered_multisig_owner_verification() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + None, + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + tx_env.execution_context = ExecutionContext::Simulation; + }); + store_native_multisig_account(&mut test, &config); + + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("RPC simulation should skip native multisig owner-signature verification"); + assert_eq!( + init_gas.initial_regular_gas, + native_multisig_complete_config_validation_gas(config.owners.len()), + "RPC simulation should charge every stored owner and direct-weight row" + ); +} + +#[test] +fn test_t11_signed_transaction_ignores_multisig_simulation_hint() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let signature = MultisigSignature::new(account, vec![Bytes::from_static(&[0xaa; 65])], None) + .with_simulation_config_owner_count(config.owners.len()) + .unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(signature), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &config); + + let mut init_gas = InitialAndFloorGas::default(); + assert!( + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .is_err() + ); + assert_eq!( + init_gas.initial_regular_gas, + native_multisig_complete_config_validation_gas(config.owners.len()), + "non-serialized simulation hints must not suppress transaction gas" + ); +} + +#[test] +fn test_t11_registered_multisig_checks_state_gas_before_owner_signatures() { + let config = native_multisig_config(); + let owner_count = config.owners.len(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + None, + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &config); + + let mut init_gas = test.validate_initial_tx_gas(); + let required_gas = init_gas + .initial_total_gas() + .saturating_add(native_multisig_complete_config_validation_gas(owner_count)); + test.evm.inner.ctx.tx.inner.gas_limit = required_gas - 1; + + assert!(matches!( + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas), + Err(EVMError::Transaction( + TempoInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::CallGasCostMoreThanGasLimit { .. } + ) + )) + )); +} + +#[test] +fn test_t11_rpc_simulation_charges_nested_key_authorization_config() { + use tempo_primitives::transaction::SignatureType; + + let nested_config = single_owner_native_multisig_config(0x41, Address::from([0x11; 20])); + let nested_account = nested_config.account().unwrap(); + let config = single_owner_native_multisig_config(0x42, nested_account); + let account = config.account().unwrap(); + let access_key = Address::from([0x33; 20]); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let nested_signature = TempoSignature::Multisig(MultisigSignature::new( + nested_account, + vec![owner_signature], + None, + )); + let key_authorization = KeyAuthorization::unrestricted(1, SignatureType::Secp256k1, access_key) + .with_account(account) + .into_signed(TempoSignature::Multisig( + MultisigSignature::from_decoded(account, vec![nested_signature], None).unwrap(), + )); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Primitive(PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + )), + key_authorization: Some(key_authorization), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + tx_env.execution_context = ExecutionContext::Simulation; + }); + store_native_multisig_account(&mut test, &config); + store_native_multisig_account(&mut test, &nested_config); + + let cold_storage_read_gas = test.gas_params().warm_storage_read_cost() + + test.gas_params().cold_storage_additional_cost(); + let mut init_gas = InitialAndFloorGas::default(); + test.handler + .validate_against_state_and_deduct_caller(&mut test.evm, &mut init_gas) + .expect("RPC simulation should model nested key-authorization config validation"); + // The fixed nested-account surcharge is intrinsic signature gas; this accumulator records + // only the state-dependent registry and complete-config reads. + assert_eq!( + init_gas.initial_regular_gas, + cold_storage_read_gas + + native_multisig_complete_config_validation_gas(config.owners.len()) + + native_multisig_complete_config_validation_gas(nested_config.owners.len()), + ); +} + +#[test] +fn test_t11_registered_account_rejects_multisig_init_outside_simulation() { + let config = native_multisig_config(); + let account = config.account().unwrap(); + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + account, + vec![Bytes::from_static(&[0xaa; 65])], + Some(config.clone()), + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &config); + + let Err(EVMError::Transaction(err)) = test.validate_against_state_and_deduct_caller() else { + panic!("registered account should reject bootstrap"); + }; + assert!(matches!( + &err, + TempoInvalidTransaction::NativeMultisigValidationFailed { reason } + if reason.contains("already initialized") + )); + assert!(!err.is_bad_transaction()); +} + +#[test] +fn test_t11_registered_native_multisig_accepts_max_depth_nested_multisig_owner() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let signer = PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(); + let child_config = single_owner_native_multisig_config(0x42, signer.address()); + let child_account = child_config.account().unwrap(); + let parent_config = single_owner_native_multisig_config(0x43, child_account); + let parent_account = parent_config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x44); + let parent_digest = multisig_digest(signature_hash, parent_account, 1); + let child_digest = multisig_digest(parent_digest, child_account, 1); + let child_owner_signature = PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&child_digest) + .expect("owner signing succeeds"), + ) + .to_bytes(); + let nested_signature = TempoSignature::Multisig(MultisigSignature::new( + child_account, + vec![child_owner_signature], + None, + )) + .to_bytes(); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + parent_account, + vec![nested_signature], + None, + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + tx_hash: B256::repeat_byte(0x45), + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = parent_account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &child_config); + store_native_multisig_account(&mut test, &parent_config); + + test.validate_against_state_and_deduct_caller() + .expect("nested native multisig owner should authorize parent multisig"); +} + +#[test] +fn test_t11_registered_native_multisig_rejects_code_bearing_nested_owner() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let signer = PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(); + let child_config = single_owner_native_multisig_config(0x42, signer.address()); + let child_account = child_config.account().unwrap(); + let parent_config = single_owner_native_multisig_config(0x43, child_account); + let parent_account = parent_config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x44); + let parent_digest = multisig_digest(signature_hash, parent_account, 1); + let child_digest = multisig_digest(parent_digest, child_account, 1); + let child_owner_signature = PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&child_digest) + .expect("owner signing succeeds"), + ) + .to_bytes(); + let nested_signature = TempoSignature::Multisig(MultisigSignature::new( + child_account, + vec![child_owner_signature], + None, + )) + .to_bytes(); + + let aa_env = TempoBatchCallEnv { + signature: TempoSignature::Multisig(MultisigSignature::new( + parent_account, + vec![nested_signature], + None, + )), + aa_calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::new(), + }], + signature_hash, + tx_hash: B256::repeat_byte(0x45), + ..Default::default() + }; + let mut test = TestHandlerEvm::aa(TempoHardfork::T11, aa_env, |tx_env| { + tx_env.inner.caller = parent_account; + tx_env.inner.kind = TxKind::Call(Address::random()); + }); + store_native_multisig_account(&mut test, &child_config); + store_native_multisig_account(&mut test, &parent_config); + let code = revm::bytecode::Bytecode::new_raw(Bytes::from_static(&[0x00])); + test.evm.ctx().db_mut().insert_account_info( + child_account, + AccountInfo { + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); + + let result = test.validate_against_state_and_deduct_caller(); + assert!( + matches!( + result, + Err(EVMError::Transaction( + TempoInvalidTransaction::NativeMultisigValidationFailed { ref reason } + )) if reason.contains("owner account") && reason.contains("cannot have code") + ), + "nested multisig owner with code must be rejected: {result:?}" + ); +} + +fn registered_multisig_auth_config( + config: &InitMultisig, + version: u64, +) -> NativeMultisigAuthConfig<'static> { + NativeMultisigAuthConfig::Registered(RegisteredMultisigConfig { + account: config.account().expect("valid test config"), + threshold: config.threshold, + version, + owners: config.owners.clone(), + }) +} + +#[test] +fn native_multisig_authorization_classifies_signer_order_as_invalid_transaction() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let mut signers = [ + PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(), + PrivateKeySigner::from_bytes(&B256::from([0x22; 32])).unwrap(), + ]; + signers.sort_by_key(|signer| signer.address()); + + let config = InitMultisig { + salt: B256::repeat_byte(0x42), + threshold: 2, + owners: signers + .iter() + .map(|signer| MultisigOwner { + owner: signer.address(), + weight: 1, + }) + .collect(), + }; + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x43); + let digest = multisig_digest(signature_hash, account, 0); + let mut signed = signers + .iter() + .map(|signer| { + let signature = PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes(); + (signer.address(), signature) + }) + .collect::>(); + signed.sort_by_key(|(owner, _)| *owner); + signed.reverse(); + + let signature = MultisigSignature::new( + account, + signed.into_iter().map(|(_, signature)| signature).collect(), + Some(config.clone()), + ); + let result = NativeMultisig::new().verify_authorization( + signature_hash, + &signature, + NativeMultisigAuthConfig::Inline(&config), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + + assert!( + matches!( + result, + Err(NativeMultisigAuthError::InvalidTransaction(reason)) + if reason.contains("ascending") + ), + "recovered owner order is fixed by the transaction and should be bad" + ); + + let digest = multisig_digest(signature_hash, account, 1); + let mut signed = signers + .iter() + .map(|signer| { + let signature = PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes(); + (signer.address(), signature) + }) + .collect::>(); + signed.sort_by_key(|(owner, _)| *owner); + signed.reverse(); + let signature = MultisigSignature::new( + account, + signed.into_iter().map(|(_, signature)| signature).collect(), + None, + ); + let result = NativeMultisig::new().verify_authorization( + signature_hash, + &signature, + registered_multisig_auth_config(&config, 1), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + assert!(matches!( + result, + Err(NativeMultisigAuthError::ValidationFailed(reason)) + if reason.contains("ascending") + )); +} + +#[test] +fn native_multisig_authorization_classifies_non_owner_by_config_source() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let signer = PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(); + let config = single_owner_native_multisig_config(0x42, Address::repeat_byte(0x22)); + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x43); + let signature_for_version = |version, init| { + let digest = multisig_digest(signature_hash, account, version); + MultisigSignature::new( + account, + vec![ + PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes(), + ], + init, + ) + }; + let inline_signature = signature_for_version(0, Some(config.clone())); + + let result = NativeMultisig::new().verify_authorization( + signature_hash, + &inline_signature, + NativeMultisigAuthConfig::Inline(&config), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + + assert!( + matches!( + result, + Err(NativeMultisigAuthError::InvalidTransaction(reason)) + if reason.contains("not an owner") + ), + "inline owner membership is fixed by the transaction payload" + ); + + let registered_signature = signature_for_version(1, None); + let result = NativeMultisig::new().verify_authorization( + signature_hash, + ®istered_signature, + registered_multisig_auth_config(&config, 1), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + assert!( + matches!( + result, + Err(NativeMultisigAuthError::ValidationFailed(reason)) + if reason.contains("not an owner") + ), + "registered authorization must bind membership to the canonical owner list" + ); +} + +#[test] +fn native_multisig_authorization_binds_registered_config_version() { + let signer = PrivateKeySigner::from_bytes(&B256::repeat_byte(0x11)).unwrap(); + let config = single_owner_native_multisig_config(0x42, signer.address()); + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x43); + let signature_for_version = |version| { + let digest = multisig_digest(signature_hash, account, version); + MultisigSignature::new( + account, + vec![ + PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes(), + ], + None, + ) + }; + let verify = |signature: &MultisigSignature| { + NativeMultisig::new().verify_authorization( + signature_hash, + signature, + registered_multisig_auth_config(&config, 1), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ) + }; + + assert!(matches!( + verify(&signature_for_version(0)), + Err(NativeMultisigAuthError::ValidationFailed(_)) + )); + verify(&signature_for_version(1)).expect("current config version should authorize"); +} + +#[test] +fn native_multisig_authorization_classifies_stale_p256_by_config_source() { + use p256::{ + ecdsa::{SigningKey, signature::hazmat::PrehashSigner}, + elliptic_curve::rand_core::OsRng, + }; + use sha2::{Digest, Sha256}; + use tempo_primitives::transaction::tt_signature::normalize_p256_s; + + let signing_key = SigningKey::random(&mut OsRng); + let encoded_point = signing_key.verifying_key().to_encoded_point(false); + let pub_key_x = B256::from_slice(encoded_point.x().unwrap()); + let pub_key_y = B256::from_slice(encoded_point.y().unwrap()); + let owner = derive_p256_address(&pub_key_x, &pub_key_y); + let config = single_owner_native_multisig_config(0x42, owner); + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x43); + let signature_for_version = |version, include_init: bool| { + let digest = multisig_digest(signature_hash, account, version); + let prehashed = Sha256::digest(digest); + let signature: p256::ecdsa::Signature = signing_key + .sign_prehash(&prehashed) + .expect("owner signing succeeds"); + let signature_bytes = signature.to_bytes(); + MultisigSignature::new( + account, + vec![ + PrimitiveSignature::P256(P256SignatureWithPreHash { + r: B256::from_slice(&signature_bytes[..32]), + s: normalize_p256_s(&signature_bytes[32..]) + .expect("p256 crate produces valid s"), + pub_key_x, + pub_key_y, + pre_hash: true, + }) + .to_bytes(), + ], + include_init.then(|| config.clone()), + ) + }; + let verify_registered = |signature: &MultisigSignature| { + NativeMultisig::new().verify_authorization( + signature_hash, + signature, + registered_multisig_auth_config(&config, 1), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ) + }; + + let stale_signature = signature_for_version(0, false); + let bootstrap_signature = signature_for_version(0, true); + NativeMultisig::new() + .verify_authorization( + signature_hash, + &bootstrap_signature, + NativeMultisigAuthConfig::Inline(&config), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ) + .expect("version-zero approval should authorize bootstrap"); + assert!(matches!( + verify_registered(&stale_signature), + Err(NativeMultisigAuthError::ValidationFailed(reason)) + if reason == "invalid multisig owner signature" + )); + + let current_signature = signature_for_version(1, false); + let current_inline_signature = signature_for_version(1, true); + verify_registered(¤t_signature).expect("current config version should authorize"); + assert!(matches!( + NativeMultisig::new().verify_authorization( + signature_hash, + ¤t_inline_signature, + NativeMultisigAuthConfig::Inline(&config), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ), + Err(NativeMultisigAuthError::InvalidTransaction(reason)) + if reason == "invalid multisig owner signature" + )); +} + +#[test] +fn native_multisig_authorization_rejects_trailing_owner_approvals() { + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_primitives::transaction::{PrimitiveSignature, multisig_digest}; + + let mut signers = [ + PrivateKeySigner::from_bytes(&B256::from([0x11; 32])).unwrap(), + PrivateKeySigner::from_bytes(&B256::from([0x22; 32])).unwrap(), + ]; + signers.sort_by_key(|signer| signer.address()); + + let config = InitMultisig { + salt: B256::repeat_byte(0x42), + threshold: 1, + owners: signers + .iter() + .map(|signer| MultisigOwner { + owner: signer.address(), + weight: 1, + }) + .collect(), + }; + let account = config.account().unwrap(); + let signature_hash = B256::repeat_byte(0x43); + let digest = multisig_digest(signature_hash, account, 0); + let signatures = signers + .iter() + .map(|signer| { + PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes() + }) + .collect(); + + let signature = MultisigSignature::new(account, signatures, Some(config.clone())); + let result = NativeMultisig::new().verify_authorization( + signature_hash, + &signature, + NativeMultisigAuthConfig::Inline(&config), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + + assert!( + matches!( + result, + Err(NativeMultisigAuthError::InvalidTransaction(reason)) + if reason.contains("excess") + ), + "unused trailing approvals should make multisig signatures non-canonical" + ); + + let digest = multisig_digest(signature_hash, account, 1); + let signatures = signers + .iter() + .map(|signer| { + PrimitiveSignature::Secp256k1( + signer + .sign_hash_sync(&digest) + .expect("owner signing succeeds"), + ) + .to_bytes() + }) + .collect(); + let signature = MultisigSignature::new(account, signatures, None); + let result = NativeMultisig::new().verify_authorization( + signature_hash, + &signature, + registered_multisig_auth_config(&config, 1), + |_| unreachable!("primitive owner approvals should not load nested configs"), + ); + assert!(matches!( + result, + Err(NativeMultisigAuthError::ValidationFailed(reason)) + if reason.contains("excess") + )); +} + +#[test] +fn native_multisig_authorization_rejects_oversized_owner_approval_before_decode() { + let signature = MultisigSignature::try_new( + Address::repeat_byte(0x11), + vec![Bytes::from(vec![ + 0xaa; + MAX_MULTISIG_OWNER_SIGNATURE_BYTES + 1 + ])], + None, + ); + + assert!( + matches!( + signature, + Err(MultisigSignatureError::OwnerSignatureTooLarge) + ), + "oversized owner approval should be rejected before constructing a multisig signature" + ); +} + +#[test] +fn test_aa_gas_native_multisig_1_of_1_secp256k1_overhead() { + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(MultisigSignature::new( + Address::from([0x44; 20]), + vec![owner_signature], + None, + )); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let expected_overhead = NATIVE_MULTISIG_VALIDATION_GAS + NATIVE_MULTISIG_OWNER_WEIGHT_GAS; + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "1-of-1 native multisig should add validation plus one owner-weight lookup because base tx gas already covers one secp256k1 verification" + ); + assert_eq!( + multisig_gas.initial_state_gas, base_gas.initial_state_gas, + "native multisig signature validation overhead is regular intrinsic gas" + ); + assert_eq!( + multisig_gas.initial_total_gas() - base_gas.initial_total_gas(), + expected_overhead + ); +} + +#[test] +fn test_aa_gas_native_multisig_extra_secp256k1_owner_overhead() { + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(MultisigSignature::new( + Address::from([0x44; 20]), + vec![owner_signature.clone(), owner_signature], + None, + )); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let expected_overhead = + NATIVE_MULTISIG_VALIDATION_GAS + 2 * NATIVE_MULTISIG_OWNER_WEIGHT_GAS + ECRECOVER_GAS; + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "native multisig should charge extra owner signatures after subtracting the traditional secp256k1 verification covered by base tx gas" + ); +} + +#[test] +fn test_aa_gas_native_multisig_p256_owner_overhead() { + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = PrimitiveSignature::P256(P256SignatureWithPreHash { + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + pre_hash: false, + }) + .to_bytes(); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(MultisigSignature::new( + Address::from([0x44; 20]), + vec![owner_signature], + None, + )); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let expected_overhead = + NATIVE_MULTISIG_VALIDATION_GAS + NATIVE_MULTISIG_OWNER_WEIGHT_GAS + P256_VERIFY_GAS; + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "1-of-1 P256 native multisig should pay validation plus P256 cost relative to the traditional secp256k1 tx baseline" + ); +} + +#[test] +fn test_aa_gas_native_multisig_nested_owner_overhead() { + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let nested_signature = TempoSignature::Multisig(MultisigSignature::new( + Address::from([0x33; 20]), + vec![owner_signature], + None, + )) + .to_bytes(); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(MultisigSignature::new( + Address::from([0x44; 20]), + vec![nested_signature], + None, + )); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let expected_overhead = (NATIVE_MULTISIG_VALIDATION_GAS + NATIVE_MULTISIG_OWNER_WEIGHT_GAS) * 2 + + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS; + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "nested native multisig should charge validation for both accounts and subtract the base secp256k1 verification only once" + ); +} + +#[test] +fn test_aa_gas_native_multisig_simulation_models_nested_config_validation() { + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let nested_signature = TempoSignature::Multisig( + MultisigSignature::new(Address::from([0x33; 20]), vec![owner_signature], None) + .with_simulation_config_owner_count(2) + .unwrap(), + ); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig( + MultisigSignature::from_decoded(Address::from([0x44; 20]), vec![nested_signature], None) + .unwrap(), + ); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let expected_overhead = (NATIVE_MULTISIG_VALIDATION_GAS + NATIVE_MULTISIG_OWNER_WEIGHT_GAS) * 2 + + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS + + native_multisig_complete_config_validation_gas(2); + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "RPC simulation should include complete validation of nested registered configs" + ); +} + +#[test] +fn test_aa_gas_native_multisig_bootstrap_charges_packed_storage_slots_t11() { + use tempo_chainspec::constants::gas::SSTORE_CREATE_COST; + + let gas_params = tempo_gas_params(TempoHardfork::T11); + let base_env = make_single_call_env(Bytes::from(vec![1, 2])); + let owner_signature = + PrimitiveSignature::Secp256k1(alloy_primitives::Signature::test_signature()).to_bytes(); + let config = native_multisig_config(); + let mut multisig_env = base_env.clone(); + multisig_env.signature = TempoSignature::Multisig(MultisigSignature::new( + config.account().unwrap(), + vec![owner_signature], + Some(config.clone()), + )); + + let base_gas = calculate_aa_batch_intrinsic_gas( + &base_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let multisig_gas = calculate_aa_batch_intrinsic_gas( + &multisig_env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + + let storage_slots = native_multisig_bootstrap_storage_slots(&config); + assert_eq!(storage_slots, 1 + 2 * config.owners.len() as u64); + + let expected_storage_gas = (SSTORE_CREATE_COST + gas_params.sstore_static_gas()) + * storage_slots + + gas_params.cold_storage_cost() * storage_slots.saturating_sub(1) + + 2 * gas_params.warm_storage_read_cost() + + revm::interpreter::gas::LOG + + gas_params.log_cost(2, 0); + let expected_overhead = + NATIVE_MULTISIG_VALIDATION_GAS + NATIVE_MULTISIG_OWNER_WEIGHT_GAS + expected_storage_gas; + assert_eq!( + multisig_gas.initial_regular_gas - base_gas.initial_regular_gas, + expected_overhead, + "bootstrap should charge packed account header, indexed and lookup owner slots, and the initialization event" + ); + assert_eq!( + multisig_gas.initial_state_gas, base_gas.initial_state_gas, + "native multisig bootstrap storage stays in regular intrinsic gas under non-Amsterdam params" + ); +} + +#[test] +fn test_aa_gas_max_native_multisig_bootstrap_allows_recursive_authorization_t11() { + use tempo_chainspec::constants::gas::{SSTORE_CREATE_COST, TEMPO_T1_TX_GAS_LIMIT_CAP}; + + let gas_params = tempo_gas_params(TempoHardfork::T11); + let config = InitMultisig { + salt: B256::ZERO, + threshold: MAX_MULTISIG_SIGNATURES as u8, + owners: (1..=MAX_MULTISIG_OWNERS as u16) + .map(|index| MultisigOwner { + owner: Address::from_word(B256::from(U256::from(index))), + weight: 1, + }) + .collect(), + }; + let webauthn_signature = PrimitiveSignature::WebAuthn(WebAuthnSignature { + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + webauthn_data: Bytes::from(vec![0xff; MAX_WEBAUTHN_SIGNATURE_LENGTH - 128]), + }) + .to_bytes(); + let account = config.account().unwrap(); + let nested_signatures = config + .owners + .iter() + .take(MAX_MULTISIG_SIGNATURES) + .map(|owner| { + TempoSignature::Multisig( + MultisigSignature::new( + owner.owner, + vec![webauthn_signature.clone(); MAX_MULTISIG_SIGNATURES], + None, + ) + .with_simulation_config_owner_count(MAX_MULTISIG_OWNERS) + .unwrap(), + ) + }) + .collect(); + let mut env = make_single_call_env(Bytes::new()); + env.signature = TempoSignature::Multisig( + MultisigSignature::from_decoded(account, nested_signatures, Some(config)).unwrap(), + ); + + let gas = calculate_aa_batch_intrinsic_gas( + &env, + &gas_params, + None::>, + TempoHardfork::T11, + ) + .unwrap(); + let bootstrap_gas = gas + .initial_total_gas() + .saturating_add(gas_params.get(GasId::new_account_cost())); + + assert!( + bootstrap_gas <= TEMPO_T1_TX_GAS_LIMIT_CAP, + "the maximum config and recursive authorization must fit the transaction gas cap" + ); + const OWNERS_TO_PREVIOUS_LIMIT: u64 = 2; + assert!( + bootstrap_gas + + 2 * OWNERS_TO_PREVIOUS_LIMIT + * (SSTORE_CREATE_COST + + gas_params.sstore_static_gas() + + gas_params.cold_storage_cost()) + + MAX_MULTISIG_SIGNATURES as u64 + * native_multisig_complete_config_validation_gas(OWNERS_TO_PREVIOUS_LIMIT as usize,) + > TEMPO_T1_TX_GAS_LIMIT_CAP, + "the previous 50-owner limit would exceed the transaction gas cap" + ); +} diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 1acda365e5..0ecf11a4f2 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -25,5 +25,10 @@ pub use evm::TempoEvm; pub use fee_manager::{FeeTokenResolver, ProtocolFeeContext, ProtocolFeeManager, TempoFeeManager}; pub use handler::{ValidationContext, calculate_aa_batch_intrinsic_gas}; pub use revm::interpreter::instructions::utility::IntoAddress; +pub use signature_gas::{ + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS, NATIVE_MULTISIG_OWNER_WEIGHT_GAS, + NATIVE_MULTISIG_VALIDATION_GAS, P256_VERIFY_GAS, + native_multisig_complete_config_validation_gas, +}; pub use tempo_primitives::TempoBlockEnv; pub use tx::{ExecutionContext, TempoBatchCallEnv, TempoTxEnv}; diff --git a/crates/revm/src/signature_gas.rs b/crates/revm/src/signature_gas.rs index aa7b9484ba..0b72917bc5 100644 --- a/crates/revm/src/signature_gas.rs +++ b/crates/revm/src/signature_gas.rs @@ -1,17 +1,52 @@ -use revm::interpreter::gas::{ - COLD_SLOAD_COST, STANDARD_TOKEN_COST, get_tokens_in_calldata_istanbul, +use revm::{ + context_interface::cfg::{GasId, GasParams, gas::COLD_ACCOUNT_ACCESS_COST}, + interpreter::gas::{ + COLD_SLOAD_COST, LOG, STANDARD_TOKEN_COST, get_tokens_in_calldata_istanbul, + }, +}; +use tempo_chainspec::{constants::gas::STORAGE_CREDIT_VALUE, hardfork::TempoHardfork}; +use tempo_precompiles::ECRECOVER_GAS; +use tempo_primitives::transaction::{ + InitMultisig, MAX_MULTISIG_NESTING_DEPTH, MultisigSignature, PrimitiveSignature, TempoSignature, }; -use tempo_primitives::transaction::{PrimitiveSignature, TempoSignature}; /// Additional gas for P256 signature verification. /// /// This includes the P256 precompile cost, the extra signature calldata, and the ecrecover savings /// already included in the base transaction cost. -pub(crate) const P256_VERIFY_GAS: u64 = 5_000; +pub 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; +/// Additional gas for each native multisig config/header validation. +/// +/// Owner signature verification and owner-weight lookups are charged separately, relative to the +/// secp256k1 verification already covered by the base transaction stipend. +pub const NATIVE_MULTISIG_VALIDATION_GAS: u64 = COLD_SLOAD_COST; + +/// Additional gas for each native multisig owner-weight lookup. +pub const NATIVE_MULTISIG_OWNER_WEIGHT_GAS: u64 = COLD_SLOAD_COST; + +/// Gas for checking that each nested multisig owner account has no code or delegation. +pub const NATIVE_MULTISIG_NESTED_ACCOUNT_GAS: u64 = COLD_ACCOUNT_ACCESS_COST; + +/// Additional storage reads required to validate every ordered owner row against its direct +/// weight row before using a registered configuration. +#[inline] +pub fn native_multisig_complete_config_validation_gas(owner_count: usize) -> u64 { + (owner_count as u64) + .saturating_mul(2) + .saturating_mul(COLD_SLOAD_COST) +} + +/// Persistent storage rows created by native multisig bootstrap before owner rows: +/// the packed `{ threshold, owner_count, version }` account header. +const NATIVE_MULTISIG_BOOTSTRAP_FIXED_STORAGE_SLOTS: u64 = 1; + +/// Topic count for the non-anonymous `MultisigInitialized(address indexed account)` event. +const NATIVE_MULTISIG_BOOTSTRAP_EVENT_TOPICS: u8 = 2; + /// Calculates the gas cost for verifying a primitive signature. /// /// Returns the additional gas required beyond the base transaction cost: @@ -30,6 +65,65 @@ pub(crate) fn primitive_signature_verification_gas(signature: &PrimitiveSignatur } } +/// Calculates full primitive owner-signature verification gas. +/// +/// Unlike transaction signatures, owner approvals are nested inside the multisig signature, so this +/// returns the full verification cost before the top-level native multisig schedule subtracts the +/// one traditional secp256k1 verification already included in base transaction gas. +#[inline] +fn native_multisig_primitive_owner_signature_verification_gas( + signature: &PrimitiveSignature, +) -> u64 { + ECRECOVER_GAS + primitive_signature_verification_gas(signature) +} + +fn native_multisig_owner_approval_verification_gas( + signature: &TempoSignature, + depth: usize, +) -> u64 { + match signature { + TempoSignature::Primitive(primitive) => { + native_multisig_primitive_owner_signature_verification_gas(primitive) + } + TempoSignature::Multisig(multisig_signature) if depth < MAX_MULTISIG_NESTING_DEPTH => { + NATIVE_MULTISIG_NESTED_ACCOUNT_GAS.saturating_add( + native_multisig_signature_verification_gas(multisig_signature, false, depth + 1), + ) + } + TempoSignature::Keychain(_) | TempoSignature::Multisig(_) => { + ECRECOVER_GAS + P256_VERIFY_GAS + } + } +} + +fn native_multisig_signature_verification_gas( + signature: &MultisigSignature, + subtract_base_secp256k1: bool, + depth: usize, +) -> u64 { + let owner_signature_gas = signature + .signatures() + .iter() + .map(|sig| native_multisig_owner_approval_verification_gas(sig, depth)) + .fold(0u64, u64::saturating_add); + let owner_weight_gas = + NATIVE_MULTISIG_OWNER_WEIGHT_GAS.saturating_mul(signature.signatures().len() as u64); + let complete_config_validation_gas = signature + .simulation_config_owner_count() + .map(native_multisig_complete_config_validation_gas) + .unwrap_or_default(); + + let gas = NATIVE_MULTISIG_VALIDATION_GAS + .saturating_add(owner_weight_gas) + .saturating_add(complete_config_validation_gas) + .saturating_add(owner_signature_gas); + if subtract_base_secp256k1 { + gas.saturating_sub(ECRECOVER_GAS) + } else { + gas + } +} + /// Calculates the gas cost for verifying an AA signature. /// /// For keychain signatures, adds key validation overhead to the inner signature cost. Returns the @@ -41,7 +135,62 @@ pub(crate) fn tempo_signature_verification_gas(signature: &TempoSignature) -> u6 TempoSignature::Keychain(keychain_sig) => { primitive_signature_verification_gas(&keychain_sig.signature) + KEYCHAIN_VALIDATION_GAS } - // Native multisig transactions are rejected before intrinsic gas is calculated. - TempoSignature::Multisig(_) => 0, + TempoSignature::Multisig(multisig_sig) => { + native_multisig_signature_verification_gas(multisig_sig, true, 1) + } + } +} + +#[inline] +pub(crate) fn native_multisig_bootstrap_storage_slots(init: &InitMultisig) -> u64 { + let owner_slots = u64::try_from(init.owners.len()).unwrap_or(u64::MAX); + NATIVE_MULTISIG_BOOTSTRAP_FIXED_STORAGE_SLOTS.saturating_add(owner_slots.saturating_mul(2)) +} + +/// Calculates persistent storage gas for native multisig bootstrap. +/// +/// The committed bootstrap write is a protocol pre-execution write. It runs without TIP-1060 +/// storage-credit accounting because this intrinsic charge includes the creditable portion. +/// The packed native multisig layout creates exactly: +/// - one packed account header slot containing threshold, owner count, and version +/// - one packed owner slot per owner +/// - one direct owner-weight lookup slot per owner +#[inline] +pub(crate) fn calculate_native_multisig_bootstrap_storage_gas( + init: &InitMultisig, + gas_params: &GasParams, + spec: TempoHardfork, +) -> (u64, u64) { + let num_sstores = native_multisig_bootstrap_storage_slots(init); + let num_cold_sstores = num_sstores.saturating_sub(1); + + let mut sstore_cost = gas_params.get(GasId::sstore_set_without_load_cost()); + if spec.is_t7() { + // T7 exposes only the SSTORE residual in the gas table. Since bootstrap storage is + // intrinsic-only, also charge the TIP-1060 creditable portion here. + sstore_cost = sstore_cost.saturating_add(STORAGE_CREDIT_VALUE); } + + // Bootstrap validation has already warmed the account header. The owner rows and direct + // weight rows are new cold slots. The committed write also re-reads the warm header and + // records the bootstrapped account in transient storage before emitting the event. + let sstore_gas = sstore_cost + .saturating_add(gas_params.sstore_static_gas()) + .saturating_mul(num_sstores) + .saturating_add( + gas_params + .cold_storage_cost() + .saturating_mul(num_cold_sstores), + ); + let warm_context_gas = gas_params.warm_storage_read_cost().saturating_mul(2); + let event_gas = + LOG.saturating_add(gas_params.log_cost(NATIVE_MULTISIG_BOOTSTRAP_EVENT_TOPICS, 0)); + let regular_gas = sstore_gas + .saturating_add(warm_context_gas) + .saturating_add(event_gas); + let state_gas = gas_params + .get(GasId::sstore_set_state_gas()) + .saturating_mul(num_sstores); + + (regular_gas, state_gas) }