diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 2e8b7d8fc6..8327810741 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -18,9 +18,12 @@ use reth_storage_api::StateProviderFactory; use reth_transaction_pool::{AllPoolTransactions, TransactionPool}; use std::time::Instant; use tempo_chainspec::hardfork::TempoHardforks; -use tempo_contracts::precompiles::{IAccountKeychain, IFeeManager, ITIP20, ITIP403Registry}; +use tempo_contracts::precompiles::{ + IAccountKeychain, IFeeManager, INativeMultisig, ITIP20, ITIP403Registry, +}; use tempo_precompiles::{ - ACCOUNT_KEYCHAIN_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS, + ACCOUNT_KEYCHAIN_ADDRESS, NATIVE_MULTISIG_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + TIP403_REGISTRY_ADDRESS, }; use tempo_primitives::{TempoAddressExt, TempoHeader, TempoPrimitives}; use tracing::{debug, error}; @@ -91,6 +94,18 @@ pub struct TempoPoolUpdates { /// Pending AA transactions carrying the same `(account, witness)` key authorization are no /// longer executable once the account explicitly burns that witness. pub key_authorization_witness_burns: AddressMap, + /// Native multisig accounts whose owner set or threshold changed. + /// + /// A native-multisig-signed transaction is validated against the account's current stored + /// config, so once the config is rotated (or the account is initialized) any pooled multisig + /// transaction admitted under the previous owner set may no longer meet quorum and should be + /// re-validated. Indexed by account. + pub multisig_config_changes: AddressSet, + /// Accounts newly registered as native multisigs by the committed chain segment. + /// + /// Transactions using one of these identities as an access key, explicit fee payer, or + /// authorization-list authority must be re-validated against registry restrictions. + pub multisig_initializations: AddressSet, } impl TempoPoolUpdates { @@ -114,6 +129,8 @@ impl TempoPoolUpdates { && self.fee_balance_changes.is_empty() && self.spending_limit_spends.is_empty() && self.key_authorization_witness_burns.is_empty() + && self.multisig_config_changes.is_empty() + && self.multisig_initializations.is_empty() } /// Extracts pool updates from a committed chain segment. @@ -233,11 +250,43 @@ impl TempoPoolUpdates { Some(_) | None => {} } } + // Native multisig owner-set / threshold rotations and initializations. + else if log.address == NATIVE_MULTISIG_ADDRESS { + updates.record_native_multisig_config_change(log, false); + } } updates } + fn record_native_multisig_config_change(&mut self, log: &Log, reverted: bool) { + let account = match NativeMultisigPoolEvent::decode(log) { + Some(NativeMultisigPoolEvent::ConfigUpdated(event)) => event.account, + Some(NativeMultisigPoolEvent::Initialized(event)) => { + if !reverted { + self.multisig_initializations.insert(event.account); + } + event.account + } + None => return, + }; + self.multisig_config_changes.insert(account); + } + + /// Adds multisig configurations changed by a reverted chain segment. + fn extend_reverted_multisig_config_changes(&mut self, chain: &Chain) { + for log in chain + .execution_outcome() + .receipts() + .iter() + .flatten() + .flat_map(|receipt| &receipt.logs) + .filter(|log| log.address == NATIVE_MULTISIG_ADDRESS) + { + self.record_native_multisig_config_change(log, true); + } + } + /// Returns true if there are any invalidation events that require scanning the pool. pub fn has_invalidation_events(&self) -> bool { self.has_keychain_subject_updates() @@ -251,6 +300,20 @@ impl TempoPoolUpdates { || !self.key_authorization_witness_burns.is_empty() } + pub(crate) fn affects_multisig_transaction( + &self, + transaction: &TempoPooledTransaction, + ) -> bool { + (!self.multisig_config_changes.is_empty() + && transaction.depends_on_multisig_config(&self.multisig_config_changes)) + || (!self.multisig_initializations.is_empty() + && transaction.uses_multisig_restricted_account(&self.multisig_initializations)) + } + + pub(crate) fn has_multisig_updates(&self) -> bool { + !self.multisig_config_changes.is_empty() || !self.multisig_initializations.is_empty() + } + /// Returns true if updates may invalidate keychain-signature transactions. pub fn has_keychain_subject_updates(&self) -> bool { !self.revoked_keys.is_empty() @@ -300,6 +363,29 @@ impl AccountKeychainPoolEvent { } } +/// Transaction-pool relevant subset of `INativeMultisig` events. +enum NativeMultisigPoolEvent { + /// [`INativeMultisig::MultisigConfigUpdated`] log. + ConfigUpdated(INativeMultisig::MultisigConfigUpdated), + /// [`INativeMultisig::MultisigInitialized`] log. + Initialized(INativeMultisig::MultisigInitialized), +} + +impl NativeMultisigPoolEvent { + /// Decodes only native-multisig events used by transaction-pool maintenance. + fn decode(log: &Log) -> Option { + match first_topic(log)? { + INativeMultisig::MultisigConfigUpdated::SIGNATURE_HASH => { + decode_event(log).map(Self::ConfigUpdated) + } + INativeMultisig::MultisigInitialized::SIGNATURE_HASH => { + decode_event(log).map(Self::Initialized) + } + _ => None, + } + } +} + /// Transaction-pool relevant subset of `IFeeManager::IFeeManagerEvents`. enum FeeManagerPoolEvent { /// [`IFeeManager::ValidatorTokenSet`] log. @@ -492,17 +578,17 @@ where // Process all maintenance operations on new block commit or reorg. while let Some(event) = chain_events.next().await { - let new = match event { - CanonStateNotification::Reorg { old: _, new } => { + let (new, reverted) = match event { + CanonStateNotification::Reorg { old, new } => { // Repopulate AMM liquidity cache from the new canonical chain // to invalidate stale entries from orphaned blocks. if let Err(err) = amm_cache.repopulate(pool.client()) { error!(target: "txpool", ?err, "AMM liquidity cache repopulate after reorg failed"); } - new + (new, Some(old)) } - CanonStateNotification::Commit { new } => new, + CanonStateNotification::Commit { new } => (new, None), }; let block_update_start = Instant::now(); @@ -539,7 +625,10 @@ where .record(amm_start.elapsed()); // 3. Collect all block-level invalidation events - let updates = TempoPoolUpdates::from_chain(tip); + let mut updates = TempoPoolUpdates::from_chain(tip); + if let Some(reverted) = reverted { + updates.extend_reverted_multisig_config_changes(&reverted); + } let mut all_txs: Option> = None; // Reth's canonical-update handling may not have pruned mined transactions yet. @@ -623,8 +712,11 @@ where user_token_changes = updates.user_token_changes.len(), blacklist_additions = updates.blacklist_additions.len(), whitelist_removals = updates.whitelist_removals.len(), + multisig_config_changes = updates.multisig_config_changes.len(), + multisig_initializations = updates.multisig_initializations.len(), "Processing transaction invalidation events" ); + let mut multisig_revalidated = Vec::new(); let evicted = { let all_txs = all_txs.get_or_insert_with(|| pool.all_transactions()); pool.evict_invalidated_transactions_from( @@ -633,12 +725,35 @@ where .iter() .filter(|tx| !removed_this_iteration.contains(tx.hash())), Some(tip_timestamp.saturating_add(EVICTION_BUFFER_SECS)), + Some(&mut multisig_revalidated), ) }; metrics .transactions_invalidated .increment(evicted.len() as u64); removed_txs.push(evicted); + + if !multisig_revalidated.is_empty() { + let count = multisig_revalidated.len(); + metrics.multisig_revalidated.increment(count as u64); + + let pool_clone = pool.clone(); + tokio::spawn(async move { + let txs = multisig_revalidated + .into_iter() + .map(|tx| (tx.origin, tx.transaction.with_discarded_caches())) + .collect(); + let results = pool_clone.add_transactions_with_origins(txs).await; + let success = results.iter().filter(|result| result.is_ok()).count(); + debug!( + target: "txpool", + total = count, + success, + reason = "native multisig state update", + "Re-validated transactions" + ); + }); + } metrics .invalidation_eviction_duration_seconds .record(invalidation_start.elapsed()); @@ -688,6 +803,77 @@ mod tests { use std::sync::Arc; use tempo_primitives::{Block, BlockBody, TempoHeader, TempoTxEnvelope}; + #[test] + fn multisig_config_changes_select_outer_and_nested_authorizations() { + let parent = Address::random(); + let child = Address::random(); + let direct = TxBuilder::aa(parent).build_multisig(parent); + let nested = TxBuilder::aa(parent).build_multisig_nested(parent, child); + + let mut updates = TempoPoolUpdates::new(); + assert!(!updates.affects_multisig_transaction(&direct)); + + updates.multisig_config_changes.insert(parent); + assert!(updates.affects_multisig_transaction(&direct)); + + updates.multisig_config_changes.clear(); + updates.multisig_config_changes.insert(child); + assert!(updates.affects_multisig_transaction(&nested)); + + updates.multisig_config_changes.clear(); + updates.multisig_config_changes.insert(Address::random()); + assert!(!updates.affects_multisig_transaction(&nested)); + + let key_id = Address::random(); + let key_authorization = tempo_primitives::transaction::KeyAuthorization::unrestricted( + 42431, + tempo_primitives::transaction::SignatureType::Secp256k1, + key_id, + ) + .with_account(parent) + .into_signed(tempo_primitives::transaction::TempoSignature::Primitive( + tempo_primitives::transaction::PrimitiveSignature::Secp256k1( + alloy_primitives::Signature::test_signature(), + ), + )); + let key_transaction = TxBuilder::aa(parent) + .key_authorization(key_authorization) + .build(); + updates.multisig_initializations.insert(key_id); + assert!(updates.affects_multisig_transaction(&key_transaction)); + + updates.multisig_initializations.clear(); + updates.multisig_config_changes.clear(); + updates.multisig_config_changes.insert(key_id); + assert!(!updates.affects_multisig_transaction(&key_transaction)); + + use alloy_eips::eip7702::Authorization; + use alloy_primitives::U256; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + let authority = PrivateKeySigner::random(); + let authorization = Authorization { + chain_id: U256::from(42431), + address: Address::random(), + nonce: 0, + }; + let signature = authority + .sign_hash_sync(&authorization.signature_hash()) + .unwrap(); + let authorization = tempo_primitives::transaction::TempoSignedAuthorization::new_unchecked( + authorization, + tempo_primitives::transaction::TempoSignature::Primitive( + tempo_primitives::transaction::PrimitiveSignature::Secp256k1(signature), + ), + ); + let authorization_transaction = TxBuilder::aa(parent) + .authorization_list(vec![authorization]) + .build(); + updates.multisig_config_changes.clear(); + updates.multisig_initializations.insert(authority.address()); + assert!(updates.affects_multisig_transaction(&authorization_transaction)); + } + mod pending_staleness_tracker_tests { use super::*; @@ -894,6 +1080,40 @@ mod tests { ); } + #[test] + fn native_multisig_decode_matches_generated_event_decoders() { + let log = event_log( + NATIVE_MULTISIG_ADDRESS, + INativeMultisig::MultisigConfigUpdated { + account: Address::random(), + threshold: 2, + owners: vec![INativeMultisig::MultisigOwner { + owner: Address::random(), + weight: 1, + }], + }, + ); + assert_decodes_like_generated!( + NativeMultisigPoolEvent, + ConfigUpdated, + INativeMultisig::MultisigConfigUpdated, + log + ); + + let log = event_log( + NATIVE_MULTISIG_ADDRESS, + INativeMultisig::MultisigInitialized { + account: Address::random(), + }, + ); + assert_decodes_like_generated!( + NativeMultisigPoolEvent, + Initialized, + INativeMultisig::MultisigInitialized, + log + ); + } + #[test] fn fee_manager_decode_matches_generated_event_decoders() { let log = event_log( @@ -1066,6 +1286,80 @@ mod tests { RecoveredBlock::new_unhashed(block, senders) } + #[test] + fn reorg_updates_include_reverted_multisig_config_changes() { + let account = Address::random(); + let log = Log::new_from_event_unchecked( + NATIVE_MULTISIG_ADDRESS, + INativeMultisig::MultisigConfigUpdated { + account, + threshold: 1, + owners: vec![INativeMultisig::MultisigOwner { + owner: Address::random(), + weight: 1, + }], + }, + ) + .reserialize(); + let initialized_log = Log::new_from_event_unchecked( + NATIVE_MULTISIG_ADDRESS, + INativeMultisig::MultisigInitialized { account }, + ) + .reserialize(); + let receipt = tempo_primitives::TempoReceipt { + tx_type: tempo_primitives::TempoTxType::AA, + success: true, + cumulative_gas_used: 1, + logs: vec![log, initialized_log], + }; + let reverted = create_test_chain_with_receipts( + vec![create_block_with_txs(1, vec![], vec![])], + vec![vec![receipt]], + ); + let replacement = create_test_chain(vec![create_block_with_txs(1, vec![], vec![])]); + + let mut updates = TempoPoolUpdates::from_chain(&replacement); + assert!(updates.multisig_config_changes.is_empty()); + + updates.extend_reverted_multisig_config_changes(&reverted); + + assert_eq!( + updates.multisig_config_changes, + AddressSet::from_iter([account]) + ); + assert!(updates.multisig_initializations.is_empty()); + } + + #[test] + fn committed_multisig_initialization_tracks_registry_restrictions() { + let account = Address::random(); + let log = Log::new_from_event_unchecked( + NATIVE_MULTISIG_ADDRESS, + INativeMultisig::MultisigInitialized { account }, + ) + .reserialize(); + let receipt = tempo_primitives::TempoReceipt { + tx_type: tempo_primitives::TempoTxType::AA, + success: true, + cumulative_gas_used: 1, + logs: vec![log], + }; + let chain = create_test_chain_with_receipts( + vec![create_block_with_txs(1, vec![], vec![])], + vec![vec![receipt]], + ); + + let updates = TempoPoolUpdates::from_chain(&chain); + assert_eq!( + updates.multisig_config_changes, + AddressSet::from_iter([account]) + ); + assert_eq!( + updates.multisig_initializations, + AddressSet::from_iter([account]) + ); + } + /// Helper to extract a TempoTxEnvelope from a TempoPooledTransaction. fn extract_envelope(tx: &crate::transaction::TempoPooledTransaction) -> TempoTxEnvelope { tx.inner().clone().into_inner() diff --git a/crates/transaction-pool/src/metrics.rs b/crates/transaction-pool/src/metrics.rs index 463c36e254..4ded578aad 100644 --- a/crates/transaction-pool/src/metrics.rs +++ b/crates/transaction-pool/src/metrics.rs @@ -101,4 +101,7 @@ pub struct TempoPoolMaintenanceMetrics { /// Number of transactions re-validated due to quote token updates. pub quote_token_revalidated: Counter, + + /// Number of transactions re-validated due to native multisig state changes. + pub multisig_revalidated: Counter, } diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index a3daa5945b..b7a10f4ddf 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -136,23 +136,27 @@ where &self, updates: &crate::maintain::TempoPoolUpdates, ) -> Vec>> { - if !updates.has_invalidation_events() { + if !updates.has_invalidation_events() && !updates.has_multisig_updates() { return Vec::new(); } let all_txs = self.all_transactions(); - self.evict_invalidated_transactions_from(updates, all_txs.iter(), None) + self.evict_invalidated_transactions_from(updates, all_txs.iter(), None, None) } /// See [`Self::evict_invalidated_transactions`]; returns the removed transactions so - /// the caller controls when they are dropped. + /// the caller controls when they are dropped. When `multisig_revalidated` is provided, + /// affected multisig transactions are removed into it during the same scan. pub(crate) fn evict_invalidated_transactions_from<'a>( &self, updates: &crate::maintain::TempoPoolUpdates, transactions: impl IntoIterator>>, expiry_cutoff: Option, + multisig_revalidated: Option<&mut Vec>>>, ) -> Vec>> { - if !updates.has_invalidation_events() && expiry_cutoff.is_none() { + let has_multisig_updates = updates.has_multisig_updates(); + let revalidate_multisig = multisig_revalidated.is_some() && has_multisig_updates; + if !updates.has_invalidation_events() && expiry_cutoff.is_none() && !has_multisig_updates { return Vec::new(); } @@ -227,6 +231,7 @@ where }; let mut to_remove = Vec::new(); + let mut to_revalidate = Vec::new(); let mut revoked_count = 0; let mut key_authorization_target_count = 0; let mut spending_limit_count = 0; @@ -244,6 +249,15 @@ where let mut fee_balance_cache: HashMap<(Address, Address), U256> = HashMap::default(); for tx in transactions { + if has_multisig_updates && updates.affects_multisig_transaction(&tx.transaction) { + if revalidate_multisig { + to_revalidate.push(*tx.hash()); + } else { + to_remove.push(*tx.hash()); + } + continue; + } + if expiry_cutoff.is_some_and(|cutoff| tx.transaction.is_expired_by(cutoff)) { to_remove.push(*tx.hash()); continue; @@ -506,6 +520,12 @@ where } } + if let Some(multisig_revalidated) = multisig_revalidated + && !to_revalidate.is_empty() + { + multisig_revalidated.extend(self.remove_transactions(to_revalidate)); + } + if to_remove.is_empty() { return Vec::new(); } @@ -1650,6 +1670,50 @@ mod tests { assert_eq!(size.total, 1); } + #[test] + fn classifies_multisig_revalidation_during_invalidation_scan() { + let multisig_account = Address::random(); + let multisig = + crate::test_utils::TxBuilder::aa(multisig_account).build_multisig(multisig_account); + let expired = crate::test_utils::TxBuilder::aa(Address::random()) + .valid_before(10) + .build(); + let pool = create_test_pool(create_provider_with_tip()); + add_validated(&pool, multisig.clone()); + add_validated(&pool, expired.clone()); + + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates.multisig_config_changes.insert(multisig_account); + let all_txs = pool.all_transactions(); + let mut multisig_revalidated = Vec::new(); + let evicted = pool.evict_invalidated_transactions_from( + &updates, + all_txs.iter(), + Some(10), + Some(&mut multisig_revalidated), + ); + + assert_eq!(tx_hashes(&multisig_revalidated), vec![*multisig.hash()]); + assert_eq!(tx_hashes(&evicted), vec![*expired.hash()]); + assert!(pool.get(multisig.hash()).is_none()); + assert!(pool.get(expired.hash()).is_none()); + } + + #[test] + fn public_invalidation_evicts_affected_multisig_transactions() { + let account = Address::random(); + let multisig = crate::test_utils::TxBuilder::aa(account).build_multisig(account); + let pool = create_test_pool(create_provider_with_tip()); + add_validated(&pool, multisig.clone()); + + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates.multisig_config_changes.insert(account); + + let evicted = pool.evict_invalidated_transactions(&updates); + assert_eq!(tx_hashes(&evicted), vec![*multisig.hash()]); + assert!(pool.get(multisig.hash()).is_none()); + } + fn sponsored_keychain_transaction( sender: Address, fee_token: Address, diff --git a/crates/transaction-pool/src/test_utils.rs b/crates/transaction-pool/src/test_utils.rs index 459164f979..4be90ac447 100644 --- a/crates/transaction-pool/src/test_utils.rs +++ b/crates/transaction-pool/src/test_utils.rs @@ -189,8 +189,7 @@ impl TxBuilder { self } - /// Build an AA transaction. - pub(crate) fn build(self) -> TempoPooledTransaction { + fn into_aa_transaction(self) -> TempoTransaction { let calls = self.calls.unwrap_or_else(|| { vec![Call { to: self.kind, @@ -199,7 +198,7 @@ impl TxBuilder { }] }); - let tx = TempoTransaction { + TempoTransaction { chain_id: self.chain_id, max_priority_fee_per_gas: self.max_priority_fee_per_gas, max_fee_per_gas: self.max_fee_per_gas, @@ -214,15 +213,59 @@ impl TxBuilder { access_list: self.access_list, tempo_authorization_list: self.authorization_list.unwrap_or_default(), key_authorization: self.key_authorization, - }; + } + } + fn build_aa_with_signature( + tx: TempoTransaction, + signature: TempoSignature, + sender: Address, + ) -> TempoPooledTransaction { + let envelope: TempoTxEnvelope = AASigned::new_unhashed(tx, signature).into(); + TempoPooledTransaction::new(Recovered::new_unchecked(envelope, sender)) + } + + /// Build an AA transaction. + pub(crate) fn build(self) -> TempoPooledTransaction { + let sender = self.sender; + let tx = self.into_aa_transaction(); let signature = TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature())); - let aa_signed = AASigned::new_unhashed(tx, signature); - let envelope: TempoTxEnvelope = aa_signed.into(); + Self::build_aa_with_signature(tx, signature, sender) + } - let recovered = Recovered::new_unchecked(envelope, self.sender); - TempoPooledTransaction::new(recovered) + /// Build an AA transaction with a native multisig outer signature for `account`. + /// + /// The sender equals `account`, matching how native multisig transactions execute. + pub(crate) fn build_multisig(self, account: Address) -> TempoPooledTransaction { + use tempo_primitives::transaction::MultisigSignature; + + let tx = self.into_aa_transaction(); + let owner_signature = PrimitiveSignature::Secp256k1(Signature::test_signature()).to_bytes(); + let signature = + TempoSignature::Multisig(MultisigSignature::new(account, vec![owner_signature], None)); + Self::build_aa_with_signature(tx, signature, account) + } + + /// Build an AA transaction whose outer multisig `parent` has a single nested multisig owner + /// approval from `child` (a 1-level-deep nested native multisig authorization). + pub(crate) fn build_multisig_nested( + self, + parent: Address, + child: Address, + ) -> TempoPooledTransaction { + use tempo_primitives::transaction::MultisigSignature; + + let tx = self.into_aa_transaction(); + let child_owner = PrimitiveSignature::Secp256k1(Signature::test_signature()).to_bytes(); + let child_sig = + TempoSignature::Multisig(MultisigSignature::new(child, vec![child_owner], None)); + let parent_sig = TempoSignature::Multisig(MultisigSignature::new( + parent, + vec![child_sig.to_bytes()], + None, + )); + Self::build_aa_with_signature(tx, parent_sig, parent) } /// Build an AA transaction with a V2 keychain signature. @@ -247,30 +290,7 @@ impl TxBuilder { use alloy_signer::SignerSync; use tempo_primitives::transaction::tt_signature::KeychainSignature; - let calls = self.calls.unwrap_or_else(|| { - vec![Call { - to: self.kind, - value: self.value, - input: Default::default(), - }] - }); - - let tx = TempoTransaction { - chain_id: self.chain_id, - max_priority_fee_per_gas: self.max_priority_fee_per_gas, - max_fee_per_gas: self.max_fee_per_gas, - gas_limit: self.gas_limit, - calls, - nonce_key: self.nonce_key, - nonce: self.nonce, - fee_token: self.fee_token, - fee_payer_signature: None, - valid_after: self.valid_after, - valid_before: self.valid_before, - access_list: self.access_list, - tempo_authorization_list: self.authorization_list.unwrap_or_default(), - key_authorization: self.key_authorization, - }; + let tx = self.into_aa_transaction(); // Create a temp AASigned to get the signature hash let temp_sig = @@ -278,19 +298,16 @@ impl TxBuilder { let unsigned = AASigned::new_unhashed(tx.clone(), temp_sig); let sig_hash = unsigned.signature_hash(); - let (effective_hash, keychain_sig) = match version { + let keychain_sig = match version { KeychainVersion::V1 => { // V1: sign raw sig_hash directly let signature = access_key_signer .sign_hash_sync(&sig_hash) .expect("signing failed"); - ( - sig_hash, - TempoSignature::Keychain(KeychainSignature::new_v1( - user_address, - PrimitiveSignature::Secp256k1(signature), - )), - ) + TempoSignature::Keychain(KeychainSignature::new_v1( + user_address, + PrimitiveSignature::Secp256k1(signature), + )) } KeychainVersion::V2 => { // V2: sign keccak256(0x04 || sig_hash || user_address) @@ -298,16 +315,12 @@ impl TxBuilder { let signature = access_key_signer .sign_hash_sync(&hash) .expect("signing failed"); - ( - hash, - TempoSignature::Keychain(KeychainSignature::new( - user_address, - PrimitiveSignature::Secp256k1(signature), - )), - ) + TempoSignature::Keychain(KeychainSignature::new( + user_address, + PrimitiveSignature::Secp256k1(signature), + )) } }; - let _ = effective_hash; let signed_tx = AASigned::new_unhashed(tx, keychain_sig); let envelope: TempoTxEnvelope = signed_tx.into(); diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 00d47fdc6a..a9909493a2 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -11,7 +11,8 @@ use alloy_eips::{ }; use alloy_evm::FromRecoveredTx; use alloy_primitives::{ - Address, B256, Bytes, TxHash, TxKind, U256, bytes, keccak256, map::AddressMap, + Address, B256, Bytes, TxHash, TxKind, U256, bytes, keccak256, + map::{AddressMap, AddressSet}, }; use alloy_sol_types::SolInterface; use reth_evm::execute::WithTxEnv; @@ -20,6 +21,7 @@ use reth_transaction_pool::{ EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, PoolTransaction, error::{PoolTransactionError, RawPoolTransactionError}, }; +use revm::context::transaction::AuthorizationTr; use std::{ convert::Infallible, fmt::Debug, @@ -35,7 +37,9 @@ use tempo_precompiles::{ }; use tempo_primitives::{ TempoTxEnvelope, - transaction::{InvalidValidAfter, InvalidValidBefore, calc_gas_balance_spending}, + transaction::{ + InvalidValidAfter, InvalidValidBefore, MultisigSignature, calc_gas_balance_spending, + }, }; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; use thiserror::Error; @@ -213,6 +217,91 @@ impl TempoPooledTransaction { }) } + /// Returns whether this transaction's authorization depends on one of the changed multisig + /// configs, including nested owner accounts. + pub(crate) fn depends_on_multisig_config(&self, changed_accounts: &AddressSet) -> bool { + fn contains_changed_account( + signature: &MultisigSignature, + changed_accounts: &AddressSet, + ) -> bool { + changed_accounts.contains(&signature.account()) + || signature.signatures().iter().any(|approval| { + approval + .as_multisig() + .is_some_and(|nested| contains_changed_account(nested, changed_accounts)) + }) + } + + self.inner().as_aa().is_some_and(|aa_tx| { + aa_tx + .signature() + .as_multisig() + .is_some_and(|signature| contains_changed_account(signature, changed_accounts)) + || aa_tx + .tx() + .key_authorization + .as_ref() + .and_then(|authorization| authorization.signature.as_multisig()) + .is_some_and(|signature| contains_changed_account(signature, changed_accounts)) + }) + } + + /// Returns whether this transaction uses one of the accounts newly subject to native + /// multisig registry restrictions. + pub(crate) fn uses_multisig_restricted_account(&self, accounts: &AddressSet) -> bool { + let authorization_uses_restricted_account = || { + if let Some(tx_env) = self.cached_tx_env() { + if let Some(aa_env) = tx_env.tempo_tx_env.as_ref() { + aa_env.tempo_authorization_list.iter().any(|authorization| { + authorization + .authority() + .is_some_and(|authority| accounts.contains(&authority)) + }) + } else { + tx_env.inner.authorization_list.iter().any(|authorization| { + authorization + .authority() + .is_some_and(|authority| accounts.contains(&authority)) + }) + } + } else if let Some(aa_tx) = self.inner().as_aa() { + aa_tx + .tx() + .tempo_authorization_list + .iter() + .any(|authorization| { + authorization + .recover_authority() + .is_ok_and(|authority| accounts.contains(&authority)) + }) + } else { + self.inner() + .authorization_list() + .is_some_and(|authorizations| { + authorizations.iter().any(|authorization| { + authorization + .recover_authority() + .is_ok_and(|authority| accounts.contains(&authority)) + }) + }) + } + }; + + if let Some(aa_tx) = self.inner().as_aa() { + let tx = aa_tx.tx(); + tx.key_authorization + .as_ref() + .is_some_and(|authorization| accounts.contains(&authorization.authorization.key_id)) + || (tx.fee_payer_signature.is_some() + && self + .fee_payer() + .is_ok_and(|fee_payer| accounts.contains(&fee_payer))) + || authorization_uses_restricted_account() + } else { + authorization_uses_restricted_account() + } + } + /// Extracts the keychain subject for the signer of an inline `KeyAuthorization`. /// /// Used for revocation matching: if the access key that signed an inline authorization is @@ -952,7 +1041,8 @@ mod tests { use tempo_contracts::precompiles::ITIP20; use tempo_precompiles::{PATH_USD_ADDRESS, nonce::NonceManager}; use tempo_primitives::transaction::{ - TEMPO_EXPIRING_NONCE_KEY, TempoTransaction, + KeyAuthorization, MultisigSignature, SignatureType, TEMPO_EXPIRING_NONCE_KEY, + TempoTransaction, tempo_transaction::Call, tt_signature::{PrimitiveSignature, TempoSignature}, tt_signed::AASigned, @@ -1109,6 +1199,74 @@ mod tests { ); } + #[test] + fn test_multisig_accounts_includes_key_authorization_tree() { + let account = Address::random(); + let nested_account = Address::random(); + let access_key = PrivateKeySigner::random(); + let key_authorization = |nested: Option
| { + let owner_approval = PrimitiveSignature::Secp256k1(Signature::test_signature()); + let owner_approval = nested.map_or_else( + || owner_approval.to_bytes(), + |nested_account| { + TempoSignature::Multisig(MultisigSignature::new( + nested_account, + vec![owner_approval.to_bytes()], + None, + )) + .to_bytes() + }, + ); + KeyAuthorization::unrestricted(42431, SignatureType::Secp256k1, access_key.address()) + .with_account(account) + .into_signed(TempoSignature::Multisig(MultisigSignature::new( + account, + vec![owner_approval], + None, + ))) + }; + + let direct = TxBuilder::aa(account) + .key_authorization(key_authorization(None)) + .build_keychain(account, &access_key); + assert!(direct.depends_on_multisig_config(&AddressSet::from_iter([account]))); + assert!(!direct.depends_on_multisig_config(&AddressSet::from_iter([nested_account]))); + + let nested = TxBuilder::aa(account) + .key_authorization(key_authorization(Some(nested_account))) + .build_keychain(account, &access_key); + assert!(nested.depends_on_multisig_config(&AddressSet::from_iter([account]))); + assert!(nested.depends_on_multisig_config(&AddressSet::from_iter([nested_account]))); + } + + #[test] + fn multisig_restriction_checks_reuse_cached_authorities() { + use alloy_eips::eip7702::Authorization; + use tempo_primitives::transaction::TempoSignedAuthorization; + + let authority = PrivateKeySigner::random(); + let authorization = Authorization { + chain_id: U256::ONE, + nonce: 0, + address: Address::random(), + }; + let signature = authority + .sign_hash_sync(&authorization.signature_hash()) + .unwrap(); + let authorization = TempoSignedAuthorization::new_unchecked( + authorization, + TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)), + ); + let transaction = TxBuilder::aa(Address::random()) + .authorization_list(vec![authorization]) + .build(); + let restricted = AddressSet::from_iter([authority.address()]); + + assert!(transaction.uses_multisig_restricted_account(&restricted)); + let _ = transaction.tx_env(); + assert!(transaction.uses_multisig_restricted_account(&restricted)); + } + #[test] fn test_aa_transaction_with_zero_nonce_key() { let sender = Address::random();