From 66dadf4e8f89f0736d84dcbf149ff5378a95a653 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:18:16 +0200 Subject: [PATCH 01/10] feat(pool): invalidate native multisig transactions --- crates/transaction-pool/src/maintain.rs | 152 ++++++++++++++++++++- crates/transaction-pool/src/tempo_pool.rs | 81 +++++++++++ crates/transaction-pool/src/test_utils.rs | 93 +++++++++++++ crates/transaction-pool/src/transaction.rs | 82 ++++++++++- 4 files changed, 399 insertions(+), 9 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 2e8b7d8fc6..1a1070ab6c 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,13 @@ 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, } impl TempoPoolUpdates { @@ -114,6 +124,7 @@ 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() } /// Extracts pool updates from a committed chain segment. @@ -233,11 +244,39 @@ 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); + } } updates } + fn record_native_multisig_config_change(&mut self, log: &Log) { + let account = match NativeMultisigPoolEvent::decode(log) { + Some(NativeMultisigPoolEvent::ConfigUpdated(event)) => event.account, + // Initialization also makes any pending duplicate bootstrap inadmissible. + Some(NativeMultisigPoolEvent::Initialized(event)) => 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); + } + } + /// 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() @@ -249,6 +288,7 @@ impl TempoPoolUpdates { || !self.paused_tokens.is_empty() || !self.fee_balance_changes.is_empty() || !self.key_authorization_witness_burns.is_empty() + || !self.multisig_config_changes.is_empty() } /// Returns true if updates may invalidate keychain-signature transactions. @@ -300,6 +340,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 +555,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 +602,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. @@ -894,6 +960,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 +1166,44 @@ 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 receipt = tempo_primitives::TempoReceipt { + tx_type: tempo_primitives::TempoTxType::AA, + success: true, + cumulative_gas_used: 1, + logs: vec![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]) + ); + } + /// 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/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index a3daa5945b..991d9a0b4c 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -237,6 +237,7 @@ where let mut blacklisted_count = 0; let mut unwhitelisted_count = 0; let mut insolvent_fee_payer_count = 0; + let mut multisig_config_count = 0; let mut paused_token_count = 0; let has_keychain_subject_updates = updates.has_keychain_subject_updates(); let has_key_authorization_target_updates = @@ -503,6 +504,23 @@ where { to_remove.push(*tx.hash()); user_token_count += 1; + continue; + } + + // Check 7: Native multisig owner-set / threshold rotations + // A native-multisig-signed transaction is verified against the current stored config of + // its outer account and of every nested multisig owner, so once any of those configs is + // rotated (or initialized) on-chain the pooled transaction may no longer meet quorum and + // must be re-validated. + if !updates.multisig_config_changes.is_empty() + && tx + .transaction + .multisig_accounts() + .iter() + .any(|account| updates.multisig_config_changes.contains(account)) + { + to_remove.push(*tx.hash()); + multisig_config_count += 1; } } @@ -523,6 +541,7 @@ where blacklisted_count, unwhitelisted_count, insolvent_fee_payer_count, + multisig_config_count, paused_token_count, "Evicting invalidated or expired transactions" ); @@ -1774,6 +1793,68 @@ mod tests { assert!(pool.get(pooled.hash()).is_none()); } + #[tokio::test] + async fn evicts_multisig_transaction_when_its_config_rotates() { + let account = Address::random(); + let pooled = crate::test_utils::TxBuilder::aa(account).build_multisig(account); + + let provider = create_provider_with_tip(); + provider.add_account( + account, + ExtendedAccount::new(pooled.nonce(), *pooled.cost()), + ); + let pool = create_test_pool(provider); + add_validated(&pool, pooled.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![*pooled.hash()]); + assert!(pool.get(pooled.hash()).is_none()); + } + + #[tokio::test] + async fn evicts_multisig_transaction_when_nested_owner_config_rotates() { + let parent = Address::random(); + let child = Address::random(); + let pooled = crate::test_utils::TxBuilder::aa(parent).build_multisig_nested(parent, child); + + let provider = create_provider_with_tip(); + provider.add_account(parent, ExtendedAccount::new(pooled.nonce(), *pooled.cost())); + let pool = create_test_pool(provider); + add_validated(&pool, pooled.clone()); + + // Rotating the NESTED owner's config must evict the parent transaction, since the parent's + // authorization depends on the nested account's current threshold/owner weights. + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates.multisig_config_changes.insert(child); + + let evicted = pool.evict_invalidated_transactions(&updates); + assert_eq!(tx_hashes(&evicted), vec![*pooled.hash()]); + assert!(pool.get(pooled.hash()).is_none()); + } + + #[tokio::test] + async fn keeps_multisig_transaction_when_other_account_config_rotates() { + let account = Address::random(); + let pooled = crate::test_utils::TxBuilder::aa(account).build_multisig(account); + + let provider = create_provider_with_tip(); + provider.add_account( + account, + ExtendedAccount::new(pooled.nonce(), *pooled.cost()), + ); + let pool = create_test_pool(provider); + add_validated(&pool, pooled.clone()); + + let mut updates = crate::maintain::TempoPoolUpdates::new(); + updates.multisig_config_changes.insert(Address::random()); + + assert!(pool.evict_invalidated_transactions(&updates).is_empty()); + assert!(pool.get(pooled.hash()).is_some()); + } + #[tokio::test] async fn keeps_sponsored_keychain_transaction_on_spending_limit_invalidations() { let sender = Address::random(); diff --git a/crates/transaction-pool/src/test_utils.rs b/crates/transaction-pool/src/test_utils.rs index 459164f979..d97b616e7b 100644 --- a/crates/transaction-pool/src/test_utils.rs +++ b/crates/transaction-pool/src/test_utils.rs @@ -225,6 +225,99 @@ impl TxBuilder { 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 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 owner_signature = PrimitiveSignature::Secp256k1(Signature::test_signature()).to_bytes(); + let signature = + TempoSignature::Multisig(MultisigSignature::new(account, vec![owner_signature], None)); + let aa_signed = AASigned::new_unhashed(tx, signature); + let envelope: TempoTxEnvelope = aa_signed.into(); + + let recovered = Recovered::new_unchecked(envelope, account); + TempoPooledTransaction::new(recovered) + } + + /// 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 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, + }; + + // child multisig: one primitive owner approval. + let child_owner = PrimitiveSignature::Secp256k1(Signature::test_signature()).to_bytes(); + let child_sig = + TempoSignature::Multisig(MultisigSignature::new(child, vec![child_owner], None)); + // parent multisig: its single owner approval is the nested child multisig. + let parent_sig = TempoSignature::Multisig(MultisigSignature::new( + parent, + vec![child_sig.to_bytes()], + None, + )); + + let aa_signed = AASigned::new_unhashed(tx, parent_sig); + let envelope: TempoTxEnvelope = aa_signed.into(); + + let recovered = Recovered::new_unchecked(envelope, parent); + TempoPooledTransaction::new(recovered) + } + /// Build an AA transaction with a V2 keychain signature. /// /// The `user_address` is the account that owns the keychain key, diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 00d47fdc6a..8971bbb350 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -35,7 +35,10 @@ use tempo_precompiles::{ }; use tempo_primitives::{ TempoTxEnvelope, - transaction::{InvalidValidAfter, InvalidValidBefore, calc_gas_balance_spending}, + transaction::{ + InvalidValidAfter, InvalidValidBefore, MultisigSignature, TempoSignature, + calc_gas_balance_spending, + }, }; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; use thiserror::Error; @@ -213,6 +216,42 @@ impl TempoPooledTransaction { }) } + /// Returns every native multisig account whose stored config this transaction's authorization + /// depends on across its outer and key-authorization signatures, including every nested + /// multisig owner account in either approval tree. + /// + /// The stateful verifier recurses into nested multisig owners and reloads their + /// threshold/owner weights, so a config rotation of the outer account *or any nested owner* + /// can invalidate an already-pooled transaction. All of them must be matched against on-chain + /// config changes when deciding eviction. Recursion is bounded by `MAX_MULTISIG_NESTING_DEPTH`, + /// which is enforced at decode time. + pub fn multisig_accounts(&self) -> Vec
{ + fn collect(sig: &MultisigSignature, out: &mut Vec
) { + out.push(sig.account()); + for approval in sig.signatures() { + if let TempoSignature::Multisig(nested) = approval { + collect(nested, out); + } + } + } + + let mut accounts = Vec::new(); + if let Some(aa_tx) = self.inner().as_aa() { + if let Some(multisig) = aa_tx.signature().as_multisig() { + collect(multisig, &mut accounts); + } + if let Some(multisig) = aa_tx + .tx() + .key_authorization + .as_ref() + .and_then(|authorization| authorization.signature.as_multisig()) + { + collect(multisig, &mut accounts); + } + } + accounts + } + /// 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 +991,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 +1149,44 @@ 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_eq!(direct.multisig_accounts(), vec![account]); + + let nested = TxBuilder::aa(account) + .key_authorization(key_authorization(Some(nested_account))) + .build_keychain(account, &access_key); + assert_eq!(nested.multisig_accounts(), vec![account, nested_account]); + } + #[test] fn test_aa_transaction_with_zero_nonce_key() { let sender = Address::random(); From 13d7a13494a6a9ebd6db78ffaa578b4b589700fb Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:37:59 +0200 Subject: [PATCH 02/10] fix(pool): revalidate multisig transactions after config changes --- crates/transaction-pool/src/maintain.rs | 71 +++++++++++++++++++- crates/transaction-pool/src/metrics.rs | 3 + crates/transaction-pool/src/tempo_pool.rs | 80 ----------------------- 3 files changed, 73 insertions(+), 81 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 1a1070ab6c..a6f22386fd 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -288,7 +288,14 @@ impl TempoPoolUpdates { || !self.paused_tokens.is_empty() || !self.fee_balance_changes.is_empty() || !self.key_authorization_witness_burns.is_empty() - || !self.multisig_config_changes.is_empty() + } + + fn affects_multisig_transaction(&self, transaction: &TempoPooledTransaction) -> bool { + !self.multisig_config_changes.is_empty() + && transaction + .multisig_accounts() + .iter() + .any(|account| self.multisig_config_changes.contains(account)) } /// Returns true if updates may invalidate keychain-signature transactions. @@ -676,6 +683,45 @@ where } } + if !updates.multisig_config_changes.is_empty() { + let hashes: Vec = { + let all_txs = all_txs.get_or_insert_with(|| pool.all_transactions()); + all_txs + .iter() + .filter(|tx| !removed_this_iteration.contains(tx.hash())) + .filter(|tx| updates.affects_multisig_transaction(&tx.transaction)) + .map(|tx| *tx.hash()) + .collect() + }; + if !hashes.is_empty() { + let removed_txs = pool.remove_transactions(hashes); + let count = removed_txs.len(); + + for tx in &removed_txs { + removed_this_iteration.insert(*tx.hash()); + } + + metrics.multisig_config_revalidated.increment(count as u64); + + let pool_clone = pool.clone(); + tokio::spawn(async move { + let txs = removed_txs + .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 config update", + "Re-validated transactions" + ); + }); + } + } + // 5. Evict expired and invalidated transactions in one pool traversal. let invalidation_start = Instant::now(); debug!( @@ -689,6 +735,7 @@ 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(), "Processing transaction invalidation events" ); let evicted = { @@ -754,6 +801,28 @@ 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)); + } + mod pending_staleness_tracker_tests { use super::*; diff --git a/crates/transaction-pool/src/metrics.rs b/crates/transaction-pool/src/metrics.rs index 463c36e254..480467793c 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 configuration changes. + pub multisig_config_revalidated: Counter, } diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 991d9a0b4c..60c1f113a5 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -237,7 +237,6 @@ where let mut blacklisted_count = 0; let mut unwhitelisted_count = 0; let mut insolvent_fee_payer_count = 0; - let mut multisig_config_count = 0; let mut paused_token_count = 0; let has_keychain_subject_updates = updates.has_keychain_subject_updates(); let has_key_authorization_target_updates = @@ -506,22 +505,6 @@ where user_token_count += 1; continue; } - - // Check 7: Native multisig owner-set / threshold rotations - // A native-multisig-signed transaction is verified against the current stored config of - // its outer account and of every nested multisig owner, so once any of those configs is - // rotated (or initialized) on-chain the pooled transaction may no longer meet quorum and - // must be re-validated. - if !updates.multisig_config_changes.is_empty() - && tx - .transaction - .multisig_accounts() - .iter() - .any(|account| updates.multisig_config_changes.contains(account)) - { - to_remove.push(*tx.hash()); - multisig_config_count += 1; - } } if to_remove.is_empty() { @@ -541,7 +524,6 @@ where blacklisted_count, unwhitelisted_count, insolvent_fee_payer_count, - multisig_config_count, paused_token_count, "Evicting invalidated or expired transactions" ); @@ -1793,68 +1775,6 @@ mod tests { assert!(pool.get(pooled.hash()).is_none()); } - #[tokio::test] - async fn evicts_multisig_transaction_when_its_config_rotates() { - let account = Address::random(); - let pooled = crate::test_utils::TxBuilder::aa(account).build_multisig(account); - - let provider = create_provider_with_tip(); - provider.add_account( - account, - ExtendedAccount::new(pooled.nonce(), *pooled.cost()), - ); - let pool = create_test_pool(provider); - add_validated(&pool, pooled.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![*pooled.hash()]); - assert!(pool.get(pooled.hash()).is_none()); - } - - #[tokio::test] - async fn evicts_multisig_transaction_when_nested_owner_config_rotates() { - let parent = Address::random(); - let child = Address::random(); - let pooled = crate::test_utils::TxBuilder::aa(parent).build_multisig_nested(parent, child); - - let provider = create_provider_with_tip(); - provider.add_account(parent, ExtendedAccount::new(pooled.nonce(), *pooled.cost())); - let pool = create_test_pool(provider); - add_validated(&pool, pooled.clone()); - - // Rotating the NESTED owner's config must evict the parent transaction, since the parent's - // authorization depends on the nested account's current threshold/owner weights. - let mut updates = crate::maintain::TempoPoolUpdates::new(); - updates.multisig_config_changes.insert(child); - - let evicted = pool.evict_invalidated_transactions(&updates); - assert_eq!(tx_hashes(&evicted), vec![*pooled.hash()]); - assert!(pool.get(pooled.hash()).is_none()); - } - - #[tokio::test] - async fn keeps_multisig_transaction_when_other_account_config_rotates() { - let account = Address::random(); - let pooled = crate::test_utils::TxBuilder::aa(account).build_multisig(account); - - let provider = create_provider_with_tip(); - provider.add_account( - account, - ExtendedAccount::new(pooled.nonce(), *pooled.cost()), - ); - let pool = create_test_pool(provider); - add_validated(&pool, pooled.clone()); - - let mut updates = crate::maintain::TempoPoolUpdates::new(); - updates.multisig_config_changes.insert(Address::random()); - - assert!(pool.evict_invalidated_transactions(&updates).is_empty()); - assert!(pool.get(pooled.hash()).is_some()); - } - #[tokio::test] async fn keeps_sponsored_keychain_transaction_on_spending_limit_invalidations() { let sender = Address::random(); From 7e33470ebf0a3a0727afb93aa330e131653ee37c Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:52:26 +0200 Subject: [PATCH 03/10] chore(pool): remove stale invalidation control flow --- crates/transaction-pool/src/tempo_pool.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 60c1f113a5..a3daa5945b 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -503,7 +503,6 @@ where { to_remove.push(*tx.hash()); user_token_count += 1; - continue; } } From 1918900d3b2f7c9a9077de1ac566c298f3713769 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:56:30 +0200 Subject: [PATCH 04/10] fix(pool): revalidate multisig registry dependencies --- crates/transaction-pool/src/maintain.rs | 121 +++++++++++++++++++-- crates/transaction-pool/src/transaction.rs | 31 ++++++ 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index a6f22386fd..4734314b48 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -101,6 +101,11 @@ pub struct TempoPoolUpdates { /// 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 { @@ -125,6 +130,7 @@ impl TempoPoolUpdates { && 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. @@ -246,18 +252,22 @@ impl TempoPoolUpdates { } // Native multisig owner-set / threshold rotations and initializations. else if log.address == NATIVE_MULTISIG_ADDRESS { - updates.record_native_multisig_config_change(log); + updates.record_native_multisig_config_change(log, false); } } updates } - fn record_native_multisig_config_change(&mut self, log: &Log) { + fn record_native_multisig_config_change(&mut self, log: &Log, reverted: bool) { let account = match NativeMultisigPoolEvent::decode(log) { Some(NativeMultisigPoolEvent::ConfigUpdated(event)) => event.account, - // Initialization also makes any pending duplicate bootstrap inadmissible. - Some(NativeMultisigPoolEvent::Initialized(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); @@ -273,7 +283,7 @@ impl TempoPoolUpdates { .flat_map(|receipt| &receipt.logs) .filter(|log| log.address == NATIVE_MULTISIG_ADDRESS) { - self.record_native_multisig_config_change(log); + self.record_native_multisig_config_change(log, true); } } @@ -291,11 +301,16 @@ impl TempoPoolUpdates { } fn affects_multisig_transaction(&self, transaction: &TempoPooledTransaction) -> bool { - !self.multisig_config_changes.is_empty() + (!self.multisig_config_changes.is_empty() && transaction .multisig_accounts() .iter() - .any(|account| self.multisig_config_changes.contains(account)) + .any(|account| self.multisig_config_changes.contains(account))) + || (!self.multisig_initializations.is_empty() + && transaction + .multisig_registry_dependencies() + .iter() + .any(|account| self.multisig_initializations.contains(account))) } /// Returns true if updates may invalidate keychain-signature transactions. @@ -683,7 +698,9 @@ where } } - if !updates.multisig_config_changes.is_empty() { + if !updates.multisig_config_changes.is_empty() + || !updates.multisig_initializations.is_empty() + { let hashes: Vec = { let all_txs = all_txs.get_or_insert_with(|| pool.all_transactions()); all_txs @@ -736,6 +753,7 @@ where 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 evicted = { @@ -821,6 +839,55 @@ mod tests { 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 { @@ -1250,11 +1317,16 @@ mod tests { }, ) .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], + logs: vec![log, initialized_log], }; let reverted = create_test_chain_with_receipts( vec![create_block_with_txs(1, vec![], vec![])], @@ -1271,6 +1343,37 @@ mod tests { 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. diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 8971bbb350..719fedb796 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -252,6 +252,37 @@ impl TempoPooledTransaction { accounts } + /// Returns identities whose later native multisig initialization would invalidate this + /// transaction's use of a registry-restricted role. + pub fn multisig_registry_dependencies(&self) -> Vec
{ + let mut accounts = Vec::new(); + + if let Some(aa_tx) = self.inner().as_aa() { + let tx = aa_tx.tx(); + if let Some(key_authorization) = &tx.key_authorization { + accounts.push(key_authorization.authorization.key_id); + } + if tx.fee_payer_signature.is_some() + && let Ok(fee_payer) = self.fee_payer() + { + accounts.push(fee_payer); + } + accounts.extend( + tx.tempo_authorization_list + .iter() + .filter_map(|authorization| authorization.recover_authority().ok()), + ); + } else if let Some(authorizations) = self.inner().authorization_list() { + accounts.extend( + authorizations + .iter() + .filter_map(|authorization| authorization.recover_authority().ok()), + ); + } + + accounts + } + /// 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 From a73ee864235eabcee75a4237bc135415e7495bab Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:41:46 +0200 Subject: [PATCH 05/10] refactor(pool): clarify multisig revalidation --- crates/transaction-pool/src/maintain.rs | 4 ++-- crates/transaction-pool/src/metrics.rs | 4 ++-- crates/transaction-pool/src/transaction.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index 4734314b48..ffa357b0ee 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -718,7 +718,7 @@ where removed_this_iteration.insert(*tx.hash()); } - metrics.multisig_config_revalidated.increment(count as u64); + metrics.multisig_revalidated.increment(count as u64); let pool_clone = pool.clone(); tokio::spawn(async move { @@ -732,7 +732,7 @@ where target: "txpool", total = count, success, - reason = "native multisig config update", + reason = "native multisig state update", "Re-validated transactions" ); }); diff --git a/crates/transaction-pool/src/metrics.rs b/crates/transaction-pool/src/metrics.rs index 480467793c..4ded578aad 100644 --- a/crates/transaction-pool/src/metrics.rs +++ b/crates/transaction-pool/src/metrics.rs @@ -102,6 +102,6 @@ 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 configuration changes. - pub multisig_config_revalidated: Counter, + /// Number of transactions re-validated due to native multisig state changes. + pub multisig_revalidated: Counter, } diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index 719fedb796..dc4e2efb58 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -225,7 +225,7 @@ impl TempoPooledTransaction { /// can invalidate an already-pooled transaction. All of them must be matched against on-chain /// config changes when deciding eviction. Recursion is bounded by `MAX_MULTISIG_NESTING_DEPTH`, /// which is enforced at decode time. - pub fn multisig_accounts(&self) -> Vec
{ + pub(crate) fn multisig_accounts(&self) -> Vec
{ fn collect(sig: &MultisigSignature, out: &mut Vec
) { out.push(sig.account()); for approval in sig.signatures() { @@ -254,7 +254,7 @@ impl TempoPooledTransaction { /// Returns identities whose later native multisig initialization would invalidate this /// transaction's use of a registry-restricted role. - pub fn multisig_registry_dependencies(&self) -> Vec
{ + pub(crate) fn multisig_registry_dependencies(&self) -> Vec
{ let mut accounts = Vec::new(); if let Some(aa_tx) = self.inner().as_aa() { From c548ef361b919afdefeab065e97302710b733b80 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:54:28 +0200 Subject: [PATCH 06/10] refactor(pool): avoid multisig dependency allocations --- crates/transaction-pool/src/maintain.rs | 10 +- crates/transaction-pool/src/transaction.rs | 116 ++++++++++----------- 2 files changed, 56 insertions(+), 70 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index ffa357b0ee..ebcaaa0996 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -302,15 +302,9 @@ impl TempoPoolUpdates { fn affects_multisig_transaction(&self, transaction: &TempoPooledTransaction) -> bool { (!self.multisig_config_changes.is_empty() - && transaction - .multisig_accounts() - .iter() - .any(|account| self.multisig_config_changes.contains(account))) + && transaction.depends_on_multisig_config(&self.multisig_config_changes)) || (!self.multisig_initializations.is_empty() - && transaction - .multisig_registry_dependencies() - .iter() - .any(|account| self.multisig_initializations.contains(account))) + && transaction.uses_multisig_restricted_account(&self.multisig_initializations)) } /// Returns true if updates may invalidate keychain-signature transactions. diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index dc4e2efb58..e52b28167a 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; @@ -36,8 +37,7 @@ use tempo_precompiles::{ use tempo_primitives::{ TempoTxEnvelope, transaction::{ - InvalidValidAfter, InvalidValidBefore, MultisigSignature, TempoSignature, - calc_gas_balance_spending, + InvalidValidAfter, InvalidValidBefore, MultisigSignature, calc_gas_balance_spending, }, }; use tempo_revm::{TempoInvalidTransaction, TempoTxEnv}; @@ -216,71 +216,61 @@ impl TempoPooledTransaction { }) } - /// Returns every native multisig account whose stored config this transaction's authorization - /// depends on across its outer and key-authorization signatures, including every nested - /// multisig owner account in either approval tree. - /// - /// The stateful verifier recurses into nested multisig owners and reloads their - /// threshold/owner weights, so a config rotation of the outer account *or any nested owner* - /// can invalidate an already-pooled transaction. All of them must be matched against on-chain - /// config changes when deciding eviction. Recursion is bounded by `MAX_MULTISIG_NESTING_DEPTH`, - /// which is enforced at decode time. - pub(crate) fn multisig_accounts(&self) -> Vec
{ - fn collect(sig: &MultisigSignature, out: &mut Vec
) { - out.push(sig.account()); - for approval in sig.signatures() { - if let TempoSignature::Multisig(nested) = approval { - collect(nested, out); - } - } + /// 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)) + }) } - let mut accounts = Vec::new(); - if let Some(aa_tx) = self.inner().as_aa() { - if let Some(multisig) = aa_tx.signature().as_multisig() { - collect(multisig, &mut accounts); - } - if let Some(multisig) = aa_tx - .tx() - .key_authorization - .as_ref() - .and_then(|authorization| authorization.signature.as_multisig()) - { - collect(multisig, &mut accounts); - } - } - 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 identities whose later native multisig initialization would invalidate this - /// transaction's use of a registry-restricted role. - pub(crate) fn multisig_registry_dependencies(&self) -> Vec
{ - let mut accounts = Vec::new(); - + /// 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 { if let Some(aa_tx) = self.inner().as_aa() { let tx = aa_tx.tx(); - if let Some(key_authorization) = &tx.key_authorization { - accounts.push(key_authorization.authorization.key_id); - } - if tx.fee_payer_signature.is_some() - && let Ok(fee_payer) = self.fee_payer() - { - accounts.push(fee_payer); - } - accounts.extend( - tx.tempo_authorization_list - .iter() - .filter_map(|authorization| authorization.recover_authority().ok()), - ); + 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))) + || tx.tempo_authorization_list.iter().any(|authorization| { + authorization + .recover_authority() + .is_ok_and(|authority| accounts.contains(&authority)) + }) } else if let Some(authorizations) = self.inner().authorization_list() { - accounts.extend( - authorizations - .iter() - .filter_map(|authorization| authorization.recover_authority().ok()), - ); + authorizations.iter().any(|authorization| { + authorization + .recover_authority() + .is_ok_and(|authority| accounts.contains(&authority)) + }) + } else { + false } - - accounts } /// Extracts the keychain subject for the signer of an inline `KeyAuthorization`. @@ -1210,12 +1200,14 @@ mod tests { let direct = TxBuilder::aa(account) .key_authorization(key_authorization(None)) .build_keychain(account, &access_key); - assert_eq!(direct.multisig_accounts(), vec![account]); + 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_eq!(nested.multisig_accounts(), vec![account, nested_account]); + assert!(nested.depends_on_multisig_config(&AddressSet::from_iter([account]))); + assert!(nested.depends_on_multisig_config(&AddressSet::from_iter([nested_account]))); } #[test] From f275e7ab66445c05ab6dfa8e857dc24c8101c9b9 Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:56:05 +0200 Subject: [PATCH 07/10] refactor(test): reuse AA transaction construction --- crates/transaction-pool/src/test_utils.rs | 144 +++++----------------- 1 file changed, 32 insertions(+), 112 deletions(-) diff --git a/crates/transaction-pool/src/test_utils.rs b/crates/transaction-pool/src/test_utils.rs index d97b616e7b..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,25 @@ 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(); - - let recovered = Recovered::new_unchecked(envelope, self.sender); - TempoPooledTransaction::new(recovered) + Self::build_aa_with_signature(tx, signature, sender) } /// Build an AA transaction with a native multisig outer signature for `account`. @@ -231,39 +240,11 @@ impl TxBuilder { pub(crate) fn build_multisig(self, account: Address) -> TempoPooledTransaction { use tempo_primitives::transaction::MultisigSignature; - 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(); let owner_signature = PrimitiveSignature::Secp256k1(Signature::test_signature()).to_bytes(); let signature = TempoSignature::Multisig(MultisigSignature::new(account, vec![owner_signature], None)); - let aa_signed = AASigned::new_unhashed(tx, signature); - let envelope: TempoTxEnvelope = aa_signed.into(); - - let recovered = Recovered::new_unchecked(envelope, account); - TempoPooledTransaction::new(recovered) + Self::build_aa_with_signature(tx, signature, account) } /// Build an AA transaction whose outer multisig `parent` has a single nested multisig owner @@ -275,47 +256,16 @@ impl TxBuilder { ) -> TempoPooledTransaction { use tempo_primitives::transaction::MultisigSignature; - 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, - }; - - // child multisig: one primitive owner approval. + 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)); - // parent multisig: its single owner approval is the nested child multisig. let parent_sig = TempoSignature::Multisig(MultisigSignature::new( parent, vec![child_sig.to_bytes()], None, )); - - let aa_signed = AASigned::new_unhashed(tx, parent_sig); - let envelope: TempoTxEnvelope = aa_signed.into(); - - let recovered = Recovered::new_unchecked(envelope, parent); - TempoPooledTransaction::new(recovered) + Self::build_aa_with_signature(tx, parent_sig, parent) } /// Build an AA transaction with a V2 keychain signature. @@ -340,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 = @@ -371,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) @@ -391,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(); From 23cc929c33e08a68e08526d523cdcdecfe304c3b Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:50:28 +0200 Subject: [PATCH 08/10] perf(pool): reuse recovered multisig authorities --- crates/transaction-pool/src/transaction.rs | 81 ++++++++++++++++++---- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/crates/transaction-pool/src/transaction.rs b/crates/transaction-pool/src/transaction.rs index e52b28167a..a9909493a2 100644 --- a/crates/transaction-pool/src/transaction.rs +++ b/crates/transaction-pool/src/transaction.rs @@ -21,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, @@ -248,6 +249,44 @@ impl TempoPooledTransaction { /// 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 @@ -257,19 +296,9 @@ impl TempoPooledTransaction { && self .fee_payer() .is_ok_and(|fee_payer| accounts.contains(&fee_payer))) - || tx.tempo_authorization_list.iter().any(|authorization| { - authorization - .recover_authority() - .is_ok_and(|authority| accounts.contains(&authority)) - }) - } else if let Some(authorizations) = self.inner().authorization_list() { - authorizations.iter().any(|authorization| { - authorization - .recover_authority() - .is_ok_and(|authority| accounts.contains(&authority)) - }) + || authorization_uses_restricted_account() } else { - false + authorization_uses_restricted_account() } } @@ -1210,6 +1239,34 @@ mod tests { 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(); From bc87b4222cd254307cd8505da4a97f52d50dda9e Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:55:33 +0200 Subject: [PATCH 09/10] perf(pool): fold multisig revalidation into invalidation scan --- crates/transaction-pool/src/maintain.rs | 74 ++++++++++------------- crates/transaction-pool/src/tempo_pool.rs | 48 ++++++++++++++- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index ebcaaa0996..8327810741 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -300,13 +300,20 @@ impl TempoPoolUpdates { || !self.key_authorization_witness_burns.is_empty() } - fn affects_multisig_transaction(&self, transaction: &TempoPooledTransaction) -> bool { + 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() @@ -692,47 +699,6 @@ where } } - if !updates.multisig_config_changes.is_empty() - || !updates.multisig_initializations.is_empty() - { - let hashes: Vec = { - let all_txs = all_txs.get_or_insert_with(|| pool.all_transactions()); - all_txs - .iter() - .filter(|tx| !removed_this_iteration.contains(tx.hash())) - .filter(|tx| updates.affects_multisig_transaction(&tx.transaction)) - .map(|tx| *tx.hash()) - .collect() - }; - if !hashes.is_empty() { - let removed_txs = pool.remove_transactions(hashes); - let count = removed_txs.len(); - - for tx in &removed_txs { - removed_this_iteration.insert(*tx.hash()); - } - - metrics.multisig_revalidated.increment(count as u64); - - let pool_clone = pool.clone(); - tokio::spawn(async move { - let txs = removed_txs - .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" - ); - }); - } - } - // 5. Evict expired and invalidated transactions in one pool traversal. let invalidation_start = Instant::now(); debug!( @@ -750,6 +716,7 @@ where 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( @@ -758,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()); diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index a3daa5945b..7cd05e1b47 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -141,18 +141,21 @@ where } 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 revalidate_multisig = multisig_revalidated.is_some() && updates.has_multisig_updates(); + if !updates.has_invalidation_events() && expiry_cutoff.is_none() && !revalidate_multisig { return Vec::new(); } @@ -227,6 +230,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 +248,11 @@ where let mut fee_balance_cache: HashMap<(Address, Address), U256> = HashMap::default(); for tx in transactions { + if revalidate_multisig && updates.affects_multisig_transaction(&tx.transaction) { + to_revalidate.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 +515,10 @@ where } } + if let Some(multisig_revalidated) = multisig_revalidated { + multisig_revalidated.extend(self.remove_transactions(to_revalidate)); + } + if to_remove.is_empty() { return Vec::new(); } @@ -1650,6 +1663,35 @@ 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()); + } + fn sponsored_keychain_transaction( sender: Address, fee_token: Address, From dadc5963fd7ff4a0271ffa1ebfafa1f582361aea Mon Sep 17 00:00:00 2001 From: joshieDo <93316087+joshieDo@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:09:15 +0200 Subject: [PATCH 10/10] fix(pool): handle multisig invalidation consistently --- crates/transaction-pool/src/tempo_pool.rs | 34 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/transaction-pool/src/tempo_pool.rs b/crates/transaction-pool/src/tempo_pool.rs index 7cd05e1b47..b7a10f4ddf 100644 --- a/crates/transaction-pool/src/tempo_pool.rs +++ b/crates/transaction-pool/src/tempo_pool.rs @@ -136,7 +136,7 @@ where &self, updates: &crate::maintain::TempoPoolUpdates, ) -> Vec>> { - if !updates.has_invalidation_events() { + if !updates.has_invalidation_events() && !updates.has_multisig_updates() { return Vec::new(); } @@ -154,8 +154,9 @@ where expiry_cutoff: Option, multisig_revalidated: Option<&mut Vec>>>, ) -> Vec>> { - let revalidate_multisig = multisig_revalidated.is_some() && updates.has_multisig_updates(); - if !updates.has_invalidation_events() && expiry_cutoff.is_none() && !revalidate_multisig { + 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(); } @@ -248,8 +249,12 @@ where let mut fee_balance_cache: HashMap<(Address, Address), U256> = HashMap::default(); for tx in transactions { - if revalidate_multisig && updates.affects_multisig_transaction(&tx.transaction) { - to_revalidate.push(*tx.hash()); + 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; } @@ -515,7 +520,9 @@ where } } - if let Some(multisig_revalidated) = multisig_revalidated { + if let Some(multisig_revalidated) = multisig_revalidated + && !to_revalidate.is_empty() + { multisig_revalidated.extend(self.remove_transactions(to_revalidate)); } @@ -1692,6 +1699,21 @@ mod tests { 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,